如何手写防抖和节流?它们有什么区别?
简化版
防抖是在连续触发停止一段时间后执行,适合搜索输入、窗口尺寸变化;节流是在连续触发期间按固定间隔最多执行一次,适合滚动、拖拽和采样上报。二者都要保留调用时的 this 与参数,并明确 leading、trailing、取消等行为。
详细版
基础防抖每次触发都重置定时器:
function debounce(fn, wait) {
let timer = null
return function (...args) {
const context = this
clearTimeout(timer)
timer = setTimeout(() => {
timer = null
fn.apply(context, args)
}, wait)
}
}
基础节流可以记录上次执行时间,只有间隔达到 wait 才执行。工程实现还应回答:首次是否立即执行、末次是否补执行、如何 cancel、返回值如何处理,以及组件卸载时怎样清理定时器。
完整版教学
一、用时间线区分两者
假设 wait = 100ms,事件在 0、30、60、180ms 触发:
| 策略 | 一种常见配置下的执行时刻 | 关注点 |
|---|---|---|
| trailing 防抖 | 160ms、280ms | 等输入安静下来 |
| leading 防抖 | 0ms、180ms | 每轮首个事件立即响应 |
| leading 节流 | 0ms、180ms | 每个窗口最多一次 |
| leading + trailing 节流 | 0ms、100ms、180ms | 高频期间持续更新,并补末次 |
防抖会把等待窗口不断后移;节流不会因为每次新事件而无限推迟执行。
“固定时间内只执行一次”描述不够精确:防抖和节流都有 leading、trailing 组合。面试前先约定配置,否则两份不同代码都可能自洽。
二、实现可取消、可立即执行的防抖
下面版本约定默认 trailing 执行,可选 leading,并提供取消能力:
function debounce(fn, wait, { leading = false, trailing = true } = {}) {
if (typeof fn !== 'function') throw new TypeError('fn must be a function')
if (!leading && !trailing) throw new TypeError('one edge must be enabled')
let timer = null
let lastArgs
let lastThis
let result
function invoke() {
const args = lastArgs
const context = lastThis
lastArgs = lastThis = undefined
result = Reflect.apply(fn, context, args)
return result
}
function later() {
timer = null
if (trailing && lastArgs) invoke()
}
function debounced(...args) {
const shouldCallNow = leading && timer === null
lastArgs = args
lastThis = this
clearTimeout(timer)
timer = setTimeout(later, wait)
if (shouldCallNow) invoke()
return result
}
debounced.cancel = () => {
clearTimeout(timer)
timer = null
lastArgs = lastThis = undefined
}
return debounced
}
leading 执行后仍设置定时器,是为了形成冷却窗口。连续触发时,只有保存的最后一组参数会用于 trailing 调用。
三、实现带末次补偿的节流
只比较 Date.now() 的基础版会丢掉窗口末尾事件。下面实现 leading 与 trailing 都开启:
function throttle(fn, wait) {
if (typeof fn !== 'function') throw new TypeError('fn must be a function')
let previous = 0
let timer = null
let lastArgs
let lastThis
function invoke(time) {
previous = time
const args = lastArgs
const context = lastThis
lastArgs = lastThis = undefined
Reflect.apply(fn, context, args)
}
function throttled(...args) {
const now = Date.now()
if (previous === 0) previous = now - wait
const remaining = wait - (now - previous)
lastArgs = args
lastThis = this
if (remaining <= 0 || remaining > wait) {
clearTimeout(timer)
timer = null
invoke(now)
} else if (timer === null) {
timer = setTimeout(() => {
timer = null
invoke(Date.now())
}, remaining)
}
}
throttled.cancel = () => {
clearTimeout(timer)
timer = null
previous = 0
lastArgs = lastThis = undefined
}
return throttled
}
remaining > wait 用来应对系统时钟向后调整。需要更稳定的耗时测量时可使用单调时钟 performance.now(),但它与 Date.now() 的时间基准不同,不能混算。
四、参数、this 与返回值语义
包装器必须在触发时保存 this 和参数,而不是创建包装器时保存:
const search = {
prefix: 'q=',
run(keyword) {
console.log(this.prefix + keyword)
},
}
search.delayed = debounce(search.run, 200)
search.delayed('java') // 最终仍需输出 q=java
异步的 trailing 调用不能同步把“未来的返回值”交给当前调用者。上面防抖实现返回最近一次已执行结果,这与一些工具库一致,但不是唯一合同。若业务需要等待本次结果,应专门设计返回 Promise 的版本,并定义被后续调用取消时旧 Promise 是拒绝还是共享最终结果。
五、取消、卸载和异常边界
组件卸载、路由切换或请求失效时必须调用 cancel(),否则闭包仍引用参数、DOM 或组件实例,并可能在卸载后执行副作用。
定时器回调中抛出的异常发生在新的异步任务里,外层调用点的 try/catch 捕获不到。生产代码要在真正执行 fn 的位置处理异常,或让业务监控捕获全局异步错误。
若回调内部再次调用包装函数,还会产生重入。本文在调用原函数前更新定时状态和清理参数,避免旧参数在重入过程中被误用。
六、性能、定时精度与选择原则
浏览器定时器只保证“不早于”指定延迟,主线程繁忙、后台标签页限频都会让实际执行更晚。节流不是实时调度器,也不能保证每 100ms 精准执行。
| 场景 | 推荐 | 原因 |
|---|---|---|
| 搜索联想 | trailing 防抖 | 只关心最终输入 |
| 防止重复提交 | leading 防抖并禁用按钮 | 首次立即响应 |
| 滚动位置同步 | 节流或 requestAnimationFrame | 高频期间持续更新 |
| 窗口 resize 后重算布局 | trailing 防抖 | 等变化结束再做重活 |
涉及绘制时,requestAnimationFrame 往往比固定毫秒节流更贴合浏览器刷新节奏。
七、常见误区与追问
- 误区:防抖等于最后一次事件执行。 若配置 leading 且关闭 trailing,它执行的是一轮中的第一次。
- 误区:节流只需丢弃窗口内所有后续事件。 业务常需要 trailing 补偿,否则会漏掉最终状态。
- 误区:
setTimeout(fn, 100)会在 100ms 精确执行。 它只保证达到最小延迟,还受任务队列和主线程阻塞影响。 - 追问:为什么要保存最后一次参数? trailing 执行应反映窗口内最新状态,而不是第一次触发时的旧数据。
- 追问:组件销毁时做什么? 调用
cancel()清理定时器和闭包引用,避免卸载后更新状态。 - 追问:节流能否由防抖实现? 支持
maxWait的防抖可以表达节流,但需要可靠处理最大等待时间和双边触发。 - 追问:滚动动画为什么常用
requestAnimationFrame? 它跟随渲染帧调度,减少同一帧重复绘制,比任意毫秒间隔更合适。
八、加强记忆
- 防抖:不断推迟窗口,安静后再执行。
- 节流:限制执行频率,高频期间仍可周期执行。
- 配置:leading 决定首端,trailing 决定末端。
- 上下文:保存最后一次
this与参数,执行后及时释放。 - 生命周期:提供
cancel,卸载时必须清理。 - 精度:定时器不是实时保证,绘制场景优先考虑
requestAnimationFrame。