说实话,我一开始对“手写节流”这件事相当不以为然。刚入行那会儿觉得这不就背一段代码么,面试前突击一下,能把 setTimeout 两种写法默写出来就算过关。直到有一次在真实项目里做长列表滚动加载,被频繁触发的滚动事件打得页面直接掉到二十帧,我才老老实实回头把一个 throttle 从头到尾手写了一遍。那一刻才意识到:节流几个版本之间的差别、this 的保存方式、首尾触发的取舍,其实每一项都在真实场景里埋着坑。这篇就把我围绕“JS 节流全家桶”整理出来的东西完整写一遍,从最简单的原理到手写演进,再到工程里能用起来的完整封装,尽量做到你拿过去就能直接用。
1. 一个滚动加载的卡顿问题,让我重新理解了节流
1.1 一个真实卡顿案例
当时做的是一个接近微信朋友圈形态的信息流页面,上拉到底自动加载下一页。前端拿到数据后往 DOM 里 append 节点,同时还要根据内容高度做几个懒加载图片的占位。由于图片没有固定尺寸,每一条内容渲染完都要触发一次重排。用户在触控板上快速滚动的时候,scroll 事件回调一秒钟能被触发几十次,每一次回调里都去判断“是不是到底了”,到底了就去发请求、改状态。结果是滚动起来页面像幻灯片一样一跳一跳的。
我当时第一反应是加防抖:等用户停下来再判断。结果更糟——用户快速滚动时防抖一直在重置定时器,请求迟迟不发;等用户真的停下来,又要再等几百毫秒才开始加载,体验非常别扭。后来换了节流,把判断逻辑固定成每500毫秒最多跑一次,页面立刻顺滑了许多,滚动到接近底部时也能在下一拍及时触发加载。
1.2 快速滚动一秒钟到底会产生多少回调
你可以在控制台贴这段代码感受一下:
javascript复制window.addEventListener('scroll', () => {
counter++;
console.log(counter);
});
在 Mac 触控板上快速滑动两三屏,scroll 事件回调次数通常一秒钟能到 30 到 60 次以上。普通鼠标滚轮稍微慢一些,但也会在 20 到 30 次左右。这些事件如果没有节流,页面里的每一次回调都会实打实地执行一遍:判断位置、读取 DOM 高度、计算距离底部距离。这些操作本身不慢,但事件频率太高,连续触发就会把主线程占满。尤其当回调里还带着 getBoundingClientRect、强制同步布局之类的操作时,掉帧是必然的。
一个高频事件 + 一个有点重量级的回调 = 卡顿。节流的作用是给回调的执行频率装上限速阀,而不是优化回调本身的耗时。
1.3 防抖不能救“持续高频触发”,为什么
很多人会把防抖和节流混在一起,觉得它们都是“降低触发频率”的。但两者应对的场景其实不一样。防抖的逻辑是“用户停下来以后再执行”,它解决的是“一连串触发只需要最后一次”的场景。比如输入框关键词搜索,用户打了十几个字,中间每次按键都触发搜索没意义,只要在用户停顿几百毫秒后请求一次就够了。
但滚动这种事件是持续的。用户可能连续滚了十秒才停,如果这十秒内一次都不执行,页面就可能一直处于“等待停止”的状态,中间该做的加载、懒加载判断、吸顶计算全都冻结了。节流和防抖最大的差异就在这里:节流保证你在持续高频触发时仍然能每隔一段固定时间执行一次回调,不会彻底罢工。
这个差异也解释了为什么面试手写题里,节流的实现会比防抖多一层“上次执行时间”的判断。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手写之前:定时器的 this 和 event 对象先想明白
2.1 闭包在节流里保存的是哪两个值
一个节流函数返回的往往是一个新函数,这个新函数通过闭包保存着状态。以最经典的时间戳版本为例,闭包里需要保存的只有两个值:上一次执行的时间 previous,以及可能存在的定时器句柄 timer。
javascript复制function throttle(fn, wait) {
let previous = 0;
let timer = null;
return function(...args) {
// 每次调用时都能访问到 previous 和 timer
};
}
用闭包是因为新函数每次被触发时,都需要读取上一次执行的时间点,并且需要把定时器句柄留在同一个作用域里,方便后续清除。如果你把这两个变量定义在 return 出来的函数内部,那么每次触发都会重新初始化,节流效果直接失效。这个道理看着简单,但真换到自己写的时候,很多人会顺手把变量定义错位置。
2.2 this 的丢失场景和正确姿势
手写节流最容易翻车的地方之一就是 this。节流函数通常要作为一个包装函数返回,并且支持被对象方法调用。看这个错误例子:
javascript复制const obj = {
name: '列表',
handleScroll() {
console.log(this.name);
}
};
// 错误示范:丢失 this
const throttled = throttle(function() {
console.log(this.name); // 这里的 this 已经不对了
}, 200);
window.addEventListener('scroll', throttled);
事件监听的回调里,this 指向元素本身,不是你的对象。所以手写节流时,返回的函数内部必须把当前 this 保存下来,在真正调用原始 fn 时用 apply 传回去。
javascript复制return function(...args) {
const context = this;
// 后面真正调用时:
fn.apply(context, args);
};
用箭头函数直接包住 fn(...args) 的写法在多数时候也能工作,因为箭头函数不绑定自己的 this,你外层普通函数执行时的 this 会被箭头函数捕获。但我更推荐显式保存 context 再 apply,这样逻辑更清楚,面试官看着也更放心。
2.3 event 对象进入 setTimeout 后最容易被忽略
如果你要手写的节流带“尾触发”能力(等停止后补执行一次),那么 event 对象会在定时器里用到。这里藏着一个比较古董但也很经典的坑:部分浏览器在事件处理函数执行完后,会把传入的 event 对象回收或置空,定时器回调里再读取 event 时,某些属性可能已经拿不到了。尤其是 clipboardEvent、dragEvent 这类比较冷门的事件,表现更明显。
解决方案就像很多库做的那样,在进入节流包装函数时先把 event 存到一个普通变量里,然后传给定时器回调:
javascript复制function throttled(...args) {
const context = this;
const event = args[0]; // 缓存事件对象
if (!timer) {
timer = setTimeout(() => {
timer = null;
fn.call(context, event); // 用缓存的 event
}, wait);
}
}
当然,现代浏览器里这个坑并不常见,但这会让你理解为什么很多开源库的节流实现里喜欢做事件对象缓存,而不是直接在定时器回调里读 arguments。
3. 节流的三个手写版本:时间戳、定时器、组合完整版
3.1 时间戳版:立即执行、到点执行,但不擅长收尾
最基础的手写节流是时间戳比较版本。每次触发时拿到当前时间 now,如果和 previous 的差已经大于等于 wait,就立即执行一次,并更新时间戳。
javascript复制function throttleByTimestamp(fn, wait) {
let previous = 0;
return function(...args) {
const context = this;
const now = Date.now();
if (now - previous >= wait) {
previous = now;
fn.apply(context, args);
}
};
}
这个版本有一个很明显的特征:第一次触发会立即执行,因为 now - 0 通常是大于 wait 的。在持续触发的过程中,它保证固定间隔执行,不会拖泥带水。
但它的短板也很明显:不擅长“收尾”。假设 wait 是 500ms,用户从第 0ms 开始触发,到第 300ms 时停止。上一次执行发生在第 0ms,下一次触发在第 300ms,此时 now - previous = 300 < 500,直接被 if 挡掉了。那么用户这最后一下滚动造成的状态变化,就没有任何机会再被处理。在很多场景下,丢了最后一次更新意味着页面状态停留在旧位置,这是不可接受的。
3.2 定时器版:能收尾,但第一次会“迟到”
为了处理最后一次触发,很自然会想到用 setTimeout。定时器版本的思路是:如果当前没有等待中的定时器,就创建一个在 wait 毫秒后执行的定时器,执行完再把定时器句柄置空。
javascript复制function throttleByTimer(fn, wait) {
let timer = null;
return function(...args) {
const context = this;
if (!timer) {
timer = setTimeout(() => {
timer = null;
fn.apply(context, args);
}, wait);
}
};
}
这个版本的优点恰好弥补了时间戳版的缺点:连续触发的最后一次,即使已经停止了,只要之前创建了一个定时器,它依然会在 wait 毫秒后执行一次,相当于天然带着“尾触发”能力。
缺点是第一次触发会延迟。用户第一次滚动时,页面内容不能立刻更新,必须等 wait 毫秒过去后才执行第一次回调。对于滚动加载这类追求即时反馈的场景,这个延迟会让人感觉“卡了一下”。
3.3 组合完整版:把首触发和尾触发拼在一个周期里
真正能在项目里用的节流,大多把两种思路组合起来:第一次触发立即执行,下一次周期边界时如果还有触发在进行,也补执行一次。我建议直接记下面这个版本:
javascript复制function throttle(fn, wait) {
let previous = 0;
let timer = null;
function throttled(...args) {
const context = this;
const now = Date.now();
const remaining = wait - (now - previous);
// remaining <= 0:已经超过一个周期,立即执行
// remaining > wait:系统时间往回跳等边界情况,保险起见也立即执行
if (remaining <= 0 || remaining > wait) {
if (timer) {
clearTimeout(timer);
timer = null;
}
previous = now;
fn.apply(context, args);
} else if (!timer) {
// 还没到时间点,安排一个定时器在剩余时间后执行收尾
timer = setTimeout(() => {
previous = Date.now();
timer = null;
fn.apply(context, args);
}, remaining);
}
}
throttled.cancel = function () {
if (timer) {
clearTimeout(timer);
timer = null;
}
previous = 0;
};
throttled.flush = function (...args) {
if (timer) {
clearTimeout(timer);
timer = null;
}
previous = Date.now();
fn.apply(this, args);
};
return throttled;
}
一眼看上去代码变长了,但核心逻辑只有两个分支:
- 当前时间离上次执行已经超过
wait,说明进入了新的时间窗口,可以立即执行一次。 - 还没到时间,并且当前没有等待中的定时器,那就把 timer 设定在
remaining毫秒后触发,用它来“补齐”下一次周期边界上的执行。
很多教程把这类写法叫做“首尾双触发”,即第一次进入触发时立即执行,最后一次触发结束后也会在周期边界执行一次收尾。实测下来,这个版本在做滚动加载、拖拽位置同步、resize 布局这类需求时,体验最稳。
上面这个实现主要是为了教学,在可读性上做了取舍。生产环境想偷懒可以直接用 lodash 的
throttle,它的边界处理更完善,比如支持leading: false来禁用首触发。
我在最初写组合版时漏掉了一个场景:如果刚创建完定时器,下一次触发隔了很久才来,此时原本的定时器可能还没执行,但是 now - previous 已经超过了 wait。这时候会进入第一个分支并 clearTimeout(timer)。如果不清理,旧定时器到点后还会再执行一次,导致短时间内执行两次。上面这个版本已经处理了这个细节,你可以放心用。
3.4 为什么我一直保留 cancel
手写节流时如果不返回 cancel 方法,只是一个“能用”的节流,不算一个“完整”的节流。在单页应用里,组件卸载、页面切走、路由跳转时,事件监听不会被自动清理。如果你在滚动事件上挂了节流函数,又没有在合适的时机移除监听或者取消定时器,那个定时器回调可能会在组件已经卸载后依然执行,触发已经不存在的 DOM 更新,严重的时候还会报错。
所以在写节流时,我习惯直接把 cancel 挂到返回的函数上,再配合一个 flush 用于主动收尾。多几行代码,后面省很多事。
4. 防抖和节流靠电梯和公交来记,比背名词有用
4.1 用电梯来理解防抖
防抖的核心是“停下来才算一次”。你在电梯门口按了开门键,有人陆续走过来,电梯门每次都会重新等几秒,直到一段时间内没有人再按键,门才缓缓关上。对应到代码里就是:每次触发都会清掉上一个定时器、重新计时,只有最后那次触发之后的等待时间完整走完,回调才执行。
最适合防抖的典型场景就是搜索框。用户每敲一个字符都触发一次“联想搜索”,这会发太多请求,而且大概率响应顺序还会错乱。敲完停下来 300ms 后再搜索,请求量立刻降下来了。
4.2 用公交车来理解节流
节流更像公交车或地铁的“定时发车”机制。不管站台上来了多少人,车都是固定间隔出发。比如每 15 分钟一班,到点就开,不会为了等一个刚出地铁口的人把整个间隔无限延长。对应到代码里就是:不管高频事件触发多少次,函数只按固定频率执行。
滚动加载用的是这个逻辑:不管用户滚得多快,每隔 500ms 最多判断一次是否需要加载下一页。
4.3 一条最简单的判断口诀
我自己的判断口诀就六个字:“看停下,还是看频率。”
- 如果需求是“连续操作只要最后一次结果”,用防抖。
- 如果需求是“连续操作也需要定期给反馈/定期做处理”,用节流。
- 不确定时,默认为滚动、拖拽、resize、鼠标移动这类持续事件选节流;为输入、连续点击选防抖,一般不会跑偏。
另外一个辅助思路是问自己:用户操作停止后,还需要处理一次吗?防抖天然会处理“停止后”的最后一次,因为整个机制就是等停止;节流组合版通过尾触发也能处理停止后的最后一次。但如果场景只需要中间节拍而不需要最后收尾,可以修改配置丢弃尾部触发。
4.4 哪些场景适合用哪种
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 搜索框输入联想 | 防抖,300ms 左右 | 只需要用户停笔后的最终输入 |
| 滚动加载下一页/滚动懒加载 | 节流,300ms-500ms | 持续滚动的过程中也要定期判断 |
| 窗口 resize 后重绘图表 | 防抖或节流均可 | 重绘重,希望停止后稳定;但如果resize过程需要实时预览,节流更合适 |
| 按钮提交防连点 | 节流,禁用期间 leading: true |
第一次点击应立即执行,后续点击被窗口挡住 |
| 拖拽时的位置上报 | 节流,每 100-200ms 一次 | 持续高频需要平滑、有节拍地上报 |
| 地图缩放后重新加载瓦片 | 防抖 | 希望缩放动作彻底停止后再请求资源 |
5. 全家桶的功能扩展:cancel、flush 与生命周期管理
5.1 在组件卸载时取消定时器
写 React 功能组件的时候,最让我头疼的不是节流函数本身,而是“组件卸载后节流定时器还在跑”这种问题。比如:
javascript复制useEffect(() => {
const handleScroll = throttle(() => {
setScrollTop(window.scrollY);
}, 200);
window.addEventListener('scroll', handleScroll);
return () => {
handleScroll.cancel();
window.removeEventListener('scroll', handleScroll);
};
}, []);
注意清理函数里要先 cancel() 再移除监听。如果只移除 scroll 事件,已经排进事件循环的定时器仍然会触发。cancel() 做的就是把 timer 清掉,让回调无法再在组件卸载后执行。这个顺序看起来微不足道,但实际排查时经常能救你一命。
5.2 用 useRef 保存节流实例
节流函数如果依赖了外部状态,比如“当前是否处于 loading”,直接写进 effect 闭包很容易拿到过期值。一个解法是用 useRef 把最新状态保存起来,节流函数里读取 ref.current,而不是直接读闭包变量。同时也可以把节流实例放到 useRef 里,避免重新渲染时生成新的节流句柄,导致 removeEventListener 找不到原来的引用。
javascript复制const throttledRef = useRef(null);
if (!throttledRef.current) {
throttledRef.current = throttle((pos) => {
doSomething(pos);
}, 200);
}
useEffect(() => {
const handler = () => {
throttledRef.current(window.scrollY);
};
window.addEventListener('scroll', handler);
return () => {
window.removeEventListener('scroll', handler);
throttledRef.current.cancel();
};
}, []);
如果把 throttle 直接写在 render 逻辑里,每次渲染都会生成一个新函数。新函数持有不同的闭包状态,addEventListener 时用的是第一次渲染的版本,清理时又拿第二次渲染的版本去 removeEventListener,结果是监听永远移除不掉。这几个问题串在一起,往往是线上“节流越滚越卡”的隐藏原因。
5.3 flush 适合用来主动收尾
flush 方法的作用是立即执行一次真正要跑的函数,并取消掉等待中的定时器。什么时候用?比如用户点击“加载更多”之后又立刻切换了 tab,你希望把已经发生的滚动状态立刻同步给新页面,而不是等下一个节流周期;又比如在页面隐藏或组件卸载前,想把最后一次状态刷新到 localStorage 或后端,用 flush 就能强制把那一下补上。
javascript复制function handlePageHide() {
throttledFlush();
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
throttled.flush();
}
});
注意 flush 的语义是“立即执行,不遵守周期”。如果业务逻辑本身不允许最短间隔内连续执行,使用时要谨慎,或者给 flush 加一个额外判断。
6. 三个真实翻车复盘:配置、this 和解绑
6.1 坑一:滚动加载到最底部时,永远少了最后一发请求
我第一次把节流封装到业务代码里时,用的是 lite 版本,只判断时间点、立即执行,没有尾触发。线上反馈说“页面拉到最底下,转圈图标一直不消失,再往上回滚一下再拉到底,才加载成功”。
原因就是文章开头提过的:用户快速滚动到底后停止,最后一次滚动的回调触发时,距离上一次执行还不足 wait,被节流直接忽略了。滚动已经停了,后面也不会再触发新的 scroll 事件,所以加载判断永远没跑。
解决办法非常简单:换成带尾触发的节流版本,或在停止滚动时主动调用一次判断。用前面第三个小节那个组合版本可以直接解决,因为它会在最后一次触发后的周期边界补执行一次收尾。
给封装函数的默认行为加一条原则:除非明确知道不需要收尾,否则节流默认必须带尾触发。
6.2 坑二:防抖做提交按钮,用户总觉得没点上
另一个印象很深的翻车是同事用防抖做“按钮防连点”。需求是用户点击保存后 1 秒内不能重复提交,他用防抖把点击回调延迟了 500ms。结果用户点击后页面半点反应都没有,很自然地以为是没点中,又连续点了几下。虽然最终可能只执行了一次,但用户已经产生了“这按钮是不是坏了”的困惑。
这类场景应该用带首触发的节流,而且不能让回调等待:
javascript复制const handleSave = throttle(saveToServer, 1000);
用户第一次点击时,now - previous 明显大于 wait,会立即执行保存;紧接着的 1 秒内的第二次点击会被节流窗口挡住。这样既达到了防连点的目的,也保留了实时响应。
6.3 坑三:this 被节流改掉以后,连报错都难查
还有一次是在 Vue 项目里,事件回调是一个组件方法,里面需要访问 this.$store。由于我用的节流封装内部用了 fn(...args) 直接调用,没有把外层的 this 转交给原函数,方法一执行就提示找不到 $store。报错信息指向组件内部最后一行,光看堆栈根本想不到问题出在节流包装函数上。
排查手法倒是简单。在节流函数里打一个断点,看真正调用 fn 时 this 是什么。如果显示 undefined 而不是预期的组件实例,基本可以确定是包装时没绑定 this。解决方式就是前面一直强调的:保存 const context = this,再用 fn.apply(context, args)。
在事件监听场景里还有一种变体:监听器用匿名函数节流会导致后续无法移除。我习惯提前保存节流后的引用:
javascript复制const onScroll = throttle(handler, 200);
window.addEventListener('scroll', onScroll);
// 清理时
window.removeEventListener('scroll', onScroll);
如果反过来,每次都在添加监听时临时包一层,那移除监听时找不到同一个函数引用,监听器会永远驻留,隐式内存泄漏。这种问题不报错,但页面会慢慢变卡,特别难定位。
最后再分享一点体会
这几件事叠加起来,让我对手写节流的态度从“背代码”变成了“拆需求”。节流本身不复杂,复杂的是你动手写之前想清楚:这个场景需要首触发吗?需要尾触发吗?事件处理函数里的 this 是谁?组件销毁时定时器怎么办?把这些回答清楚以后,代码基本就能一遍写对了。
如果现在的项目已经有 lodash 等工具库,直接 import 现成实现当然没问题。但我还是建议你亲手把组合版本默写几遍,不用逐行背诵,只要记住两个核心状态 previous 和 timer、两个决策分支,然后自然就能写出来。等你写顺了,就会发现它和“给高频操作装一个节拍器”是同一件事。
