← 返回题目列表

如何手写防抖和节流?它们有什么区别?

高频 中等 第 10 / 27 题 更新于 2026/07/28
手写代码防抖节流性能优化

简化版

防抖是在连续触发停止一段时间后执行,适合搜索输入、窗口尺寸变化;节流是在连续触发期间按固定间隔最多执行一次,适合滚动、拖拽和采样上报。二者都要保留调用时的 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 它跟随渲染帧调度,减少同一帧重复绘制,比任意毫秒间隔更合适。

八、加强记忆

  1. 防抖:不断推迟窗口,安静后再执行。
  2. 节流:限制执行频率,高频期间仍可周期执行。
  3. 配置:leading 决定首端,trailing 决定末端。
  4. 上下文:保存最后一次 this 与参数,执行后及时释放。
  5. 生命周期:提供 cancel,卸载时必须清理。
  6. 精度:定时器不是实时保证,绘制场景优先考虑 requestAnimationFrame