7 分钟理解 JS 的节流、防抖及使用场景
Category(分类): JavaScript Status: 已更新
原文作者:薄荷前端
原文写于 2018 年,保留了“输入事件、定时器和高频回调”的学习路线,并修正了原文抓取造成的代码粘连、共享计时器、参数丢失和节流重复调用问题。现代实现还需要考虑
leading、trailing、cancel()、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 属性上。这样创建多个防抖包装器时不会互相取消,也不会污染原函数。
包装器使用 ...args 和 fn.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():立即执行尚未执行的尾调用。
如果 leading 和 trailing 同时开启,是否在只有一次调用时执行尾调用,需要在实现或库文档中明确。不同库的边界语义可能不同,不能只看函数名判断行为。

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. 基本概念
在一个时间窗口内限制函数最多执行一次。持续触发时,通常按
leading和trailing选项决定窗口两端是否执行。
节流适合持续滚动、拖动、指针移动等场景,但如果每次执行都要更新界面,优先考虑 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)
})
与原文实现相比,这个版本不会把定时器挂在原函数上,也不会在已有尾定时器的情况下再次安排一个尾调用。实际项目可以使用经过测试的库实现,但必须阅读其 leading、trailing、cancel 和 flush 语义。


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 和提供取消方法。

三、视觉更新: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 }; - 输入请求:防抖只减少发起次数,仍需处理请求取消、竞态和错误。
五、总结
- 防抖是在距离最后一次调用达到等待时间后执行,适合“等待用户停止操作”;
- 节流是在持续调用期间限制最大频率,适合“持续操作中定期处理”;
leading、trailing决定边界调用,cancel()和flush()决定生命周期控制;- 回调包装器要保留完整参数和
this,不能把计时器放到原函数对象上; - 定时器不是精确时钟,不能用“恰好每秒执行”描述实际行为;
- 防抖和节流都不是请求取消机制,需要时配合
AbortController; - 先确认问题是事件频率、渲染节奏、网络竞态还是组件生命周期,再选择对应 API。