1. 先搞明白你要防的到底是哪个抖前端社区流传着一句话如果你手里有一个 setTimeout一切问题看起来都像防抖。这些年我浏览过不少项目的源码几乎每个项目里都躺着一个大同小异的 debounce 工具函数clearTimeout 一下再 setTimeout 一下延迟 300 毫秒。语法没有毛病逻辑也自洽可一旦把它无差别地套在滚动、缩放、输入框这些场景上性能账单最后都会寄到主线程那里。用原生 API 替代 setTimeout 模拟防抖并不是为了炫技而是因为很多所谓的“防抖需求”本质上根本不要求“延迟执行”只要求“收敛频率”。这两个诉求被混为一谈才是整套问题的源头。先把话说清楚setTimeout 不是不能用来做防抖输入联想、自动保存这类“等用户安静下来再执行”的场景它依然是标准答案。真正要扔掉的是那些“滚动到底部判断高度”“窗口缩放重绘图表”“监听元素进入视口”之类的伪防抖——它们的目标是跟手、流畅、不重复渲染而 setTimeout 给的却是延迟、等待、以及可能被吞掉的中途状态。理解了这条分界线后面所有替换都会变得顺理成章。1.1 防抖解决的不是“慢”是“频繁”当一个事件在极短时间内连续触发几十次但业务只需要其中最新一次的最终结果时我们才会想到防抖。比如用户在输入框里敲字每敲一下键盘都要触发一次查询请求比如窗口被连续拖拽缩放布局每帧都在变化比如页面快速滚动滚动事件会疯狂抛出一连串回调。这些场景的共同点是事件频率远高于业务实际需要的处理频率。setTimeout 版防抖的思路是把多次触发折叠成一次延迟调用只要在延迟窗口内再次触发就取消上一个定时器从零开始计时。这个思路本身没问题但它默认了一件事——你真正关心的是“最后一次触发之后的那段安静时间”。搜索框确实是这种模型因为用户的输入意图只有在停顿时才清晰可滚动、缩放、DOM 尺寸变化的场景根本不是这种模型它们关心的是“持续变化的过程中要及时响应”硬套 debounce 反而让响应永远慢一拍视觉上就像跟手度被砍了一刀。1.2 setTimeout 模拟防抖的四个硬伤第一个硬伤是定时器不准。浏览器规定嵌套调用超过 5 层的定时器最小延迟会被强抬到 4 毫秒这不是什么稀有情况防抖函数里如果内部又套了定时器分分钟踩中。更麻烦的是后台标签页的定时器会被统一节流常见策略是直接放大到 1000 毫秒甚至更多。用户切走再切回来你的“300 毫秒防抖”可能变成“数秒防抖”中间状态全被吞掉。第二个硬伤是任务队列排队。setTimeout 只承诺“最早到点执行”不承诺“准点执行”。主线程一旦被某个长任务占据到点的回调只能在任务队列里排队等候。你在同步代码里测出来的延迟放到真实加载环境下实际触发时间会变得不可控。第三个硬伤是阈值溢出。setTimeout 的延迟参数是有符号 32 位整数最大值约 24.8 天。防抖参数一般不会设那么大但如果你写过一个可配置的通用工具传参不规范时就会踩到立即执行的坑。第四个硬伤是语义错配。这一点最容易被忽略滚动、resize 这类高频事件你要的不是“等用户停下来了再处理”而是“在事件发生的同时、每帧最多处理一次”。setTimeout 防抖给的响应模型完全不对症这也是我坚持把这类需求叫做“频率收敛”而不是“防抖”的原因。2. 原生 API 才是性能天花板四个替代方案逐个拆解如果你接受了我上面那条分界线接下来的问题就变成不用 setTimeout高频场景里的频率收敛到底怎么做答案不是一个 API而是一组 API。它们各自瞄准了不同的事件类型而且都是浏览器渲染管线里的“自家人”天生比定时器更能和帧、布局、绘制这几个环节配合。2.1 requestAnimationFrame天然的一帧一次requestAnimationFrame 做的事情很单纯把回调安排到下一帧绘制之前执行并且保证一帧最多执行一次。这个“一帧一次”的承诺恰好就是高频事件想要的节流语义。浏览器本身会在合适的时机统一调度它而且页面切到后台时会自动暂停 rAF 的派发从根上避免无意义的空转。用它来做“帧级节流”而不是“时间级防抖”一是不需要维护一个 timer 变量二是回调执行的时机天然和渲染管线对齐不会出现“定时器到点但帧还没到”导致的多余布局抖动。最常见的封装长这样function frameThrottle(fn) { let ticking false; return function (...args) { if (ticking) return; ticking true; requestAnimationFrame(() { fn.apply(this, args); ticking false; }); }; }这段代码的精髓在于 ticking 标志位。高频事件无论触发多少次在每一帧内都只会放行一个回调等这一帧的回调执行完再把标志位放开。相比 setTimeout 防抖它没有“等待安静窗口”的概念事件触发的第一时间就开始处理只是一帧内最多处理一次。拿滚动来说用户滑动的过程中页面每帧都在更新帧内合并之后视觉上完全是实时的但处理次数从“每毫秒数次”降到了“每秒最多 60 次”。这个模型就是浏览器自己的节奏你不需要去猜延迟该设多少。2.2 IntersectionObserver连事件都不用监听的观察者IntersectionObserver 是另一个被严重低估的原生 API。它的作用是异步观察一个目标元素与视口或者某个祖先容器之间的交集变化一旦交集状态发生变化会在合适的时机批量回调。也就是说你根本不需要监听 scroll 事件不需要自己在滚动回调里算 getBoundingClientRect更不需要防抖——因为浏览器自己就是“防抖”的它会等滚动稳定之后统一派发一轮回调。放一个最典型的懒加载场景。以前常见写法是在 scroll 回调里对每个图片元素做距离判断再加上防抖防止回调爆炸换成 IO 之后只需要观察图片进入视口的那一刻const images document.querySelectorAll(img[data-src]); const io new IntersectionObserver((entries, observer) { for (const entry of entries) { if (!entry.isIntersecting) continue; const img entry.target; img.src img.dataset.src; observer.unobserve(img); // 加载完就从观察列表里移除 } }, { rootMargin: 100px 0px }); images.forEach(img io.observe(img));注意这里 rootMargin 的作用把视口的判定区域向外扩展 100 像素相当于图片还没真正进入视口就已经开始加载用户滚到那里时根本没有等待。IO 内部做了空闲期合并回调不会像 scroll 那样高频轰炸主线程所以“防抖”这一层直接省掉了。2.3 ResizeObserver容器尺寸变化的正解ResizeObserver 解决的问题是 resize 事件覆盖不到的角落。window.resize 只能监听窗口尺寸但很多时候我们需要响应的是某个容器尺寸的变化——侧边栏折叠、字体放大、面板拖拽、iframe 内内容伸缩这些都不会触发 window.resize。以前大家只能轮询 getBoundingClientRect 或者挂个 setTimeout 防抖定期查一次既浪费又迟钝。ResizeObserver 直接观察目标元素的内容盒大小尺寸一变就回调时机在布局之后、绘制之前天然和渲染流程同步。这种“布局后立刻告知”的时机恰恰是 resize 防抖无法提供的——防抖要等安静窗口而 RO 是当帧反馈。const container document.querySelector(#panel); const ro new ResizeObserver((entries) { for (const entry of entries) { const { width, height } entry.contentRect; console.log(容器尺寸变化:, width, height); } }); ro.observe(container);2.4 passive: true 与滚动监听的最后一环passive: true 不是防抖本身但它是滚动性能的一块拼图。浏览器在处理 scroll、wheel、touchstart、touchmove 这些事件时默认需要先确认监听器有没有调用 preventDefault确认完毕才能滚动这个确认过程的开销在高频触发下会被放大。主动声明 passive: true等于告诉浏览器“我不会阻止默认行为”滚动可以直接开始顺畅度肉眼可见。window.addEventListener(scroll, handler, { passive: true }); // 如果明确不需要 preventDefault就这么写我把 passive 和前面三个 API 放在一起讲是因为它们解决的是同一个根本问题让浏览器在早已设计好的高效路径上工作而不是用定时器在外面硬撑。这个思路贯穿到底才是“性能天花板”这四个字的意思。3. 实操改造三个高频场景的前后对照3.1 搜索输入联想setTimeout 该留还是该砍诚实说输入搜索这个场景setTimeout 防抖不是不能留而是不能裸用。用户停止输入 300 毫秒后再发请求这个需求本身合理问题出在两处请求没有取消机制以及防抖延迟导致的首帧响应太慢。我拿某个后台系统的搜索框举例。老代码长这样input.addEventListener(input, debounce((e) { fetch(/api/search?q e.target.value) .then(res res.json()) .then(renderList); }, 300));看起来正常但深挖就有问题如果用户在 300 毫秒内连续输入前一个请求已经发出去了等返回时渲染的却是旧关键词的结果用户看到的列表和输入内容对不上。要彻底解决这个竞态得引入 AbortController在每次触发时取消上一次未完成的请求let controller null; input.addEventListener(input, debounce(async (e) { if (controller) controller.abort(); controller new AbortController(); try { const res await fetch(/api/search?q e.target.value, { signal: controller.signal }); renderList(await res.json()); } catch (err) { if (err.name ! AbortError) console.error(err); } }, 300));至于首帧响应慢可以改成“首个输入立即请求后续输入防抖”也就是 leading 和 trailing 都开的防抖。这样用户敲第一个字立刻出结果继续敲字才进入 300 毫秒等待。这个场景确实是少数 setTimeout 不被替代的地方但也要做对才行。3.2 滚动加载更多从 scroll 监听换到 IO 哨兵滚动加载是 setTimeout 防抖的重灾区。以前我看到的典型写法是这样let timer null; window.addEventListener(scroll, () { clearTimeout(timer); timer setTimeout(() { const rect sentinel.getBoundingClientRect(); if (rect.top window.innerHeight 200) { loadNextPage(); } }, 150); });这里至少有四个问题。第一scroll 回调里做 getBoundingClientRect 会触发强制同步布局这是性能杀手第二防抖让加载动作在用户停止滚动之后才触发滚动到底部时会出现明显的“空窗期”第三滚动特别快的时候防抖可能一直被打断加载甚至不触发第四每次滚动都在主线程上加一轮定时器任务白消耗。改成 IO 之后const sentinel document.querySelector(#load-more-sentinel); const observer new IntersectionObserver((entries) { if (entries.some(entry entry.isIntersecting)) { loadNextPage(); } }, { rootMargin: 200px 0px }); observer.observe(sentinel);rootMargin 的意义很直观把视口的判定区域向外扩展 200 像素相当于提前 200 像素就触发加载。这样用户滚近底部时数据已经在路上滚动体验几乎无感。代码量更少、意图更清晰、性能还更好这种替换我做过很多次每次都能在性能面板里看到明显的长任务减少。3.3 图表自适应不要再为 resize 写定时器图表组件适配容器尺寸是另一个防抖高频出现的地方。容器宽度变化时图表要重绘很多人直接写window.addEventListener(resize, debounce(() { chart.resize(); }, 200));改成 ResizeObserverconst container document.querySelector(#chart-wrapper); const ro new ResizeObserver((entries) { for (const entry of entries) { const { width, height } entry.contentRect; chart.resize(width, height); } }); ro.observe(container);改动之后有三个可见收益。第一不再只响应窗口变化侧边栏折叠、面板拉伸、字号调整引起的容器尺寸变化全部覆盖第二回调不需要等待 200 毫秒的安静期尺寸变化当帧就得到响应第三彻底删掉定时器维护也不会因为防抖窗口吞掉中间状态。我还在一套虚拟列表组件里用 RO 跟踪可视区容器尺寸尺寸一变就重新计算渲染窗口反馈精度比之前用定时器轮询高一个量级。4. 常见问题与排查技巧实录4.1 兼容性底牌这些 API 哪些环境能用说了这么多原生 API 的好底层兼容性还是要心里有数。我整理了一张自查表方便你在项目里快速判断API当前支持情况老环境兜底策略requestAnimationFrame所有现代浏览器原生支持降级为 setTimeout(fn, 16) 模拟帧率passive: trueChrome 51、Firefox 49、Safari 11不支持时浏览器自动忽略不影响功能IntersectionObserverChrome 51、Firefox 55、Safari 12.1加载场景可临时退回 scroll rAF 方案ResizeObserverChrome 64、Firefox 69、Safari 13.1退回 window.resize rAF 手动测量表格里的版本号只是我记忆中的大致门槛真正需要精确核对时建议用三方检测工具跑一遍目标用户群的浏览器分布。这里有一个实操心得与其写一堆兼容判断代码不如把原生 API 封装成策略对象内部探测能力后自动选择实现调用方照常使用。这样老浏览器拿到降级方案新浏览器拿到天花板方案代码不会变成一团 if 糊。4.2 rAF 的两个经典翻车现场第一个翻车现场是忘记 cancel。如果某个动画循环已经启动中途状态变化后没有取消旧的帧请求回调会继续执行到天荒地老。写自管理的循环时务必用 cancelAnimationFrame 对称地回收let rafId null; function startLoop() { stopLoop(); const loop () { update(); rafId requestAnimationFrame(loop); }; rafId requestAnimationFrame(loop); } function stopLoop() { if (rafId) cancelAnimationFrame(rafId); rafId null; }第二个翻车现场是把业务逻辑放在 rAF 里却指望它后台不断。页面切到后台标签页时rAF 会完全暂停这本身是省电的优化但如果你用它做数据上报、计时统计就会发现数值悄悄漏掉了。高频 UI 更新用 rAF 没问题不依赖渲染循环的业务逻辑别往里头塞。另外 rAF 的回调参数会携带当前时间戳用来算帧间隔、做动画缓动都很好用这是 setTimeout 做不到的附带价值。4.3 IO 回调为什么总觉得“慢半拍”有人反馈说 IO 触发的时机比 scroll 事件慢。这是真的IO 回调是异步批量派发的会在当前帧的任务处理完之后才执行理论上比 scroll 回调晚十几毫秒到几十毫秒不等这是设计使然目的是把同一帧内的交叉变化合并成一轮通知。如果你在实际体验中觉得“太慢”先检查两件事。一是 rootMargin 是不是太小加载类场景建议给足提前量比如 200 像素甚至 300 像素二是 threshold 是不是设了非 0 值threshold 设为 1 时元素必须完全进入视口才会触发自然显得迟钝。加载更多这类场景阈值用 0配合 rootMargin 给提前量就够了。还有一个小提醒rootMargin 不能设置得比根元素还大否则控制台会报 Invalid rootMargin触发逻辑直接失效。4.4 ResizeObserver 的回调死循环ResizeObserver 的回调里如果修改了被观察元素的尺寸就会触发新一轮回调形成死循环。比如想在容器变窄时把内容字体调小回调里改了容器高度结果又触发一次来回拉扯。解法是只读内容盒尺寸做判断或者在回调里加一个尺寸变化阈值变化量小于阈值就忽略let lastWidth 0; const ro new ResizeObserver((entries) { for (const entry of entries) { const width entry.contentRect.width; if (Math.abs(width - lastWidth) 1) continue; lastWidth width; adaptLayout(width); } });另一个坑是重复 observe。同一个元素被 observe 两次回调触发次数会翻倍组件卸载时如果不调用 unobserve 或 disconnect还会造成观察器泄漏控制台经常刷 warning。写组件封装时务必在清理阶段把观察器断掉这是 React 和 Vue 生命周期里最容易漏的一步。4.5 什么时候必须老实回到 setTimeout把话说全原生 API 被过度推崇同样是另一种坑。IO、RO、rAF 能解决“高频事件的频率收敛”但它们不能替代“延迟执行”。真正需要等用户停下来的场景——输入联想、自动保存草稿、按钮点击后延时操作——setTimeout 防抖依然是对的。甚至更准确地说这些地方要的是一个“可取消、可重置的延迟任务”setTimeout 配合 clearTimeout 就是最朴素的实现。我的判断标准很简单事件结果最终要落到“发给服务器”或者“等待用户意图”的用 setTimeout事件结果最终要落到“更新 UI、渲染画面”或者“观测元素状态”的用原生 API。两者各管一段互相不抢戏。理解了这条边界你就不会再盲目迷信某一种写法。5. 留在实操里的几个体会最后分享几个我在真实项目里攒下来的习惯。迁移的时候不要一把梭先把现有的防抖调用点全部列出来按“服务端请求、UI 渲染、状态观测”三类打标然后逐个替换每次替换对比一下原来的响应体验有没有退化。滚动加载换成 IO 时别忘记在接口异常时恢复观察——如果加载失败哨兵元素已经进入视口回调不再触发加载就永久卡死了。我给这个场景加了个失败重试计数器超过三次就显示“加载失败”按钮让用户手动触发恢复。用 RO 做图表自适应时把旧尺寸缓存起来只有真的变了才去重绘图表能省掉很多无意义的 canvas 重绘。还有一个小技巧直接在监听器里用 debounce 包一层不如把防抖的时机放在数据流层面。先把高频事件映射成状态等待组件去响应状态变化——这一层抽象一旦建立防抖、节流、取消请求都变成可插拔策略替换原生 API 也只是换一行配置的事。我在某个跨平台项目里就是把所有滚动相关逻辑收敛到一个能力适配层老环境跑降级方案新环境跑 IO/RO 方案业务代码从头到尾没动过。这些都是我踩过几轮坑之后沉淀下来的东西。防抖本身没有错错的是拿它当万能胶。搞清事件和业务之间那层真正的约束原生 API 自然就会出现在最合适的位置上。