技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

JavaScript 防抖(debounce)与节流(throttle)

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/27-JavaScript 防抖(debounce)与节流(thorttle)
本文目录5 个章节
  1. 一、为什么需要限制高频调用
  2. 二、防抖(Debounce)
  3. 三、节流(Throttle)
  4. 四、如何选择
  5. 五、参考资料与原文

JavaScript 防抖(debounce)与节流(throttle)

Category(分类): JavaScript Status: 已整理(2026)

原文标题中的 thorttle 是拼写错误,本文只修正显示标题,不改文件名,以免影响已有路由或文章索引。

**防抖(debounce)节流(throttle)**都可以限制高频调用,但它们解决的问题不同:

  • 防抖关注“一段时间没有新调用之后再执行”,常用于只关心最后状态的搜索、自动保存和窗口尺寸变化;
  • 节流关注“在一个时间窗口内最多执行一次”,常用于以受控频率处理滚动、拖拽或指针移动;
  • 对视觉更新,requestAnimationFrame() 往往比固定毫秒节流更合适;对元素尺寸变化,可以优先考虑 ResizeObserver;对可见性,可以考虑 IntersectionObserver

它们只能控制发起调用的频率,不能自动保证异步请求按发起顺序返回,也不能替代缓存、取消请求、版本校验和正确的生命周期清理。

一、为什么需要限制高频调用

clickscrollresizeinputmousemovetouchmove 等事件可能在短时间内触发很多次。如果每次回调都执行昂贵的计算、读取和修改布局,或者都发起网络请求,就可能增加主线程工作和网络压力。

原文用“修改 DOM 会引起回流(Reflow)和重排(Repaint)”概括这一点,但术语和因果关系不够准确:

  • **布局/回流(layout/reflow)**是重新计算几何关系;
  • **绘制(paint/repaint)**是把元素绘制到图层;
  • **合成(composite)**是把图层合成为最终画面;
  • 一次 DOM 修改不必然触发整页布局或重绘,真正的开销应通过浏览器 Performance 面板测量;
  • “写入布局属性后立刻读取布局信息”可能触发强制同步布局,批量读写和合理的帧调度通常更重要。

如果回调会请求后端,防抖/节流也不是并发控制方案。旧请求可能在新请求之后返回,应该配合 AbortController、请求序列号或服务端版本号,只应用当前版本的结果。

二、防抖(Debounce)

防抖:连续调用发生时不断重新计时;只有在指定时间内没有新的调用,才执行一次。下面的通用实现默认支持尾触发,也可以配置为首触发。

原文中的第一版是“停止触发后执行”(尾触发防抖),第二版是“第一次立即执行,等待窗口结束”(首触发防抖)。这两种行为都合理,但必须明确是否执行最后一次调用。

原文历史配图:防抖示意图

图片来源:原文 Juejin 图床资源(2018 年前后),仅保留作历史配图,不代表当前库的实现或性能结论。

2.1 一个可取消、可 flush 的防抖实现

function debounce(func, wait = 0, options = {}) {
  if (typeof func !== 'function') {
    throw new TypeError('func 必须是函数')
  }

  const delay = Math.max(0, wait)
  const { leading = false, trailing = true } = options
  let timer = null
  let lastArgs
  let lastThis
  let result

  const invoke = () => {
    const args = lastArgs
    const thisArg = lastThis
    lastArgs = undefined
    lastThis = undefined
    result = func.apply(thisArg, args)
    return result
  }

  const later = () => {
    timer = null
    if (trailing && lastArgs) invoke()
    lastArgs = undefined
    lastThis = undefined
  }

  function debounced(...args) {
    const callNow = leading && timer === null
    lastArgs = args
    lastThis = this

    if (timer !== null) clearTimeout(timer)
    timer = setTimeout(later, delay)

    if (callNow) return invoke()
    return result
  }

  debounced.cancel = () => {
    if (timer !== null) clearTimeout(timer)
    timer = null
    lastArgs = undefined
    lastThis = undefined
  }

  debounced.flush = () => {
    if (timer === null) return result
    clearTimeout(timer)
    timer = null
    if (trailing && lastArgs) invoke()
    lastArgs = undefined
    lastThis = undefined
    return result
  }

  return debounced
}

这个实现的选项含义如下:

  • leading: false, trailing: true:默认尾触发,停止调用后执行最后一次;
  • leading: true, trailing: false:首触发,窗口内调用被忽略;
  • leading: true, trailing: true:第一次立即执行,窗口内如果有新调用,窗口结束时再执行最后一次;
  • cancel():取消待执行的调用,组件卸载或页面销毁时应调用;
  • flush():立即执行当前待处理的尾调用。

2.2 搜索建议:防抖不等于请求结果排序

搜索框应监听 input,这样可以覆盖键盘输入、粘贴和自动填充等情况。输入法组合输入还应根据产品需要处理 compositionstart/compositionend

const input = document.querySelector('#search')
const resultList = document.querySelector('#results')

let revision = 0
let controller = null

const search = debounce(async query => {
  const currentRevision = revision

  controller?.abort()
  controller = new AbortController()

  try {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal
    })
    if (!response.ok) throw new Error(`HTTP ${response.status}`)

    const data = await response.json()

    // 即使旧请求没有及时响应,也不让它覆盖新输入的结果
    if (currentRevision !== revision) return
    resultList.replaceChildren(
      ...data.items.map(item => {
        const element = document.createElement('li')
        element.textContent = item.title
        return element
      })
    )
  } catch (error) {
    if (error.name !== 'AbortError' && currentRevision === revision) {
      console.error('搜索失败', error)
    }
  }
}, 250)

input.addEventListener('input', event => {
  revision += 1
  const query = event.target.value.trim()
  if (!query) {
    controller?.abort()
    resultList.replaceChildren()
    search.cancel()
    return
  }
  search(query)
})

// 在组件卸载或页面销毁时执行
window.addEventListener('pagehide', () => {
  search.cancel()
  controller?.abort()
})

这里的 250ms 只是示例,实际值应通过接口延迟、输入体验和服务端负载测试确定。自动保存还应在 pagehide 或应用销毁前使用 flush()/同步持久化策略,不能只依赖一个未来的定时器。

2.3 其他防抖场景

  • 窗口尺寸变化:连续调整窗口时只在停止调整后重新布局;元素自身尺寸变化则优先考虑 ResizeObserver
  • 自动保存:用户停止编辑后保存草稿,但需要处理页面关闭、网络失败和保存版本冲突;
  • 校验和格式化:在用户停止输入后校验,避免每个字符都触发昂贵操作;
  • 搜索建议:减少请求次数,同时必须处理请求取消和过期结果;
  • 不要把“几秒钟”当作固定建议:搜索建议通常需要经过 UX 验证的短窗口,而不是盲目等待数秒。

三、节流(Throttle)

节流:无论在时间窗口内调用多少次,函数最多按指定频率执行。实现可以选择首触发、尾触发或两者同时启用。

原文历史配图:节流示意图

图片来源:原文 Juejin 图床资源(2018 年前后),仅保留作历史配图。

3.1 使用单调时钟的节流实现

Date.now() 可能受到系统时钟校正影响。计算持续时间时可以使用 performance.now();它是相对页面时间 origin 的单调时钟,更适合测量间隔。下面实现支持首触发、尾触发、cancel()flush()

function throttle(func, wait = 0, options = {}) {
  if (typeof func !== 'function') {
    throw new TypeError('func 必须是函数')
  }

  const delay = Math.max(0, wait)
  const { leading = true, trailing = true } = options
  let timer = null
  let lastInvokeTime = null
  let lastArgs
  let lastThis
  let result

  const invoke = now => {
    lastInvokeTime = now
    const args = lastArgs
    const thisArg = lastThis
    lastArgs = undefined
    lastThis = undefined
    result = func.apply(thisArg, args)
    return result
  }

  const later = () => {
    timer = null
    if (trailing && lastArgs) invoke(performance.now())
    lastArgs = undefined
    lastThis = undefined
  }

  function throttled(...args) {
    const now = performance.now()
    if (lastInvokeTime === null && !leading) lastInvokeTime = now

    lastArgs = args
    lastThis = this
    const remaining = lastInvokeTime === null ? 0 : delay - (now - lastInvokeTime)

    if (remaining <= 0 || remaining > delay) {
      if (timer !== null) {
        clearTimeout(timer)
        timer = null
      }
      invoke(now)
    } else if (timer === null && trailing) {
      timer = setTimeout(later, remaining)
    }

    return result
  }

  throttled.cancel = () => {
    if (timer !== null) clearTimeout(timer)
    timer = null
    lastInvokeTime = null
    lastArgs = undefined
    lastThis = undefined
  }

  throttled.flush = () => {
    if (timer === null) return result
    clearTimeout(timer)
    timer = null
    if (trailing && lastArgs) invoke(performance.now())
    lastArgs = undefined
    lastThis = undefined
    return result
  }

  return throttled
}

原文的两个节流版本分别是“定时器首触发”和“时间戳首触发”,它们都会丢弃窗口内的中间调用,也没有尾触发和取消接口。实际使用时应明确这些语义,而不要只写“节流会处理所有中间状态”。

3.2 滚动与指针移动:先判断任务类型

如果任务只是按浏览器绘制节奏更新视觉状态,requestAnimationFrame() 通常比固定 16.6ms 节流更贴合渲染时机。屏幕可能是 60Hz、90Hz、120Hz 或更高,后台页面还可能被降频,因此 16.6ms 只是 60Hz 的理论帧间隔,不是浏览器保证的固定预算。

let frameId = null
let latestScrollY = window.scrollY

function updateIndicator(scrollY) {
  document.querySelector('#progress').style.transform =
    `scaleX(${Math.min(1, scrollY / document.body.scrollHeight)})`
}

function scheduleScrollUpdate() {
  latestScrollY = window.scrollY
  if (frameId !== null) return

  frameId = requestAnimationFrame(() => {
    frameId = null
    updateIndicator(latestScrollY)
  })
}

window.addEventListener('scroll', scheduleScrollUpdate, { passive: true })

// 页面或组件销毁时
function cleanup() {
  window.removeEventListener('scroll', scheduleScrollUpdate)
  if (frameId !== null) cancelAnimationFrame(frameId)
}

passive: true 表示监听器不会调用 preventDefault(),浏览器可以更早处理滚动;如果确实需要阻止默认滚动,不能把该监听器声明为 passive。对于不需要每帧处理的业务逻辑,仍可以使用普通节流。

元素尺寸变化示例:

const panel = document.querySelector('.panel')
const observer = new ResizeObserver(entries => {
  for (const entry of entries) {
    console.log('新的内容盒尺寸:', entry.contentRect.width, entry.contentRect.height)
  }
})

observer.observe(panel)
// 不再需要时:observer.disconnect()

3.3 其他节流场景

  • 拖拽、pointermovemousemovetouchmove 中的昂贵计算;
  • 游戏或模拟程序中按受控频率处理输入;
  • 滚动事件中的非视觉业务逻辑;
  • 上传进度、日志和遥测上报,但应同时考虑批量和丢弃策略。

滚动处理并不是“必须使用节流”:有些任务适合 requestAnimationFrame,有些任务适合 IntersectionObserver,有些任务只需要 CSS。应先测量回调本身的工作量和长任务,再选择方案。

四、如何选择

A debounce is utilized when you only care about the final state. A throttle is best used when you want to handle all intermediate states but at a controlled rate.

可以这样理解:

  • 只关心一轮连续操作的最终状态:优先考虑防抖;
  • 需要持续响应中间状态,但可以降低频率:考虑节流;
  • 需要与屏幕绘制同步:优先考虑 requestAnimationFrame
  • 需要观察尺寸或可见性变化:考虑 ResizeObserverIntersectionObserver
  • 需要避免旧网络结果覆盖新结果:增加取消、序列号或版本校验。

延迟和频率如何确定

原文历史配图:帧预算示意图

图片来源:原文 Juejin 图床资源(2018 年前后),仅保留作历史配图。

不要把所有设备都按 60FPS 处理。回调必须在实际帧预算内完成,帧预算还包括样式计算、布局、绘制和合成;如果回调超过预算,就可能产生长任务和丢帧。使用 Performance 面板、Long Tasks、requestAnimationFrame 时间戳和真实设备测试,比背诵 16.6ms 更可靠。

五、参考资料与原文

本地图片来源:

  • images/27-image-01.webp:原文 Juejin 图床资源,资源标识 b279fd94627d4ba48e676fbd0c6769d8
  • images/27-image-02.webp:原文 Juejin 图床资源,资源标识 34e5e1e7d2c242fdb337eba492d6f39b
  • images/27-image-03.webp:原文 Juejin 图床资源,资源标识 1a2280bf9dd843619958262120ea57af
457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS