Skip to content
节流
概述
节流(throttle)控制高频调用的执行频率:在固定的时间窗口内,最多允许目标函数执行一次。与防抖(debounce)常被并提,但两者的重置逻辑正好相反——防抖在每次调用后重新计时,而节流以固定周期为单位,周期内至少执行一次。
基本概念
给定一个函数 fn 和间隔 t(毫秒),节流版本会按以下规则决定是否执行 fn:
- 调用到达,如果距离上一次执行已经过去了至少
t毫秒,则立即执行; - 否则不立即执行,但会在冷却结束后执行这段时间内最后到达的那一次调用。
这样即使调用频率远高于 1/t,实际执行频率也稳定在最高 1/t 次/秒,且传入的参数不会丢失到“中间值”上。
与防抖的核心区别:
| 节流 | 防抖 | |
|---|---|---|
| 连续调用时 | 每隔 t 执行一次(或至少一次) | 只执行最后一次 |
| 适用场景 | 滚动、resize、鼠标移动等需要持续反馈 | 搜索框联想、表单校验等期望结果稳定的场景 |
工作原理
以下实现基于 LeetCode 2676. Throttle 的语义。
ts
type F = (...args: number[]) => void
function throttle(fn: F, t: number): F {
let lastArgs: number[] = []
let nextRunTime = 0
let timer: ReturnType<typeof setTimeout> | null
return function (...args) {
const now = Date.now()
if (now >= nextRunTime) {
// 已经过了冷却期,立即执行
nextRunTime = now + t
fn(...args)
} else {
// 处于冷却期:取消先前排队的定时器,用最新参数覆盖
clearTimeout(timer!)
lastArgs = [...args]
timer = setTimeout(() => {
nextRunTime = Date.now() + t
fn(...lastArgs)
lastArgs = []
timer = null
}, nextRunTime - now)
}
}
}过程拆解:
- 首次调用时
nextRunTime为0,条件now >= 0成立,立即执行并设定下一次可执行时间为now + t。 - 第一次执行后的
t毫秒内(冷却期),所有调用都会进入else分支。 - 冷却期内的调用会不断用新的
args覆盖lastArgs,并重置延时。nextRunTime - now计算的是距离冷却结束还剩多少时间,因此定时器恰好会在冷却期结束时触发。 - 定时器执行后,会把
nextRunTime更新为执行时刻加上t,从而开始下一轮冷却。而lastArgs和timer被清空。
这保证了:
- 高频调用期间,周期性地以约
t毫秒为间隔执行; - 每个冷却周期结束时使用的是最新参数,不会因为保留第一次调用参数而导致过期数据。
基本用法
ts
const log = throttle(console.log, 100);
log('A'); // 立即打印 'A',时间: 0ms
log('B'); // 不打印,覆盖上一个排队参数
log('C'); // 不打印,覆盖参数
// 约 100ms 时打印 'C'如果将上面三次调用放在同一微任务或极短时间内,输出会是一次 'A',然后 'C' 出现在 100ms 附近。
示例
1. 逐步调用与冷却
ts
function timeLog(...args) {
console.log(`[${Date.now() - start}]`, ...args);
}
const start = Date.now();
const throttledLog = throttle(timeLog, 100);
throttledLog('1'); // [0] 1 — 立即执行
setTimeout(() => throttledLog('2'), 30); // 冷却期内
setTimeout(() => throttledLog('3'), 60); // 冷却期内
setTimeout(() => throttledLog('4'), 110); // 已过冷却期,立即执行
setTimeout(() => throttledLog('5'), 130); // 新一轮冷却期内可能的输出(取决于定时器精度):
[0] 1[100] 3— 冷却期结束时执行最后一次覆盖的参数[110] 4— 此时now >= nextRunTime,立即执行[210] 5— 新一轮冷却结束时执行
第 30ms 和第 60ms 的调用只保留了 '3',所以冷却期结束后执行的是 '3' 而非 '2'。
2. scroll 事件测试
html
<script>
const handleScroll = throttle(() => {
console.log('scroll event at', Date.now() - start);
}, 200);
window.addEventListener('scroll', handleScroll);
</script>快速滚动页面,可以看到控制台以不超过 200ms 的间隔持续输出,而不是每次 scroll 事件都触发。
3. this 绑定
上文的实现使用箭头函数返回,未绑定 this。当节流应用于对象方法时,this 会丢失:
ts
const obj = {
count: 0,
increment() {
this.count++;
console.log(this.count);
}
};
const throttledInc = throttle(obj.increment, 100);
throttledInc(); // undefined,因为 this 指向全局或 undefined(严格模式)解决方式之一是调用时通过包装函数显式传入上下文,或使用 Function.prototype.bind:
ts
const throttledInc = throttle(() => obj.increment(), 100);
// 或
const throttledInc = throttle(obj.increment.bind(obj), 100);如果需要通用性更强的节流实现,可以将返回的函数改为普通函数而非箭头函数,这样 this 会由调用方自然传入。
注意点
- 参数覆盖不会保留中间值。如果在冷却期内多次调用,只有最后一次的参数会被用于冷却结束后的执行。需要保留每一次调用的场景(例如顺序不能丢失的操作)不适合这种节流策略。
this绑定。上面的实现未保留this。如果原函数依赖调用上下文(例如作为对象方法使用),需要将return的函数改为普通function而非箭头函数,或显式绑定this。- 定时器精度。
setTimeout受事件循环和系统负载影响,实际延迟可能略大于t,不能依赖精确的固定间隔。 - 频繁
clearTimeout。冷却期内每次调用都会清除并重新设置定时器,这是实现“保留最后一次”的必要代价,在高频调用下不会有额外性能问题。 - 参数类型。LeetCode 题目限制了
...args: number[],实际实现中通常使用泛型保留原始参数类型。
限制
- 异步函数的执行时机。节流只控制函数调用窗口,不关心函数是否异步。如果
fn是异步操作,多个操作可能重叠执行——冷却期只保证发起调用的时间间隔,不保证上一次操作已完成。 - 执行次数。极端情况下,调用频率持续高于
1/t时,总执行次数约等于运行时间除以t,丢失的调用并不会累积到后续周期。 - 与防抖组合。有些交互场景需要混合策略(如首次立即执行、随后防抖),这种需求不能直接通过节流实现。
应用
- 滚动监听(滚动到位置加载更多、视差效果等),需要在性能和反馈更新之间取得平衡。
- 窗口 resize,避免短时间内触发大量重绘。
- 鼠标移动或拖拽,以固定频率更新坐标。
- 按钮提交,防止短时间内重复提交(也可使用防抖取决于 UX 需求)。
参考链接
- LeetCode 2676. Throttle
- lodash.throttle — 包含了
leading和trailing选项的更完整实现 - MDN Web Docs: setTimeout
