防抖 Debouncing 完整指南:从一次真实卡顿到手写实现
防抖 Debouncing 完整指南:从一次真实卡顿到手写实现
【免费下载链接】30-seconds-of-interviewsA curated collection of common interview questions to help you prepare for your next interview.项目地址: https://gitcode.com/gh_mirrors/30/30-seconds-of-interviews
在 30-seconds-of-interviews 这份前端面试题库里,“What is debouncing?”是 JavaScript 方向出场率最高的一道题。本文不打算先背定义,而是从一个大家都踩过的现场开始:一个每敲一个键就发一次请求的筛选框。
从一次卡顿说起:高频事件为什么打满主线程
这节回答一个问题:为什么朴素的事件监听器,会把页面拖得这么卡?
想象一个商品列表的关键词筛选框:input事件一触发就fetch,解析响应,再重新渲染列表。用户敲 "react" 五个字符,就发了 5 个请求、重排了 5 次列表,而用户真正在意的只有最后一次。scroll这类高频事件更夸张——仓库题目里给了个数字:裸的 DOM 事件监听器每秒能轻松触发 20+ 次事件。如果在scroll监听里直接做布局计算、写存储,回调本身就会一直占着主线程,浏览器来不及完成一帧绘制,用户看到的就是肉眼可见的卡顿。
所以问题不在逻辑写错了,而在逻辑跑得太多。那防抖函数凭什么让同样的逻辑只跑一次?
用电梯关门逻辑理解防抖原理
这节回答:怎么把“执行太多次”变成“只执行一次”?
想想电梯门:快关门时,只要有人再按开门键,门就重新拉开、再次延迟关门;直到没人再按,门才真正合上。防抖借的就是这套“安静下来才动作”的思路——每来一次触发就重置定时器,只有当触发停歇、连续 delay 毫秒都没有新的到来,定时器才终于走完,目标函数被执行一次。
说白了,防抖就是给函数按下“稍等”键:触发来得越密,执行就被推得越靠后,最终整串高频触发只留下一次调用。仓库题目给出的标准实现只有十几行,重点不是照抄,而是搞清下面几个机制各自负责什么。
手写防抖实现:只讲透三个关键机制
这节直接给一份可运行的完整实现,然后只聚焦支撑它的三个机制,不做逐行罗列。
function debounce(fn, wait) { let timer = null; return function (...args) { const ctx = this; clearTimeout(timer); timer = setTimeout(() => fn.apply(ctx, args), wait); }; }机制一:闭包保存“定时器句柄”。timer声明在返回函数之外,活在debounce自己的作用域里。每次调用返回的函数时,都能读到上一次留下的timer并把它改掉——这是“能重置”的前提。没有闭包,每次调用都会拿到一个全新的timer变量,上一句定时器就永远取消不了了。
机制二:clearTimeout 永远先跑。每次触发都先清掉上一个没到期的定时器,再重新挂一个新的。只要事件还在高频到来,定时器就永远在到期前被掐掉,fn一次也不会执行;直到事件停歇,最后一次挂上的定时器顺利走完,fn才执行一次。先清后挂这个顺序,就是防抖的全部核心。
机制三:this 与参数透传。返回的函数刻意用普通函数而不是箭头函数,为了捕获调用那一刻的this(比如监听器里绑定的 DOM 元素),再把实参存进args;最后fn.apply(ctx, args)把原来的调用现场完整还原。少了这一步,被包装的函数会悄悄丢掉上下文和参数。
💡 注意这里的“内外有别”:外层用普通函数捕获动态的this,内层setTimeout的回调反而用箭头函数,让ctx和args稳定地待在闭包里。两层搞混,调用语义就变了。
防抖实战:给自动保存加上 800ms 缓冲
这节用一个真实业务场景,说明防抖到底省掉了什么。
表单页的“草稿自动保存”很典型:用户停止输入后,把最新内容提交到服务端一次就够了。
const editor = document.querySelector('#draft'); const saveDraft = debounce(() => { fetch('/api/draft', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: editor.value }), }); }, 800); editor.addEventListener('input', saveDraft);不加防抖时,用户连敲 50 个字就冲出去 50 个 POST,服务端收到一堆中间状态,绝大多数没有意义;加上 800ms 缓冲后,通常只有 1 个请求落地,而且就是最终状态。省下的不只是带宽,更重要的是消除了“旧响应覆盖新结果”的竞态,主线程也不会被反复的序列化和发送占满。
scroll和resize同理:想等窗口停止缩放后再重排一次布局,把监听器包一层debounce就行。
防抖和节流的区别:一张表 + 三个高频追问快答
这节回答面试官最爱连问的两个问题:防抖和节流差在哪?你会不会扩展它?
| 对比项 | 防抖(debounce) | 节流(throttle) |
|---|---|---|
| 何时执行 | 事件停歇、安静窗口过后执行一次 | 固定间隔内最多执行一次 |
| 首次触发 | 默认不执行(trailing) | 可以立即执行一次(leading) |
| 窗口内执行次数 | 无论触发多少次,只留一次 | 按保底频率持续执行 |
| 适合场景 | 输入搜索、resize 重排、自动保存 | 滚动加载、拖拽、进度上报 |
| 一句话记忆 | 把“一段事件”压缩成一次 | 给“执行频率”装上限 |
三个高频追问,快速过一遍:
leading 与 trailing 模式:要哪个?
默认实现是 trailing——安静之后才执行。如果想要“第一次点击立即响应,随后连点全部忽略”(按钮防连点、轮播只上报首帧点击),加一个immediate选项即可:
function debounce(fn, wait, immediate = false) { let timer = null; const run = function (...args) { const callNow = immediate && timer === null; clearTimeout(timer); timer = setTimeout(() => { timer = null; if (!immediate) fn.apply(this, args); }, wait); if (callNow) fn.apply(this, args); }; run.cancel = () => { clearTimeout(timer); timer = null; }; return run; }immediate = true时,“定时器是否存在”充当冷却期标记:首次调用立即执行,timer存活期间后续触发一律丢弃;延迟结束后timer归位,才允许下一次立即执行。
cancel 取消机制:别忘了清理
⚠️ 这里有个坑:防抖函数最好暴露一个cancel。组件卸载时,挂起的定时器还在,一旦触发就会操作已经不存在的 DOM。一行clearTimeout就能让它安静离场,上面这段代码已经顺手带上了。
this 与参数透传的坑
为什么不能整体换成箭头函数?外层必须用普通函数捕获动态的this;内层setTimeout回调用箭头函数,是为了把ctx、args钉在闭包里。顺序颠倒,上下文就丢了。
防抖自检三问:你能现场手写吗
这节不给你背清单,而是把要点变成三个问题——能当场答全,这道题就算过关了。
| 自检问题 | 应该能答出 |
|---|---|
| 为什么页面会卡? | 事件以 20+ 次/秒的密度派发,回调执行太多次,占满主线程 |
| 防抖的灵魂是哪两行? | clearTimeout先清旧定时器,setTimeout再挂新的,只有最后一个能到期 |
ctx/args是干什么的? | 透传原函数的this与实参,保证包装后调用语义不变 |
顺带一提,这道题在仓库里的链路是这样的:题目正文存放在 questions/debouncing.md,由 scripts/extract.js 拆出 Answer、Good to hear 等章节,写入 data/questions.json 后交给网站前端渲染成可检索的题库。换到任何一端看,答案的骨架都不变:事件安静下来,才执行一次。
【免费下载链接】30-seconds-of-interviewsA curated collection of common interview questions to help you prepare for your next interview.项目地址: https://gitcode.com/gh_mirrors/30/30-seconds-of-interviews
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考