ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

前端性能优化:防抖与节流的核心差异、工程实现与场景选型

前端性能优化:防抖与节流的核心差异、工程实现与场景选型 做前端这些年被问得最多的一类问题不是框架怎么选而是“这个滚动监听卡得要死怎么办”“输入框每敲一个字就发一次请求后端同事快盯上我了”。这类问题的答案基本都落在防抖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和立即结算flushfunction 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”会连续触发七八次但此时输入框里的值是拼音串而不是汉字。如果只做防抖不做保护用户拼音还没选完就可能发出去一个?qxing的请求。解决办法一行就够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就用错了得换成容器的scrollTopinnerHeight换成clientHeightscrollHeight换成容器的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-350mstrailing: true配合请求序号表单实时校验防抖400-600ms监听compositionstart/end自动保存草稿防抖800-1500ms卸载前flush一次窗口 resize 重排防抖150-200ms图表类可配合ResizeObserver滚动加载更多节流150-250mspassive: true注意容器内滚动的取值埋点曝光上报节流500-1000ms用performance.now()计时拖拽跟随、视差rAF 节流跟帧走后台切回时重置状态按钮防连点防抖500-800msleading: 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这类扩展参数上的处理方式会发现自己手写版本里漏掉的很多边角情况。我个人在实际操作中的体会是这两个函数加起来不到五十行但真正吃透它带来的收益远超五十行代码本身——它训练的是对“事件频率、执行时机、生命周期”这三件事的敏感度这种敏感度放到任何性能优化场景里都用得上。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表