说实话,我一开始对“手写节流”这件事相当不以为然。刚入行那会儿觉得这不就背一段代码么,面试前突击一下,能把 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.na
