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

显示模式

登录
ARCHIVE DOCUMENTJS

7 分钟理解 JS 的节流、防抖及使用场景

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/08-7分钟理解JS的节流、防抖及使用场景
本文目录8 个章节
  1. 前言
  2. 原文示意图
  3. 一、函数防抖 debounce
  4. 二、函数节流 throttle
  5. 三、视觉更新:requestAnimationFrame 节流
  6. 四、使用场景
  7. 五、总结
  8. 参考资料

7 分钟理解 JS 的节流、防抖及使用场景

Category(分类): JavaScript Status: 已更新

原文作者:薄荷前端

历史文章:7 分钟理解 JS 的节流、防抖及使用场景

原文写于 2018 年,保留了“输入事件、定时器和高频回调”的学习路线,并修正了原文抓取造成的代码粘连、共享计时器、参数丢失和节流重复调用问题。现代实现还需要考虑 leadingtrailingcancel()flush()、请求取消和浏览器调度限制。

前言

滚动、输入、窗口变化、指针移动等事件可能在很短时间内触发很多次。如果每次事件都执行昂贵逻辑,可能造成:

  • 重复发送搜索请求;
  • 重复计算布局或筛选结果;
  • 大量脚本执行挤占渲染时间;
  • 页面响应延迟,甚至出现掉帧。

防抖(debounce)和节流(throttle)都是控制调用频率的封装方式,但目标不同:

  • 防抖:等一段时间没有新的调用后再执行,关注“这一连串操作什么时候结束”;
  • 节流:在持续调用期间限制最大执行频率,关注“单位时间最多执行多少次”。

它们不会让函数本身变成异步函数,也不会自动取消已经发出的网络请求。

原文示意图

原文:未防抖的输入框示例

原文:原始输入与控制台示例

一、函数防抖 debounce

1. 基本概念

在事件被触发后等待 delay 毫秒;如果等待期间再次触发,就重新计时。默认在最后一次调用之后执行一次。

例如搜索联想:用户还在输入时,不必为每个字符立即请求服务端;用户暂停输入后再发送请求。

function ajaxSearch(keyword) {
  console.log('request:', keyword)
}

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

input?.addEventListener('input', event => {
  ajaxSearch(event.target.value)
})

上面的代码每输入一个字符都会执行一次。下面是一个具有 cancel()flush() 的基础防抖实现:

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

  const leading = options.leading === true
  const trailing = options.trailing !== false
  let timer = null
  let lastArgs
  let lastThis
  let result

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

  function later() {
    timer = null
    if (trailing && lastArgs !== undefined) {
      invoke()
    } else {
      lastArgs = undefined
      lastThis = undefined
    }
  }

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

    if (timer !== null) {
      clearTimeout(timer)
    }
    timer = setTimeout(later, Math.max(0, wait))

    if (callNow) {
      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 !== undefined) {
      return invoke()
    }
    lastArgs = undefined
    lastThis = undefined
    return result
  }

  return debounced
}

const debouncedSearch = debounce(ajaxSearch, 500)
input?.addEventListener('input', event => {
  debouncedSearch(event.target.value)
})

这里的计时器保存在每个包装器自己的闭包中,而不是挂在原函数的 id 属性上。这样创建多个防抖包装器时不会互相取消,也不会污染原函数。

包装器使用 ...argsfn.apply(thisArg, args),可以保留调用时的全部参数和 this

const save = debounce(function (key, value) {
  console.log(this.name, key, value)
}, 300)

const service = { name: 'settings', save }
service.save('theme', 'dark')

2. leading、trailing 和 cancel

常见选项如下:

  • leading: false:默认不在第一次调用时立即执行;
  • leading: true:这一轮调用开始时立即执行一次;
  • trailing: true:等待结束后执行最后一次调用,通常是默认行为;
  • trailing: false:只执行 leading,不执行等待结束后的尾调用;
  • cancel():取消尚未执行的尾调用并清理引用;
  • flush():立即执行尚未执行的尾调用。

如果 leadingtrailing 同时开启,是否在只有一次调用时执行尾调用,需要在实现或库文档中明确。不同库的边界语义可能不同,不能只看函数名判断行为。

原文:防抖后的输入框示例

3. 防抖不能取消网络请求

防抖只能延迟“发起请求”的动作。如果旧请求已经发出,新的输入不会自动让旧请求失效。搜索建议通常还要使用 AbortController

const input = document.querySelector('#search')
let controller
let requestId = 0

const search = debounce(async (keyword, currentRequestId) => {
  const currentController = new AbortController()
  controller = currentController

  try {
    const response = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`, {
      signal: currentController.signal
    })

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`)
    }

    const result = await response.json()
    if (currentRequestId === requestId) {
      renderSuggestions(result)
    }
  } catch (error) {
    if (error.name !== 'AbortError') {
      console.error(error)
    }
  }
}, 300)

input?.addEventListener('input', event => {
  requestId += 1
  controller?.abort()
  search(event.target.value, requestId)
})

function renderSuggestions(result) {
  console.log(result)
}

在组件卸载或页面离开时,还应调用 search.cancel() 并中止当前请求。即使使用了 AbortController,也要考虑服务端已经处理请求、响应与取消信号竞争等情况。

4. 原文中的 setInterval(debounce(...))

原文使用下面的例子说明:当调用间隔小于防抖等待时间时,函数可能一直不执行:

function logBiu() {
  console.log('biu biu biu')
}

function logBoom() {
  console.log('boom boom boom')
}

const debouncedBiu = debounce(logBiu, 500)
const debouncedBoom = debounce(logBoom, 2000)

const biuTimer = setInterval(debouncedBiu, 1000)
const boomTimer = setInterval(debouncedBoom, 1000)

setTimeout(() => {
  clearInterval(biuTimer)
  clearInterval(boomTimer)
  debouncedBiu.cancel()
  debouncedBoom.cancel()
}, 5000)

debouncedBoom 每 1 秒被调用一次,但等待时间为 2 秒,每次调用都会重新计时,因此在停止 setInterval 前可能没有尾调用。这个例子适合演示机制,不适合用定时器精确测量时间。

原文:防抖示例的控制台截图

二、函数节流 throttle

1. 基本概念

在一个时间窗口内限制函数最多执行一次。持续触发时,通常按 leadingtrailing 选项决定窗口两端是否执行。

节流适合持续滚动、拖动、指针移动等场景,但如果每次执行都要更新界面,优先考虑 requestAnimationFrame 版本。

下面是一个支持 leading、trailing、cancel()flush() 的教学实现:

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

  const leading = options.leading !== false
  const trailing = options.trailing !== false
  const delay = Math.max(0, wait)
  let hasInvoked = false
  let lastInvokeTime = 0
  let timer = null
  let lastArgs
  let lastThis
  let result

  function invoke(time) {
    hasInvoked = true
    lastInvokeTime = time
    const args = lastArgs
    const thisArg = lastThis
    lastArgs = undefined
    lastThis = undefined
    result = fn.apply(thisArg, args)
    return result
  }

  function trailingInvoke() {
    timer = null
    if (trailing && lastArgs !== undefined) {
      invoke(Date.now())
    } else {
      lastArgs = undefined
      lastThis = undefined
    }
  }

  function throttled(...args) {
    const now = Date.now()
    lastArgs = args
    lastThis = this

    // leading:false 的第一个调用只记录参数,并等待尾调用。
    if (!hasInvoked) {
      if (leading) {
        invoke(now)
      } else if (trailing && timer === null) {
        timer = setTimeout(trailingInvoke, delay)
      }
      return result
    }

    const remaining = delay - (now - lastInvokeTime)
    if (remaining <= 0) {
      if (timer !== null) {
        clearTimeout(timer)
        timer = null
      }

      if (leading) {
        invoke(now)
      } else if (trailing) {
        // 上一轮尾调用已经结束;leading:false 的新一轮仍需等待。
        timer = setTimeout(trailingInvoke, delay)
      } else {
        lastArgs = undefined
        lastThis = undefined
      }
    } else if (timer === null && trailing) {
      timer = setTimeout(trailingInvoke, remaining)
    }

    return result
  }

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

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

  return throttled
}

const throttledSearch = throttle(value => {
  console.log('latest value:', value)
}, 1000)

input?.addEventListener('input', event => {
  throttledSearch(event.target.value)
})

与原文实现相比,这个版本不会把定时器挂在原函数上,也不会在已有尾定时器的情况下再次安排一个尾调用。实际项目可以使用经过测试的库实现,但必须阅读其 leadingtrailingcancelflush 语义。

原文:节流输入框示例

原文:节流示例的控制台截图

2. 时间并不精确

setTimeout()setInterval() 只是向宿主请求“最早不早于某个时间”执行任务,并不保证精确时刻:

  • 当前 JavaScript 任务执行时间过长时,计时器会延迟;
  • 浏览器后台标签页可能对计时器限速;
  • 渲染、系统调度和事件循环都会影响实际执行时间;
  • Date.now() 可能受到系统时钟调整,性能测量可考虑 performance.now()

因此“每 1 秒执行一次”更准确的说法是“在约定窗口内限制执行频率,实际执行时间可能晚于窗口”。

原文还指出,下面这段经典代码虽然命名为 throttle,按行为更接近 trailing debounce:

function debounceByTimer(method, context) {
  clearTimeout(method.timerId)
  method.timerId = setTimeout(() => {
    method.call(context)
  }, 100)
}

函数本身可以工作,但命名会误导读者。计时器应放在包装器闭包中,并且现代实现通常还需要转发参数、保存 this 和提供取消方法。

原文:旧版 throttle 定时器代码截图

三、视觉更新:requestAnimationFrame 节流

如果目标是把滚动或指针数据用于绘制,按显示器刷新节奏更新通常比固定毫秒值更合适:

function rafThrottle(fn) {
  let frame = null
  let lastArgs
  let lastThis

  function throttled(...args) {
    lastArgs = args
    lastThis = this

    if (frame !== null) {
      return
    }

    frame = requestAnimationFrame(() => {
      frame = null
      const argsToUse = lastArgs
      const thisToUse = lastThis
      lastArgs = undefined
      lastThis = undefined
      fn.apply(thisToUse, argsToUse)
    })
  }

  throttled.cancel = () => {
    if (frame !== null) {
      cancelAnimationFrame(frame)
    }
    frame = null
    lastArgs = undefined
    lastThis = undefined
  }

  return throttled
}

const updateIndicator = rafThrottle(event => {
  console.log(event.clientX, event.clientY)
})

window.addEventListener('pointermove', updateIndicator, { passive: true })

requestAnimationFrame() 的回调会在下一次浏览器绘制前执行。它适合视觉更新,但不适合替代所有定时器;页面隐藏时浏览器也可能暂停或降低回调频率。

四、使用场景

debounce

  • 搜索联想、筛选和校验:输入暂停后再处理;
  • 自动保存:用户停止编辑一段时间后保存;
  • resize 后重新计算复杂布局;
  • 需要等待一连串操作结束的日志或校验。

throttle

  • 持续滚动时采样位置;
  • 拖拽或指针移动时限制计算频率;
  • 轮询式 UI 更新;
  • 限制按钮、鼠标或触摸事件触发频率。

现代 API 的优先选择:

  • 无限滚动触底:优先使用 IntersectionObserver
  • 元素尺寸变化:优先使用 ResizeObserver
  • 视觉绘制:优先使用 requestAnimationFrame
  • 滚动监听:如果不需要阻止默认滚动,可考虑 { passive: true }
  • 输入请求:防抖只减少发起次数,仍需处理请求取消、竞态和错误。

五、总结

  • 防抖是在距离最后一次调用达到等待时间后执行,适合“等待用户停止操作”;
  • 节流是在持续调用期间限制最大频率,适合“持续操作中定期处理”;
  • leadingtrailing 决定边界调用,cancel()flush() 决定生命周期控制;
  • 回调包装器要保留完整参数和 this,不能把计时器放到原函数对象上;
  • 定时器不是精确时钟,不能用“恰好每秒执行”描述实际行为;
  • 防抖和节流都不是请求取消机制,需要时配合 AbortController
  • 先确认问题是事件频率、渲染节奏、网络竞态还是组件生命周期,再选择对应 API。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS