做前端这些年,防抖应该是我写过最多次的“工具函数”了。以前接手上一个后台管理系统,光滚动监听就挂了三四个setTimeout防抖,看起来挺专业,真机一测,滚动起来还是肉眼可见地掉帧。后来我把核心逻辑换成requestAnimationFrame,页面立刻顺滑了一个档次。今天这篇随笔,就想聊聊为什么我说别再用 setTimeout 模拟防抖了,浏览器自带的原生 API才是更接近性能天花板的那条路。文章适合正在写列表页、编辑器、可视化大屏,或者在滚动、拖拽、Canvas 绘制场景里被卡顿困扰的朋友,看完你基本能自己判断到底该用哪种方案。
1. 先看清楚:setTimeout 模拟防抖到底做了什么
1.1 防抖的本质和经典实现
防抖这个概念,硬件圈和软件圈都有。手机上OIS光学防抖是让镜头组反向补偿位移,影石Insta360 X6这类运动相机的电子防抖则是通过算法裁切画面来稳定视频流。前端里的防抖思路也类似,核心就一句话:事件触发后不立即执行,而是等待一段时间,如果这段时间里又被触发了,就重新计时,直到稳定下来才执行一次。
最常见的setTimeout写法是这样的:
function debounce(fn, delay = 300) { let timer = null; return function (...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; }逻辑很好理解:每次调用先清掉上一次定时器,再创建新的。就像电梯门,有人连续按开门键,关门计时器就会被反复重置,只有没人按了,门才在几秒后关闭。这个思路的保护目标是“请求”这类昂贵操作,比如搜索框里停一下再请求后端,避免每敲一个字符都打接口。
但问题在于,我们实际开发里遇到的“防抖”对象,很多根本不是请求,而是页面渲染和布局计算。一旦目标是 UI 更新,setTimeout的先天短板就暴露出来了。
1.2 setTimeout 防抖的四个隐形代价
第一,时间精度没有保证。setTimeout是宏任务,排在任务队列里等主线程空闲。如果主线程里脚本执行时间很长,或者要处理的事件特别多,定时器回调的实际触发时间会明显晚于设定时间。你设 16ms,可能实际跑成 40ms 甚至更久,这种不确定性在滚动场景里非常致命。
第二,和渲染帧脱节。浏览器绘制页面是按帧来的,通常 60Hz 刷新率下一帧约 16.7ms。setTimeout回调什么时候执行,取决于任务队列而不是渲染管线,它可能刚好落在两次绘制中间,也可能一帧里执行多次,导致同一帧内容被反复修改、重复布局。你知道浏览器最怕什么吗?最怕你在一帧里改完样式又立刻读取尺寸,逼着它做强制同步布局。setTimeout防抖经常不小心就制造这种“读-写-读-写”的振荡。
第三,后台标签页被节流。当你切到别的窗口,浏览器为了省电,会把后台标签页的setTimeout节流到至少 1 秒执行一次,有些浏览器甚至更狠。这意味着用户切出去再切回来,页面里的逻辑可能一瞬间蜂拥而至,卡成幻灯片。
第四,状态管理和清理成本。timer 存在闭包里,组件卸载时要额外记得清理。我在真实项目里见过不少由遗漏清理造成的 bug——组件销毁了,定时器还在跑,回调里操作了不存在的 DOM,然后报一堆看不懂的错。严格说这不是setTimeout的错,但确实是这个方案带来的额外心智负担。
2. 原生 API 凭什么更接近性能上限
2.1 requestAnimationFrame:和浏览器渲染帧对齐的天然合并器
requestAnimationFrame是我现在高频场景里用得最多的原生 API。它的机制很有意思:你注册一个回调,浏览器会在下一次绘制之前调用它。你可以在同一个事件循环里连续注册十个回调,浏览器会把这十个回调安排到同一个帧周期里,按注册顺序去重执行。
用大白话讲,你用setTimeout是自己定闹钟,闹钟响不响还要看主线程脸色;而requestAnimationFrame是直接告诉浏览器“下一帧开始前喊我”。浏览器天然就把你这个回调纳入了它的渲染节奏,不会出现一帧被拆成多次更新、或者好几帧才更新一次的错乱。
基于它写一个针高频事件的“帧级节流”非常简洁:
function rafThrottle(fn) { let ticking = false; return function (...args) { if (ticking) return; ticking = true; requestAnimationFrame(() => { fn.apply(this, args); ticking = false; }); }; }原理就是一把“闸门”:第一次事件进来时,ticking为 false,放行并置为 true,把执行请求注册到渲染帧;同帧内后面再来几十次事件,全都被ticking挡在外面。帧结束后,ticking重新置为 false,等待下一个帧周期。最终结果是,不管你在这一帧里触发多少次滚动、鼠标移动,回调都只会执行一次,而且是在浏览器准备渲染的那个时机执行。这个时机非常关键,因为你在回调里改的样式、算的布局结果,能立刻被当前帧绘制出来,不会积压到大半个屏幕滚动完之后才追上来。
另外一个细节是,回调会收到一个 DOMHighResTimeStamp 参数,代表当前帧的开始时间,相比在回调里调Date.now()或performance.now(),直接用这个参数更快,也避免了重复测量性能开销。后面写动画时序时它特别有用。
2.2 观察者类 API:浏览器替你做了批量派发
除了requestAnimationFrame,一批“观察者 API”也把防抖思想内化到了浏览器底层,很多人可能没意识到。
ResizeObserver就是典型。以前监听元素尺寸变化,常规做法是在window.resize事件里写setTimeout防抖,然后调getBoundingClientRect手动读取尺寸。ResizeObserver直接把这个流程优化掉了——浏览器在派发回调前会先等这一帧所有尺寸变化都结束,然后一次性回调你,天然就是批量合并的。而且它精准到具体元素,不用再去手动比对宽高。类似地,IntersectionObserver做懒加载,内部把滚动、resize 等大量触发源合并成异步批量回调,根本不需要你在 scroll 里再套一层防抖;MutationObserver监听 DOM 变更同理,连续的子节点增删会被合并成一条 MutationRecord 数组再交付。
这些 API 的共同特点就是:浏览器在内核层面帮你做了事件合并和时序优化,比你在 JavaScript 层用定时器去“猜”什么时机合适要靠谱得多。所以看到一个组件里写了setTimeout + getBoundingClientRect + 手动比对尺寸三件套时,我都会先问一句:为什么不用ResizeObserver?
2.3 原生方案和 setTimeout 的核心差异对比
为了让大家更直观地选型,我把两种方式的差异整理成了一个表格:
| 对比维度 | setTimeout 防抖 | requestAnimationFrame / 观察者 API |
|---|---|---|
| 时间精度 | 受任务队列影响,误差可能很大 | 跟渲染帧对齐,稳定可靠 |
| 执行时机 | 可能错过帧,也可能一帧执行多次 | 保证在绘制前执行一次 |
| 后台标签页 | 被节流到约 1 秒一次 | 直接暂停,恢复后再继续 |
| 状态管理 | 需要维护 timer 并手动清理 | 用cancelAnimationFrame清理,或观察者自动断开 |
| 适用场景 | 请求防抖、延时业务逻辑 | 滚动、拖拽、动画、尺寸变化等 UI 高频更新 |
| 心智负担 | 闭包 + 定时器 + 清理,容易遗漏 | 要么极简,要么浏览器帮你缓冲 |
3. 实操:用原生 API 重构高频场景的防抖逻辑
3.1 场景一:滚动方向判断与无限滚动加载
滚动监听是setTimeout防抖重灾区。很多人写无限滚动,会用debounce(() => loadMore(), 200)这种形式去接住滚动事件,但问题在于:如果用户快速滚动,防抖会不断重置计时,直到用户停下来才加载,列表底部可能长时间空白;如果用节流,又容易出现一帧里多次触发。最理想的效果应该是:滚动过程中,浏览器每一帧最多检查一次是否该加载。
用rafThrottle重构后是这样的:
const onScroll = rafThrottle(() => { const { scrollTop, clientHeight, scrollHeight } = document.documentElement; if (scrollTop + clientHeight >= scrollHeight - 80) { loadMore(); } }); window.addEventListener('scroll', onScroll, { passive: true });注意{ passive: true },它告诉浏览器滚动监听里不会调用preventDefault,浏览器可以不用等待 JavaScript 执行完就直接滚动,这个优化本身也能减少很多卡顿。实际测试里,同样的页面,从setTimeout换成这种写法后,滚动掉帧明显减少,因为每一帧都只做一次高度判断,没有多余的定时器排队。
如果你想实现更严格的“滚动方向判断”,可以维护一个lastY:
let lastY = window.scrollY; const onScroll = rafThrottle(() => { const y = window.scrollY; if (y > lastY) { // 向下滚动 } else { // 向上滚动 } lastY = y; });这里比较关键的是不要每次回调都去Date.now(),直接用 rAF 自带的帧时机就够了。向下滚动时做加载、向上滚动时收起悬浮按钮这类交互都是这个套路。
3.2 场景二:Canvas 拖拽绘制去掉定时器写法
Canvas 白板、画板、图片标注这类工具,pointermove事件触发的坐标点非常多,尤其在高刷新率触摸屏上,一秒能触发上百次。如果在每次事件里直接绘制,可能一帧里画了多次,下一帧反而空着,线条会显得一顿一顿。
更好的做法是,每次pointermove只更新坐标,绘制动作注册到 rAF 帧回调里:
const canvas = document.getElementById('board'); const ctx = canvas.getContext('2d'); let points = []; let drawing = false; function drawFrame() { if (!points.length) return; ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (const p of points) ctx.lineTo(p.x, p.y); ctx.stroke(); points = []; } canvas.addEventListener('pointerdown', (e) => { drawing = true; points.push({ x: e.offsetX, y: e.offsetY }); }); canvas.addEventListener('pointermove', (e) => { if (!drawing) return; points.push({ x: e.offsetX, y: e.offsetY }); requestAnimationFrame(drawFrame); }); canvas.addEventListener('pointerup', () => { drawing = false; });这里没有防抖包裹,但requestAnimationFrame本身就在替我做合并:移动快时,points里攒了几个点,到下一帧绘制时一次性连成一条折线;移动慢时,每个点都即时画出来。这样既不会丢点,也不会重复绘制。如果要做平滑曲线,只需要在这个drawFrame里把lineTo换成贝塞尔曲线计算就行。
这里有一个细节容易踩坑:绘制完要把points清空,否则下一帧会重复画之前的线段,线宽会越来越粗、颜色越来越深。我就在这上面翻过车。
3.3 场景三:窗口尺寸变化与响应式布局重算
全局窗口 resize 时,如果要做复杂的布局计算(比如重新计算水球图的中心位置、重新排版卡片),直接绑定resize太频繁。常规做法有setTimeout300ms 防抖,但切换窗口拖拽到新位置时,用户能看到明显的延迟,表格临时撑破容器好几秒才被修正。
更顺滑的做法是 resize 事件里也走 rAF:
const onResize = rafThrottle(() => { recalculateLayout(); chart.resize(); }); window.addEventListener('resize', onResize);如果是监听某个容器元素而非全局窗口,ResizeObserver会更优:
const observer = new ResizeObserver((entries) => { for (const entry of entries) { const { width, height } = entry.contentRect; resizeChart(width, height); } }); observer.observe(document.querySelector('.chart-container'));用ResizeObserver的感受是,浏览器内部已经处理好了“连续尺寸变化合并成一次回调”的细节,你甚至不需要关心它是怎么合并的,它只在布局稳定或变化足够告一段落后派发。如果你要监听的是一个会随着内容变化的容器,这比在 window 上监听然后手动算容器getBoundingClientRect要可靠得多,少写很多计算和判断逻辑。
3.4 场景四:输入联想,别把 rAF 当万能钥匙
有一点我必须说清楚:不是所有防抖都该用 rAF 替代setTimeout。比如搜索框的实时联想,你的目标是“用户停顿 300ms 再发请求”,这本质上就是要等“安静”,此时setTimeout就是正确的工具。
硬要用 rAF 做输入联想会是灾难:用户在 120Hz 屏幕上一秒输入 10 个字符,rAF 每帧都会触发,等于每秒发 120 次请求,这不叫优化,叫自爆。
正确的做法是分清场景性质:
- 如果目标是“避免昂贵的请求”,用
setTimeout防抖,等静止了再发; - 如果目标是“让视觉反馈跟上渲染”,用 rAF 或观察者 API;
- 两者也可以结合:rAF 更新 UI,
setTimeout负责发起请求。
比如搜索框里,你可以 rAF 驱动下拉面板的定位和尺寸调整,用setTimeout防抖驱动请求。各司其职,性能体验都兼顾。
4. 常见问题与排查技巧实录
4.1 rAF 和 setTimeout 到底怎么选:一张决策速查表
我在团队内部经常被问到这个问题,后来总结了一张选型速查表,分享给大家直接用:
| 你在改什么 | 推荐方案 | 原因 |
|---|---|---|
| 滚动计算位置、方向、懒加载 | rAF + passive | 与渲染帧同步,避免错过帧 |
| Canvas/WebGL 绘制轨迹 | rAF | 天然按帧合并绘制 |
| 缩放窗口重排图表布局 | ResizeObserver / rAF | 原生批量,避免滞后感 |
| 输入框停顿后请求 | setTimeout 防抖 | 需要等待“安静”,rAF 无法表达停止语义 |
| 监听某元素尺寸变化 | ResizeObserver | 精准到元素,内部已合并变化 |
| 滚动进入视口做懒加载 | IntersectionObserver | 相当于浏览器内置防抖+节流 |
这张表的意义在于提醒自己:先问“我这个抖动到底是要防什么”,再选工具。不要因为文章标题是“别再用 setTimeout”,就把它一棒子打死。它是错误时间尺度上的工具,但换到正确场景仍然是好东西。
4.2 边界情况:刷新率、后台标签页、SSR
这里整理了几个我在实际项目中踩过的边界坑。
120Hz 屏幕的坑。rAF 的回调频率跟显示器刷新率一致,60Hz 屏幕一帧约 16.7ms,120Hz 屏幕一帧只有 8.3ms。所以不要在 rAF 回调里写死帧间隔 16.7ms 来做时间计算,要用它回调传入的 timestamp 参数算增量:
let lastTime = 0; function frame(time) { const delta = time - lastTime; lastTime = time; // 用 delta 做时间相关计算 requestAnimationFrame(frame); } requestAnimationFrame(frame);后台标签页的补偿。rAF 在后台标签页会暂停,切回来时页面状态可能已经落后。最实用的做法是监听visibilitychange,页面重新可见时做一次强制同步,不要依赖 rAF 在那段时间里持续触发。
document.addEventListener('visibilitychange', () => { if (!document.hidden) { render(true); // 强制同步一次 } });SSR 环境没有 rAF。服务端渲染时window都不存在,更别说requestAnimationFrame。如果你写的是一个通用组件,需要在入口处做能力检测:
const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (cb) => setTimeout(cb, 16);强制同步布局的隐患。用 rAF 不代表万无一失,如果你在回调里先读offsetWidth再写样式,然后再读,照样触发强制同步布局,页面还是会卡。rAF 优化的是“触发次数”,但“单次回调里干了什么”由你决定。一个通用规律是:把读样式和写样式分开,先批量读,再批量写。
清理操作不能丢。rAF 也有对应的取消方法。在组件卸载或事件解绑时,要记得cancelAnimationFrame,否则回调可能在组件销毁后继续执行,造成内存泄漏或报错。习惯是封装rafThrottle时把 cancel 逻辑也暴露出来,或者在一个统一的dispose方法里处理。
最后说点我自己的感受。每次看到网上铺天盖地的“debounce 工具函数”,我第一反应都是先问一句:你到底想防什么抖动?如果防的是请求抖动,setTimeout没问题;如果防的是 UI 抖动、滚动抖动、绘制抖动,setTimeout只是在一个错误的时间尺度上努力。我现在的习惯是,项目里先写一个rafThrottle备用,再按场景决定是否要叠加setTimeout。把性能做上去,往往不是堆更多黑科技,而是把代码的执行时机,对齐到浏览器本来就有的渲染节奏上。