前端性能优化:防抖与节流的核心差异、工程实现与场景选型
做前端这些年,被问得最多的一类问题不是框架怎么选,而是“这个滚动监听卡得要死怎么办”“输入框每敲一个字就发一次请求,后端同事快盯上我了”。这类问题的答案基本都落在防抖(Debounce)和节流(Throttle)这两个词上,而它们又恰恰是前端性能优化里投入产出比最高的两个小工具。这两个概念人人都听过,面试也能背出定义,但真正在业务里写对、用对、调对的人并不多——我见过太多把防抖写成节流、把节流写成“限流”、甚至在组件卸载之后还留着定时器默默执行的代码。这篇就把这两个高频回调控制手段彻底拆开讲:它们各自解决什么问题,实现上有哪些坑,等待参数该怎么算,在搜索联想、滚动加载、按钮防连点、埋点上报这些真实场景里怎么落地,以及怎么和请求竞态、组件生命周期配合起来。无论你是刚入门,还是已经写了两三年业务代码,看完至少能做到一件事:拿到一个高频触发的场景,几秒钟判断出该用哪个,并且能徒手写出来不用翻文档。
1. 先把这两个概念钉死在场景里
1.1 高频回调为什么会把页面拖垮
浏览器的主线程只有一个,JS 执行、样式计算、布局、绘制全都排在它后面。一个mousemove在高刷新率设备上一秒能触发一百多次,一次scroll在惯性滚动里也能轻松跑到六十次以上,input事件更是每敲一个字符就来一次。回调本身不贵,贵的是回调里通常干的事:读scrollTop、读offsetHeight、改 DOM、发请求。
这里有个很多人不知道的细节:在同一个任务里先写 DOM 样式、紧接着读布局属性,浏览器为了给你正确的值,会被迫立刻做一次同步布局计算。你写十次读十次,就是十次强制重排。滚动监听里那种“读位置—改样式—再读位置”的写法,掉帧就是这么来的。回调频率越高,这种强制同步布局发生得越密集,最终表现就是滚动条拖不动、手指划下去画面发黏。
请求风暴是另一个后果。搜索框如果不做控制,用户输入“性能优化”四个字,会发出四次请求,而且这四次请求的返回顺序完全不保证——第一字的结果可能最后才回来,把正确结果覆盖掉,用户看到的联想词永远慢半拍。埋点上报更典型,滚动一次上报一次,一篇文章从头滑到尾能给你刷出几百条日志,服务端和客户端两边都难受。
所以这类优化的核心思路从来不是“让回调跑得更快”,而是降低回调的执行次数,同时不破坏用户感知的连续性。这就是防抖和节流要解决的问题,两者都在做“降频”,但降频的规则完全不同。
1.2 防抖与节流的本质差异:等安静 vs 按节拍
我习惯用一个生活场景来讲这个区别。防抖像电梯门:只要还有人往里走,门就一直重新计时,直到彻底没人了才关上。节流像地铁班次:不管站台上挤了多少人,车都是按固定间隔发一班,人再多也不会连发三趟。
落到代码上,防抖关注的是“事件停止后的那一刻”,它把一串密集触发合并成最后一次(或第一次)执行;节流关注的是“固定时间窗口内的第一次”,它保证在一段时间内最多执行一次,中间密集的触发直接被丢掉。这个差异决定了它们的适用面:
| 维度 | 防抖 Debounce | 节流 Throttle |
|---|---|---|
| 触发时机 | 停止触发 wait 毫秒后执行 | 每隔 wait 毫秒最多执行一次 |
| 中间触发 | 不断重置计时,全部丢弃 | 窗口内的触发被丢弃,窗口末尾恢复 |
| 执行次数 | 一整串操作通常只执行 1 次 | 与持续时长成正比,持续越久执行越多 |
| 典型场景 | 搜索联想、表单校验、窗口 resize 结束、自动保存 | 滚动加载、滚动视差、拖拽跟随、埋点上报、按钮防连点 |
| 用户感知 | 有“等一下才响应”的延迟感 | 响应是连续的,只是频率被压低了 |
判断标准其实一句话就够了:你关心的是“最终结果”,还是“过程中的连续反馈”?搜索框里用户敲完才是最终结果,用防抖;滚动视差每挪一段就要更新一次位置,用节流。搞混这两个,代码不一定报错,但体验会很别扭——比如把滚动加载写成防抖,用户必须停下来不动才会触发加载,滑到底部一直滑就一直不加载,这个 bug 我见过不止一次。
2. 防抖:从“最后一次才执行”到可配置的工程实现
2.1 最小可用版本与它的三个致命缺陷
网上流传最广的防抖实现大概长这样:
function debounce(fn, wait) { let timer = null; return function (...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), wait); }; }十行不到,日常用也确实能跑。但它有三个问题,在真实项目里迟早会咬你一口。
第一个是this指向。注意上面内层用的是箭头函数,箭头函数没有自己的this,它捕获的是debounce执行时的外层this。如果你把返回的函数挂到某个对象上,或者用在类方法里,this就丢了。稳妥的写法是内层用普通函数,同时在入口处把this存下来:const ctx = this;,然后fn.apply(ctx, args)。
第二个是返回值。有些场景需要拿到被包装函数的返回值,比如表单校验函数返回布尔值,防抖之后返回值直接变成undefined,调用方拿到就懵了。这个得靠debounced.flush()或者闭包里缓存result来解决。
第三个最要命:没法取消。组件卸载了、路由切走了、弹窗关闭了,那个setTimeout还在队列里,时间一到照样执行,执行时访问的 DOM 早就没了,轻则报Cannot read properties of null,重则往已经销毁的 store 里写脏数据。所以一个能上生产的防抖,必须带cancel。
注意:定时器句柄一定要在外层闭包里声明成
let,不要挂到返回函数的属性上,也不要用全局变量。每个防抖实例的定时器是独立的,混用会出现“A 的输入把 B 的计时清掉”这种极其难查的串扰。
2.2 立即执行、取消与返回值:把核心逻辑拆开
下面这版是我在项目里用了很久的实现,支持首次立即执行(leading)、末次执行(trailing)、手动取消(cancel)和立即结算(flush):
function debounce(fn, wait = 300, options = {}) { let timer = null; let lastArgs = null; let lastThis = null; let result; const { leading = false, trailing = true } = options; function invoke() { if (!lastArgs) return result; result = fn.apply(lastThis, lastArgs); lastArgs = null; lastThis = null; return result; } function debounced(...args) { lastArgs = args; lastThis = this; // 判断这次是不是“新一串事件”的第一次 const callNow = leading && !timer; if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; if (trailing) invoke(); }, wait); if (callNow) invoke(); return result; } debounced.cancel = function () { if (timer) clearTimeout(timer); timer = null; lastArgs = null; lastThis = null; }; debounced.flush = function () { if (timer) { clearTimeout(timer); timer = null; invoke(); } return result; }; return debounced; }关键点拆一下。callNow的判断依据是!timer,而不是别的标志位——因为在一次完整的静默周期内,timer从setTimeout开始一直到回调里置空,这段时间它都是真值,只有静默wait毫秒之后才会变回null。用timer本身当状态,能避免引入额外变量导致的时序错乱。
invoke里做了if (!lastArgs) return result;的空判断。这是因为flush()和定时器回调都可能调用invoke,如果上一次已经执行完把lastArgs清空了,再进来就会用空参数调用一次原函数,典型的“在控制台看到一条参数为 undefined 的多余日志”。加了这个判断,flush在已经执行过的情况下就只返回缓存结果,不会重复触发。
leading: true的场景很具体:按钮防连点。用户点第一下要立刻有反馈,不能等 300 毫秒;后面连续的狂点全部丢掉;同时trailing: false,避免最后一次点击又补一个请求,导致用户只点了一次却发了两个请求。而搜索联想恰恰相反,要leading: false, trailing: true,等用户停手再发。这两个组合别搞反。
2.3 参数计算:wait 到底该给多少
wait给多少没有标准答案,但有几个锚点可以参照。前端有个公认的感知阈值:100 毫秒以内的延迟用户基本无感,超过 200 毫秒开始有“卡了一下”的感觉,超过 1 秒就是明确的等待。所以防抖参数要在这个区间里找平衡。
搜索联想这类场景,我一般给250 到 350 毫秒。低于 200 毫秒,打字快的人还是会连发两三个请求,起不到合并效果;高于 400 毫秒,用户敲完字会明显感觉联想词“慢半拍”,尤其是千兆宽带下请求本身只要 30 毫秒返回,延迟全压在防抖上了。300 是个能兼顾两边的中间值。
表单实时校验、自动保存草稿这类,可以放宽到500 到 800 毫秒。这些操作用户本来就不期待即时反馈,而且频繁请求后端做唯一性校验,服务压力更敏感。
窗口resize结束时重排图表,给150 到 200 毫秒就够,因为反复拖拽浏览器窗口边框时,触发间隔本来就比较松散。
这里还有个更容易被忽略的点:输入法组合输入。用中文拼音打字,input事件会在输入拼音字母阶段就触发,比如敲“xingneng”,会连续触发七八次,但此时输入框里的值是拼音串而不是汉字。如果只做防抖不做保护,用户拼音还没选完就可能发出去一个?q=xing的请求。解决办法一行就够:
let composing = false; input.addEventListener('compositionstart', () => { composing = true; }); input.addEventListener('compositionend', (e) => { composing = false; handleInput(e.target.value); // 组合结束后立刻走一次 }); input.addEventListener('input', (e) => { if (composing) return; // 组合期间直接忽略 handleInput(e.target.value); });handleInput本身就是防抖过的函数,compositionend那一脚相当于提前结算,把拼音阶段攒下的等待直接兑现,用户选完字的瞬间就能看到联想结果,体验比纯防抖更顺。
3. 节流:时间戳与定时器两条路的取舍
3.1 两种基础写法差在哪
节流的基础实现有两条路,差异全在“首次”和“末次”上,很多人背了代码却没搞清楚这个差异,用错了才发现问题。
时间戳版本:
function throttleByTimestamp(fn, wait) { let last = 0; return function (...args) { const now = Date.now(); if (now - last >= wait) { last = now; fn.apply(this, args); } }; }它的特点是:last初始为0,第一次调用时now - last是一个巨大的正数,必然满足条件,所以首次立即执行;但最后一次触发如果落在当前时间窗口内,就会被丢弃,末次不执行。滚动视差用这个没问题,页面停下来时位置本身就是对的。但如果是“滚动到底部加载更多”,末次不执行就意味着用户正好停在一个卡在阈值附近的临界位置,永远不触发加载,得再动一下手指才行。
定时器版本:
function throttleByTimer(fn, wait) { let timer = null; return function (...args) { if (timer) return; timer = setTimeout(() => { timer = null; fn.apply(this, args); }, wait); }; }它反过来:首次要等wait毫秒才执行,首延迟;但最后一次一定会执行,末次必触发。宽表格横向滚动同步表头、拖拽结束后保存位置这类场景,末次触发很关键,否则最后一下拖拽就丢了。
3.2 组合版:首尾都要触发
实际业务里最常需要的是“首次立即给反馈 + 末次补齐”,把两条路缝起来:
function throttle(fn, wait = 200) { let lastTime = 0; let timer = null; function throttled(...args) { const now = Date.now(); const remaining = wait - (now - lastTime); if (remaining <= 0) { // 时间窗口已过,立刻执行,并清掉可能存在的补丁定时器 if (timer) { clearTimeout(timer); timer = null; } lastTime = now; fn.apply(this, args); return; } // 窗口内不再执行,但保证末次一定被补上 if (!timer) { timer = setTimeout(() => { timer = null; lastTime = Date.now(); fn.apply(this, args); }, remaining); } } throttled.cancel = function () { if (timer) clearTimeout(timer); timer = null; lastTime = 0; }; return throttled; }代码里有三个细节值得说。remaining <= 0那个分支除了执行函数,还必须清掉已有的timer——因为窗口已经过了,手动执行完如果再让老定时器到期补一发,就变成执行两次了。if (!timer)这个守卫保证一个窗口内最多只挂一个补丁定时器,不会越积越多。lastTime在补丁定时器里要重新赋值,否则下一轮判断的基准还是旧的,窗口会算短。
cancel里把lastTime重置成0是个小技巧。如果组件销毁后又被复用(比如弹窗关了再打开),重置时间基准能让第一次触发立刻执行,而不是被上次遗留的时间戳卡住半个窗口。
3.3 交给渲染节奏的 rAF 型节流
如果节流的回调里要改样式、做动画、算位置,那又有一层更贴合浏览器的选择:用requestAnimationFrame代替定时器。原因很简单,浏览器一帧是 16.7 毫秒(60Hz 屏幕),你用setTimeout(fn, 10)去做滚动动画,触发点可能落在两帧之间,浏览器得把这次的样式变更攒到下一帧才渲染,反而多一次无效计算。rAF 会保证回调在渲染之前的那一帧执行,改动和绘制对齐:
function rafThrottle(fn) { let ticking = false; return function (...args) { if (ticking) return; ticking = true; const ctx = this; requestAnimationFrame(() => { fn.apply(ctx, args); ticking = false; }); }; }它的“节拍”不再是固定毫秒数,而是跟着显示器刷新率走:60Hz 屏幕相当于 16.7 毫秒一次,120Hz 屏幕就是 8.3 毫秒一次。滚动监听里读位置算联动、吸顶头部切换、视差位移,我基本都用这版。有个坑要避开:页面切到后台标签页时 rAF 会被暂停,ticking会一直卡在true,回来之后所有触发都被吞掉。所以如果这东西用在长生命周期的全局监听上,最好在visibilitychange里重置一下状态,或者在页面重新可见时手动跑一次。
4. 落地到真实业务:三个高频场景的完整方案
4.1 搜索联想:防抖只是第一步,竞态才是真问题
搜索框是防抖最常见的落地场景,但只加防抖是半成品。真实情况是:用户先输入“性能”,请求 A 发出;300 毫秒后又输入“性能优化”,请求 B 发出。A 在路上堵了一下,B 先回来了,页面渲染 B 的结果;两秒后 A 回来了,把页面覆盖成“性能”的联想词。用户看着输入框里明晃晃的“性能优化”四个字,下面显示的是“性能”的结果,体验直接崩掉。
处理方案是在防抖之外加一层请求序号校验:
let seq = 0; const fetchSuggest = debounce(async (keyword) => { if (!keyword.trim()) { renderSuggest([]); return; } const current = ++seq; try { const res = await fetch(`/api/suggest?q=${encodeURIComponent(keyword)}`); const data = await res.json(); // 回来的时候如果序号已经变了,说明这是个过期请求,直接丢弃 if (current !== seq) return; renderSuggest(data.list); } catch (err) { if (current !== seq) return; renderSuggest([]); } }, 300);seq是个单调递增的计数器,每次发请求前自增并记下当前值,回来时对比。这个思路比AbortController取消请求更省事,因为用户手速快的时候可能一瞬间发出好几个请求,挨个取消反而麻烦;序号校验的好处是请求照发,只是结果按最新的一版渲染。当然如果你更在意省流量,用AbortController把上一个请求掐掉也行,注意abort会抛出一个AbortError,得在 catch 里把它单独过滤掉,别让它进全局错误上报。
还有个体验细节:请求发出到回来这段时间,最好给输入框一个pending状态,比如输入框右侧转个圈。否则用户敲完字之后 300 毫秒防抖 + 200 毫秒接口耗时,加起来半秒多没反应,会以为页面卡了。
4.2 滚动加载与埋点上报:节流 + 单调时钟
滚动加载更多的判断逻辑本身不复杂,核心是算距离底部的距离:
const onScroll = throttle(() => { const doc = document.documentElement; const distanceToBottom = doc.scrollHeight - window.innerHeight - window.scrollY; if (distanceToBottom < 300 && !loading && hasMore) { loadNextPage(); } }, 200); window.addEventListener('scroll', onScroll, { passive: true });这里有两个容易被忽略的点。一是{ passive: true },它告诉浏览器这个监听器不会调用preventDefault(),浏览器就不用等回调执行完再滚动,滚动流畅度能明显提升。滚动、触摸移动这类高频事件都该加上,不加的话控制台会给你一条“consider marking your touch and wheel event listeners as passive”的提示。
二是阈值别给太小。distanceToBottom < 300意思是距离底部还有 300 像素就开始加载,这个余量是为了让下一页在用户真正滑到底之前就准备好。如果你只有一个可滚动容器(比如固定高度overflow: auto的列表),window.scrollY就用错了,得换成容器的scrollTop,innerHeight换成clientHeight,scrollHeight换成容器的scrollHeight。这种混用是滚动加载不触发的头号原因,我在好几个项目里都遇到过。
埋点上报的场景对时钟更敏感。用Date.now()计算时间差有个隐患:用户在系统设置里手动改了时间,或者设备自动校时跳了一下,Date.now()会出现跳变,可能算出一个负的间隔,导致节流失效(now - lastTime变成负数,永远满足条件)或者长时间不触发。稳妥做法是用单调时钟performance.now():
const report = throttle(() => { const payload = { page: currentPath, depth: Math.round((window.scrollY / document.body.scrollHeight) * 100), ts: Date.now() // 上报时用真实时间戳,用于统计 }; navigator.sendBeacon('/api/track', JSON.stringify(payload)); }, 800);注意分工:控制频率用performance.now(),业务时间戳用Date.now()。两者各司其职,别混着用。另外上报优先用navigator.sendBeacon,它在页面卸载时也能把数据递出去,比在unload里发同步请求靠谱得多。
4.3 按钮防连点与框架里的清理
按钮防连点用leading: true, trailing: false的防抖最合适:
const handleSubmit = debounce(async (formData) => { submitting.value = true; try { await api.submit(formData); toast.success('提交成功'); } finally { submitting.value = false; } }, 800, { leading: true, trailing: false });wait给 800 毫秒是为了覆盖一次接口往返。这里有个前提:防抖只能拦住“快速连点”,拦不住“网络慢导致的重复提交”。如果接口要跑 3 秒,用户在 800 毫秒后再点一次,照样能发出去第二个请求。所以服务端幂等(唯一请求号)和按钮禁用状态这两层还是得有,防抖只是第一道最廉价的防线,别指望它解决全部问题。
框架里最关键的一步是清理。防抖和节流返回的函数都持有闭包和定时器,组件卸载时必须调cancel,否则定时器到期执行时访问的响应式变量、DOM 引用都已经失效。
React 里的封装:
function useDebouncedCallback(fn, wait, deps = []) { const fnRef = useRef(fn); fnRef.current = fn; const debounced = useMemo( () => debounce((...args) => fnRef.current(...args), wait), [wait] ); useEffect(() => () => debounced.cancel(), [debounced]); return debounced; }fnRef这层是关键。如果把fn直接塞进useMemo的依赖里,父组件每次重渲染传进来的新函数都会让防抖实例重建,定时器全部作废,防抖直接失效——这是 React 里防抖“看起来没生效”的最常见原因。用 ref 存最新的函数、useMemo只依赖wait,实例就稳定了。
Vue 3 里思路一致,用onUnmounted收拾:
import { onUnmounted } from 'vue'; export function useThrottle(fn, wait = 200) { const throttled = throttle(fn, wait); onUnmounted(() => throttled.cancel()); return throttled; }顺带提一句全局监听的问题。如果监听器挂在window上,组件卸载时不仅要removeEventListener,还要先cancel掉包装函数。因为addEventListener存的是包装后的引用,你remove的时候必须传同一个引用,很多人重新写了一遍throttle(fn)去移除,函数引用不同根本删不掉,监听器就这么一直挂着,内存和 CPU 一起漏。
5. 选型、排查与踩坑速查
5.1 一张决策表把选型问题关掉
每次有人问我“这个该用防抖还是节流”,我基本按下面这张表回答,实测覆盖了九成场景:
| 场景 | 选择 | 建议参数 | 关键配置 |
|---|---|---|---|
| 搜索框联想 | 防抖 | 250-350ms | trailing: true,配合请求序号 |
| 表单实时校验 | 防抖 | 400-600ms | 监听compositionstart/end |
| 自动保存草稿 | 防抖 | 800-1500ms | 卸载前flush一次 |
| 窗口 resize 重排 | 防抖 | 150-200ms | 图表类可配合ResizeObserver |
| 滚动加载更多 | 节流 | 150-250ms | passive: true,注意容器内滚动的取值 |
| 埋点曝光上报 | 节流 | 500-1000ms | 用performance.now()计时 |
| 拖拽跟随、视差 | rAF 节流 | 跟帧走 | 后台切回时重置状态 |
| 按钮防连点 | 防抖 | 500-800ms | leading: true, trailing: false |
| 输入框字符数统计 | 不处理 | — | 纯计算,本身开销可忽略 |
最后一行值得单独说。不是所有高频事件都需要包装,像字符数统计、简单的class切换,开销就是几微秒,硬套一层防抖反而增加复杂度,还可能引入时序 bug。优化的第一步永远是先测量,performance面板跑一遍,看看到底是哪个函数吃掉了时间,别凭感觉上工具。
5.2 常见问题排查表
下面这些是我和同事在真实项目里踩过的、并且反复出现在代码评审里的问题,按现象整理成速查表:
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 防抖“完全没生效”,每次触发都执行 | 包装函数在每次渲染时被重建 | React 用useMemo固定实例,Vue 放setup顶层 |
报Cannot read properties of null | 组件卸载后定时器仍执行 | 卸载时调用cancel() |
| 节流第一次不触发 | 用了定时器版本,首延迟 | 换组合版,或把lastTime初始化为-Infinity |
| 节流最后一次不触发 | 用了时间戳版本 | 加补丁定时器,或改组合版 |
| 中文输入发了一堆拼音请求 | 没处理组合输入 | compositionstart/end拦截 |
| 联想词和输入框内容对不上 | 请求竞态 | 序号校验或AbortController |
| 滚动加载在某个位置永远不触发 | 只判断了一次,或取值对象用错 | 换容器滚动取值,加distanceToBottom打印 |
| 摘除监听后仍在执行 | 移除的引用和添加的不一致 | 保存包装函数引用,先cancel再remove |
| 页面切后台回来动画不同步 | rAF 暂停导致ticking卡死 | visibilitychange里重置 |
5.3 我踩过的三个坑
第一个坑是防抖和节流混着用。早年做一个横滑表头的表格,我给scroll套了防抖,本意是等用户停下滑动再同步表头位置。结果用户习惯一路滑到底,全程表头纹丝不动,停下来的瞬间“啪”地跳过去,观感极差。后来换成 rAF 节流,滚动过程中表头平滑跟随,问题当场就没了。这件事让我记住:凡是需要过程连续反馈的,一律节流;只有需要等最终结果的,才用防抖。
第二个坑是参数给得太激进。有个项目的搜索联想防抖给了 500 毫秒,测试环境一切正常,上了生产用户反馈“输入框像死了一样”。排查半天发现接口在弱网下平均要 400 毫秒才返回,500 + 400 将近一秒,用户早就不耐烦了。后来把防抖降到 250 毫秒,同时在输入框里加了个 loading 指示,观感立刻好转。防抖的延迟和接口耗时是叠加的,不是取最大值,算参数时必须把这段一起算进去。
第三个坑是过度包装。有个列表页给每一行的一个小图标都套了独立的节流函数,五百行就是五百个闭包加五百个定时器句柄,页面一进去内存就蹭蹭涨。这种情况正确的做法是把包装函数提到列表外面,所有行共享一个节流实例,靠event.target或者行索引区分数据来源。包装函数是有成本的,别在循环或者列表项里现造。
后续如果还想往深里走,可以看看这两个方向:一是把防抖节流和IntersectionObserver、ResizeObserver结合起来,能用观察者解决的问题就不用监听事件,浏览器原生帮你算,比 JS 里反复读布局便宜得多;二是去翻一翻成熟工具库的源码实现,对比一下它们在leading/trailing边界、maxWait这类扩展参数上的处理方式,会发现自己手写版本里漏掉的很多边角情况。我个人在实际操作中的体会是,这两个函数加起来不到五十行,但真正吃透它带来的收益远超五十行代码本身——它训练的是对“事件频率、执行时机、生命周期”这三件事的敏感度,这种敏感度放到任何性能优化场景里都用得上。