1. 一个高频事件引发的性能灾难,以及两个救火队员
几个月前接手了一个后台管理系统,里面有个搜索框。产品需求很简单:用户在输入关键词的同时,列表要实时刷新出搜索结果。开发同学也很实在,直接在input的input事件里绑了一个接口请求。结果一上线就出事了——用户每敲一个字母,请求就发出去一次,"联","联想","联想笔","联想笔记本"……打字快的时候一秒钟能触发七八次,后端直接被干趴下了,浏览器也卡得不行。
类似的现象还有:窗口resize时重算布局、页面scroll时做懒加载、鼠标mousemove时绘制轨迹。这些事件的特点都是触发极其频繁,但绝大多数触发我们根本不关心。真正的问题不是"事件触发太多次",而是"每次触发都执行了不该执行的操作"。
这时候就需要两兄弟出手了:防抖函数(debounce)和节流函数(throttle)。它们的核心作用都是限制函数的执行频率,但思路完全相反。用一个不严谨但特别好记的类比:
- 防抖是"电梯关门"——电梯门开的时候,只要还有人进来,就重新计时,直到没人进为止,才关门出发。
- 节流是"公交车发车"——不管站台上来了多少人,车子固定每隔一段时间发一班,不会因为没人来就提前走,也不会因为人多就加开。
这篇文章我会把这两个函数的原理、手写实现、真实业务选型以及各种边界情况一次性讲透。不管你是刚入行的前端新人,还是写了好几年业务代码但一直停留在"会用lodash"阶段的老手,这篇都值得花十分钟看完。
2. 防抖函数的完整实现:从零开始手写,理解每一行代码
2.1 基础版:延迟执行的核心逻辑
防抖的本质是**"事件停止触发一段时间后才执行"**,如果在这段时间内再次触发,就重新计时。代码非常短:
function debounce(func, wait) { let timer = null; return function(...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => { func.apply(this, args); }, wait); }; }这里有个核心知识点:timer通过闭包保存在内存中,每次调用返回的函数时,都能访问到上一次的定时器。clearTimeout干不掉也没关系,定时器到期后timer只是变成了一个"已过期"的编号,下一次赋值会直接覆盖它。但如果不清理,上一次的定时器仍然会在wait毫秒后执行——这就是"防抖失效"最常见的bug来源。
这段代码的问题在于:每次执行都会延迟到事件停止后wait毫秒才触发。但实际需求往往有两种场景:
- 延迟执行:停止连续操作后等待片刻再执行(比如输入搜索)。
- 立即执行:第一次点击立即执行,后续连续点击在等待期间被忽略(比如按钮提交防连点)。
2.2 升级版:立即执行的初次触发
加上immediate参数,让第一次触发立即执行,之后wait时间内的重复触发全部无效:
function debounce(func, wait, immediate = false) { let timer = null; return function(...args) { const callNow = immediate && !timer; if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; if (!immediate) func.apply(this, args); }, wait); if (callNow) func.apply(this, args); }; }这个写法里藏着几个关键细节,需要仔细理解:
第一行const callNow = immediate && !timer——只有当immediate为真且timer为空(即当前没有等待中的定时器)时才立即执行。第一次调用时timer是null,所以callNow为true。
定时器回调里设置timer = null是关键步骤。这个赋值不能省。它的作用是:wait时间结束后,把定时器状态"重置",这样下一次事件触发时,callNow又能变成true,从而允许再次立即执行。如果没有这一行,第一次立即执行之后的所有触发都会被clearTimeout掉返回的匿名函数,timer永远不会变成null,callNow永远是false,函数彻底"死"掉。
返回值问题要注意:立即执行模式下,func.apply(this, args)的返回值在callNow分支里是能拿到的,但延迟执行分支的返回值永远是undefined。对绝大多数防抖场景(发请求、改样式、写状态)来说这不算问题。但如果你真的需要拿到返回值,那说明这个场景根本不适合用标准防抖,得用带"结果缓存"的变体,后面进阶部分会讲。
2.3 为什么必须保留this和event对象
很多人手写防抖时容易忽略this和args的透传。看这个反例:
// 错误示范 function debounce(func, wait) { let timer = null; return function() { if (timer) clearTimeout(timer); timer = setTimeout(func, wait); // this丢失,event参数也没传 }; }setTimeout(func, wait)执行时,func内部的this会是全局对象(严格模式下是undefined),拿不到原来的调用上下文——比如某个Vue实例、某个DOM元素。而且事件对象event完全丢了。如果你在防抖函数里需要读event.target.value,不带参数直接写func就会在运行时报错。
所以标准写法是捕获外层this和arguments,统一交给func.apply(this, args)处理。这一点在面试中也是高频考点,面试官就是想看你有没有注意到这个细节。
2.4 立即执行版本的实际测试效果
用一个真实场景来验证:给一个按钮绑定防抖后的提交函数,wait设置为3秒。
- 第0秒点击第一次:立即执行提交,同时启动一个3秒定时器。
- 第1秒、第1.5秒、第2秒连续点击:每次都
clearTimeout掉旧定时器并重新计时,所以不会触发提交。 - 第3秒整:定时器到期,
timer置空,整个过程结束。 - 第5秒再点击:此时
timer已经是null,callNow为true,再次立即执行。
这个模式下,函数永远在"第一次触发"和"连续触发的第3秒后"之间不会多执行。如果你把immediate设为false(默认),则第5秒点击后会等到第8秒才执行——两种模式的行为差异很大,选型时一定要想清楚业务需要的是哪种。
3. 节流函数的两种实现路线:时间戳与定时器的取舍
节流的核心是**"固定时间间隔内最多执行一次"**,不管事件触发了多少次。实现思路有两条路:时间戳记录法和定时器法。两者行为差异不大,但细节上各有坑。
3.1 时间戳实现:立即执行,停止后不再补
function throttle(func, wait) { let previous = 0; return function(...args) { const now = Date.now(); if (now - previous >= wait) { func.apply(this, args); previous = now; } }; }原理一句话:记录上一次执行的时间戳,每次触发时对比当前时间,差值大于等于wait才执行。这个实现的特点是**"头执行、尾不执行"**——第一次触发立即执行,停止触发后,最后那一次"未达标"的触发会被丢掉。
举例:设置wait = 1000ms,在0ms、200ms、500ms、800ms、1200ms、2000ms触发事件,那么执行点分别是0ms、1200ms、2000ms。800ms那次因为离上一次执行才800ms,被丢掉。
这种实现的优点是简单、可预测;缺点是滚动停止那一刻的函数调用不会执行。比如做懒加载时,用户快速滚动到页面底部然后停住,最后一次"到达底部"的触发被丢了,可能导致底部内容没加载出来,必须再滚一下才触发。
3.2 定时器实现:停止后补一次执行
function throttle(func, wait) { let timer = null; return function(...args) { if (timer) return; timer = setTimeout(() => { func.apply(this, args); timer = null; }, wait); }; }这个实现的特征是**"尾执行、头不执行"**——第一次触发要等wait毫秒后才执行,停止触发后,最后一次触发仍然会进入定时器执行。
同一个例子:wait = 1000ms,0ms、200ms、500ms、800ms、1200ms、2000ms触发,执行点是1000ms(由0ms那次触发创建的定时器)、2000ms、3000ms。第一次执行被延后了,但停止后有一次补执行。
3.3 结合版:既立即执行,又保证尾部触发
实际项目里最常用的是两者的结合:第一次触发立即执行,停止后如果有"残余触发",在等待结束时再补一次。这样首尾都不丢:
function throttle(func, wait) { let previous = 0; let timer = null; return function(...args) { const now = Date.now(); const remaining = wait - (now - previous); if (remaining <= 0) { if (timer) { clearTimeout(timer); timer = null; } previous = now; func.apply(this, args); } else if (!timer) { timer = setTimeout(() => { previous = Date.now(); timer = null; func.apply(this, args); }, remaining); } }; }逻辑拆解:
remaining计算的是"距离下一次可用执行还差多少毫秒"。remaining <= 0说明间隔已满,立即执行,同时清掉可能挂着的定时器,因为等下的定时器已经没必要了。remaining > 0且当前没有定时器,则启动一个定时器,到点后执行并把previous更新为当前时间,这样下一次事件触发时remaining是从新的基准开始计算。
务实提醒:手写结合版虽然在面试中很加分,但实际项目中我建议直接用Lodash的_.throttle。这种边界问题(定时器冲突、时间基准漂移)别人已经踩过坑了,没有必要在生产环境里冒险。
4. 防抖还是节流?看业务场景,不看代码
这是最常见的选择难题。很多人在面试里能背出定义,但遇到真实业务就懵了,因为两个函数都能"限制频率",看起来差不多。我的经验是:判断这个操作是在等"结束",还是等"节奏"。
4.1 场景对照表
| 业务场景 | 推荐方案 | 理由 |
|---|---|---|
| 搜索框实时请求接口 | 防抖 | 用户停下打字才是真正想搜索的状态,连续打字过程中的每次请求都是浪费 |
| 按钮提交防止连点 | 防抖(立即执行) | 第一次点击就该生效,wait时间内重复点击直接忽略 |
| 窗口resize重算布局 | 防抖 | 窗口拖拽过程中一直在变尺寸,希望等拖完停稳后再算一次即可 |
| 滚动监听加载更多 | 节流 | 滚动是连续的,不能用防抖(防抖会导致一直滚就一直不加载),固定间隔检查一次位置 |
| 滚动时记录滚动位置 | 节流 | 需要定期保存当前位置,防止刷新后丢失;等停止再记录会丢失中间态 |
| 游戏中的射击/技能CD | 节流 | 固定间隔发子弹,不可能等你停止操作才发 |
| 表单输入校验(非请求) | 防抖 | 校验逻辑即使执行也要等输入停顿,不给连续输入过程添堵 |
| 高频DOM拖拽时的位置同步 | 节流 | 希望拖拽过程中持续更新位置(低频),而不是停止拖动才更新 |
4.2 两个容易选错的经典例子
第一个:无限滚动加载。用防抖是错的。用户快速往下滚,事件连续触发,用防抖的话,只要滚动一直不停,加载函数就永远不执行——直到用户完全停下来才开始加载。如果数据量大或网络慢,用户会看到"滚到底了但永远在转圈"的糟糕体验。正确的做法是节流,比如每200ms检查一次"是否接近页面底部",到了就发请求。
第二个:登录按钮防连点。这里必须用防抖的立即执行版本,而不是普通防抖。普通防抖(延迟执行)意味着用户第一次点击后要等几百毫秒才真正提交,视觉上按钮没反应,用户容易再点一次,反而触发更多请求。立即执行版则保证第一次点击瞬间就提交,后续连续的点击全部被吞掉。
4.3 埋一个"参数传递"的坑
在事件监听里绑定防抖/节流函数时,经常有人犯这个错误:
// 错误的监听方式:每次渲染都会创建新的防抖函数 input.addEventListener('input', debounce(handleInput, 300));事件监听里直接执行debounce(handleInput, 300),问题在于debounce只调用一次,返回的是同一个防抖函数,这个写法本身没问题。真正容易出事的是在React的render或Vue的模板里每次渲染重新执行:
// React里这样写,每次render都会生成新的防抖函数,防抖完全失效 const handleSearch = debounce(fetchData, 300); // 但如果是内联写法: <Input onChange={() => debounce(fetchData, 300)(e)} /> // 每次渲染都新建防抖上下文,等于没防抖解决方案是使用useRef或useMemo把防抖函数在整个组件生命周期内只创建一次,或者在模块顶层定义为常量。这也是实战中十个人有五个会踩的点。
5. 进阶优化:取消、取消防抖、缓存结果与动态调整等待时间
5.1 给防抖函数加cancel方法
业务场景:用户在搜索框里输入了内容,"停止输入300ms后发请求"这个任务已经排进定时器了,但用户在等待期间点了一下"重置"按钮,此时应该把待执行的定时器取消,避免发出上一次的无效请求。
function debounce(func, wait) { let timer = null; const debounced = function(...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; func.apply(this, args); }, wait); }; debounced.cancel = function() { if (timer) clearTimeout(timer); timer = null; }; return debounced; }注意cancel里也要把timer置空,否则如果直接clearTimeout而不置null,回调函数要从timer里判断状态时会出错。
另一个常见需求是flush——不等等待时间结束,立刻执行并清掉定时器。在写测试用例时特别有用:
debounced.flush = function(...args) { if (timer) { clearTimeout(timer); timer = null; func.apply(this, args); } };5.2 等待时间动态调整:自适应防抖
很多场景下固定wait不够聪明。比如搜索框,用户敲字有快有慢,快的用户按200ms没问题,慢的用户200ms可能连续触发。一个折中方案是动态等待时间:如果上一次触发和当前触发间隔很短,说明用户正在快速输入,可以自动增加等待时间;如果间隔较大,说明输入节奏慢,可以适当减少。
function adaptiveDebounce(func, wait, maxWait) { let timer = null; let lastCallTime = 0; return function(...args) { const now = Date.now(); const timeSinceLast = now - lastCallTime; const dynamicWait = Math.min(maxWait, Math.max(wait, timeSinceLast * 2)); clearTimeout(timer); timer = setTimeout(() => { func.apply(this, args); }, dynamicWait); lastCallTime = now; }; }这个实现不能说严谨,但作为思路示例够用。实际项目里我一般还是用maxWait参数来做兜底——即使防抖逻辑下一直有新任务进来,也不允许执行被无限推迟,maxWait是"最大等待时间"的底线。
5.3 节流的leading和trailing配置
Lodash的_.throttle本质上是对_.debounce的封装,支持leading和trailing两个选项:
leading: true:是否在首次触发时立即执行。trailing: true:是否在等待时间结束后补一次执行。
两个都设为true就是前面写的"结合版",两边都不丢。两个都设为false的节流没有任何意义(事件会被吞干净),所以lodash源码里强制两个不能同时为false。
5.4 一项被很多人忽略的性能优化:防抖/节流函数内部的requestAnimationFrame
对于DOM相关的防抖/节流,性能优化的上限其实不取决于调用频率,而取决于浏览器的渲染帧率。现代浏览器每帧约16.7ms,你在1帧里触发10次事件,也只会有1次渲染效果。与其用setTimeout做节流,不如考虑用requestAnimationFrame(rAF)来处理:
function throttleRaf(func) { let running = false; return function(...args) { if (running) return; running = true; requestAnimationFrame(() => { func.apply(this, args); running = false; }); }; }requestAnimationFrame的优点是:
- 自动匹配屏幕刷新率,过快的设备上不会浪费CPU。
- 页面切到后台时,rAF自动暂停,减少无谓的计算。
- 和浏览器的渲染管线对齐,不会出现"改了DOM但渲染还没开始"的错位。
缺点是无法做到精确的毫秒级控制,足够用于resize、scroll、mousemove这类视觉类事件,不适合需要"固定间隔稳定执行"的逻辑(如轮询、打点上报)。
6. 面试与工程中的细节追问:this指向、返回值、代码实现对比
面试官问防抖节流,很少只让你写个基本版就完事。我梳理了几个最高频的追问和应答要点。
6.1 防抖函数中this的指向为什么要用apply透传
因为返回的函数是独立存在的,直接用func()调用,函数内部的this会丢失。比如在Vue组件里绑定this.handleClick,handleClick内部用了this.someData,防抖返回的新函数执行时this不再是组件实例,代码直接崩。必须用闭包捕获外层this,再func.apply(this, args)。
6.2 防抖和节流在处理异步请求时的差异
纯函数层面的区别不大,但配合请求结果处理时要额外小心。
防抖场景(搜索框):用户输入"abc",停止300ms发了请求A,返回后渲染结果。但如果用户在请求A还没返回时就清了输入框,此时请求A返回的结果不应该再渲染。处理方案有三种:
- 在函数里维护一个请求序号,每次发起请求前自增,回调里比对序号。
- 使用AbortController取消上一次请求。
- 防抖函数里加一个"是否已取消"的状态位,取消时同步更新状态。
function debounceWithToken(func, wait) { let timer = null; let token = 0; return function(...args) { clearTimeout(timer); const currentToken = ++token; timer = setTimeout(() => { if (currentToken === token) { func.apply(this, args); } }, wait); }; }token颈部的判断可以保证:如果用户在等待期间又做了一次新操作,旧的那一次执行任务就被作废了。这个做法在并发请求场景下比单纯防抖更安全。
6.3 手写版本vs Lodash成熟实现:什么时候该用哪个
我的建议分三档:
| 场景 | 选择 | 理由 |
|---|---|---|
| 面试/学习原理 | 手写 | 理解闭包、this、计时器的精髓 |
| 业务代码(能引库) | lodash / underscore | 经过大量边界测试,支持cancel、flush、leading/trailing配置 |
| 业务代码(function片段/老项目) | 手写简易版 | 只要几十行代码,不引依赖 |
有一点很容易被误解:认为lodash的_.debounce和手写版的核心逻辑完全一样。实际上lodash处理了很多边界情况,比如**timer过期但还没执行时再次调用**、带maxWait的防抖(等价于节流)、错误处理等等。生产环境优先用成熟库,不是不自信,而是成熟的实现确实更可靠。
6.4 常被忽略的"同步与异步测试"差异
给防抖函数写单测时,新手常犯的错误是直接用同步断言:
// 错误示范:断言立即失败,因为防抖是异步执行的 const fn = debounce(() => result = 1, 300); fn(); expect(result).toBe(1); // 此时result还是undefined正确做法是用jest.useFakeTimers()模拟定时器,配合jest.advanceTimersByTime(300)推进时间后断言;或者用真实的setTimeout+done回调,等待300ms之后断言。很多看起来"怪"的bug,其实都是测试时序问题。
7. 变体和扩展思路:合并请求、Promise化防抖、事件流中的管道应用
7.1 合并请求:防抖在接口层的另一种用法
前端页面高频触发的操作,除了DOM事件,还有一类是相同请求的重复发送。比如用户疯狂点"刷新列表"按钮,或者多个组件同时发起同一个数据接口的请求。这时可以把防抖用在请求层,把wait时间内的多次相同请求合并成一次:
function mergeDebounce(fn, wait) { let timer = null; let pendingArgs = []; return function(...args) { pendingArgs = args; clearTimeout(timer); timer = setTimeout(() => { fn(...pendingArgs); }, wait); }; }这个思路在做"输入联想"接口时特别好用——用户敲"联想笔记本",防抖后最终只发出一次请求,但URL参数是最后一次的完整词。换言之,前面所有中间状态("联""联想""联想笔")的请求都被合并掉了。
7.2 Promise化防抖:调用方需要知道请求什么时候完成
如果防抖函数内部做了异步操作,调用方想知道"这次操作的结果",标准防抖做不到,因为返回undefined。解决方案是让防抖函数返回一个Promise:
function debouncePromise(func, wait) { let timer = null; let resolveList = []; return function(...args) { clearTimeout(timer); const resultPromise = new Promise((resolve) => { resolveList.push(resolve); }); timer = setTimeout(() => { const result = func.apply(this, args); resolveList.forEach((resolve) => resolve(result)); resolveList = []; }, wait); return resultPromise; }; }这样调用者可以await debouncedFn(...),但要注意:等待期间的多次调用都会被收集,在定时器执行后一次性resolve——如果想要"最后一次调用才返回Promise,前面的调用立即reject/返回空",需要更精细的状态机。这个版本适合日志上报、批量操作确认类场景。
7.3 配合事件流框架使用
如果你项目里用了RxJS这类响应式框架,防抖和节流可以直接用操作符替代手写逻辑:
debounceTime(300):等价于防抖。throttleTime(300):等价于节流。auditTime(300):类似节流的"尾部执行"。sampleTime(300):固定间隔取样。
在处理复杂事件流(比如拖拽 + 键盘 + 动画的组合)时,操作符的链式调用的表达力远超手写函数。
7.4 防抖/节流在服务端也能用
不要以为这是前端专属概念。服务端处理高并发请求时,也可以用类似思路做请求合并和频率限制。比如Node.js里多路请求需要批量写数据库,用防抖收集短时间内到达的请求,合并后一次性写库,DB连接数能省一大半。不过服务端场景通常更建议用队列、限流中间件(如令牌桶),因为防抖天然引入延迟,并不适合所有后端接口。防抖和节流工具函数本身也是通用的,和语言无关。
8. 我在真实项目中踩过的三个防抖/节流的坑,以及绕开方法
8.1 坑一:React事件系统里,防抖函数被重复创建
这是React函数组件中最常见的错误。看这个代码:
function SearchBox() { const handleInput = debounce((e) => { fetchSuggestions(e.target.value); }, 300); return <input onInput={handleInput} />; }每次render都会执行debounce(...),生成一个全新的防抖函数,绑定到input上,旧的监听器被移除。防抖的闭包状态完全丢失——等于每个新函数都是"首次调用",防抖完全失效。
正确做法:
function SearchBox() { const handleInputRef = useRef(); if (!handleInputRef.current) { handleInputRef.current = debounce((e) => { fetchSuggestions(e.target.value); }, 300); } const handleInput = handleInputRef.current; return <input onInput={handleInput} />; }或者更简洁地使用useMemo/useCallback并搭配useRef持久化。原理都一样:保证整个生命周期内只创建一次防抖函数。
8.2 坑二:使用节流时把队尾的触发丢失了
做无限滚动时,我早期用的是3.1节的时间戳版节流,结果遇到一个诡异问题:快速滚到接近页面底部时,"触发加载函数"的条件判断在remaining <= 0的分支里,但只要滚动够快,最后一次"接近底部"的触发可能因为离上一次执行不足wait毫秒而无法通过判断,页面停在底部,加载却永远不来。
后来改用了3.3的"首尾都执行"版本,问题消失。从此我的经验是:凡是跟"位置是否到达某个边界"有关的节流,一律用结合版,确保队尾执行不丢。
8.3 坑三:wait时间设置得太短/太长,完全没达到预期
- 设置太短(比如1ms):防抖退化为普通函数调用,节流退化为每次触发都执行,没有起到限制效果。
- 设置太长(比如1000ms):搜索框停止输入一秒后才出结果,用户会觉得"卡";无限滚动可能要滚动停下来才加载;按钮点击后等一秒才提交,体验很糟。
经验值参考(不绝对):搜索建议300~500ms;按钮防连点300~800ms;无限滚动200~300ms;resize重算300~500ms;实时保存草稿800~1500ms。具体数值要根据业务体感和实际测量微调,不要在线上拍脑袋改,先在测试环境设几个档位对比。
8.4 一个关于"防抖是否真的只是延迟执行"的澄清
写这篇文章前再明确一点:防抖不是"把函数执行推后",而是"在连续触发中,只保留最后一次"。如果你希望每次触发都能执行,只是降低频率,那就该用节流。这就是电梯和公交车的本质区别。面试时把这个类比讲清楚,会显得理解得透彻。
9. 个人实操心得:工作中怎么落地
我最后再分享一条很实际的建议:不要因为防抖节流听起来"高级"就什么都往上套。有些场景其实不需要它们。
比如一个按钮的点击事件里只有一行console.log,你给它套个防抖,纯属画蛇添足;比如一个mousemove事件里只改一个CSS变量(浏览器本身optimizes),你给它套个节流,反而可能让动画不那么流畅。防抖和节流解决的是**"高频事件触发了昂贵操作"**的组合问题,不是"所有事件都要降频"。
如果想要简化写法,可以封装一个通用的"事件执行管理器"账号,接收事件类型、回调、策略(debounce/throttle),内部统一处理注册和清理,避免在组件里散落一堆定时器:
class FrequentCallLimiter { constructor(strategy, fn, wait) { this.limitedFn = strategy === 'debounce' ? debounce(fn, wait) : throttle(fn, wait); } execute(...args) { return this.limitedFn(...args); } cancel() { if (this.limitedFn.cancel) this.limitedFn.cancel(); } }我自己在工作中还有一个习惯:每次写完防抖/节流相关的代码,都会在浏览器Performance面板里跑一次真实的用户路径,确认高频事件触发期间的函数调用次数确实降下来了。眼见为实,总比"我觉得应该没问题"靠谱得多。
这两个工具函数,原理简单,边界不少,用好了能大幅提升前端性能和用户体验。希望这篇能帮你少走几个弯路。