防抖 Debouncing 深度解析:一个定时器如何吞掉 20 次调用
【免费下载链接】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
搜索框每按一个键就发一次请求、滚动监听每秒触发 20 多次重活还在傻跑——这是前端高频事件的常见事故现场。防抖(Debouncing)就是为此而生的:事件不停,代码不跑;事件一停,才执行一次。开源面试题库 30-seconds-of-interviews 用一份 Markdown 题目文件就把这套机制讲完了,下面从事故现场到仓库管线完整走一遍。
先花 30 秒拿住核心
- 防抖的本质:把时间窗口内的多次连续调用"合并"成窗口结束后的一次执行,裸 DOM 事件监听器每秒可以轻松触发 20+ 次,防抖版只在延迟期满后调用一次;
- 全部实现就两个 API:
clearTimeout+setTimeout,外加一个用闭包(Closure)保存的定时器句柄; - 判断标准:事件还在密集触发,定时器永远到不了期;事件停了,最后一个定时器走完,目标函数执行恰好一次;
- 经典落点:搜索输入"停键后才发 API 请求"、
scroll/resize处理降频。
机制对读:定时器被反复"清零"的游戏
想象一个带自动闭门器的门。你每推它一次,闭门器就重新归位、重新计时;只有当你有一段时间不推门,它才"唰"地关上。防抖就是给函数装了这么个闭门器:
- 第 1 次调用:启动一个
delay毫秒的定时器; delay内又来第 2 次调用:先clearTimeout掐掉上一个,再重新计时;- 事件持续到来,定时器被反复清零,目标函数一次都没跑;
- 事件停止,最后一个定时器独自走完——函数执行恰好一次,且拿到的是最后一次调用的参数。
关键在于"定时器句柄存在哪":它必须在返回函数的外层作用域里(闭包),下一次调用才拿得到上一次的句柄去清除。句柄若声明在内层,每次调用都是一个新值,clearTimeout永远清不到东西,合并能力直接失效。
动手实现:12 行可跑的防抖
下面是仓库题目给出的标准实现(原样引用,注意原文addEventListere为拼写错误,应为addEventListener):
const debounce = (func, delay) => { let debounceTimer; // 闭包:保存定时器句柄 return function() { const context = this; // 捕获调用时的 this const args = arguments; // 捕获全部实参 clearTimeout(debounceTimer); // 掐掉上一次未到的定时器 debounceTimer = setTimeout(() => func.apply(context, args), delay); } } window.addEventListener('scroll', debounce(function() { // Do stuff, this function will be called after a delay of 1 second }, 1000));逐行说人话:
let debounceTimer:声明在返回函数之外,靠闭包跨调用存活,是"重置"能力的前提;clearTimeout必须在setTimeout之前执行,顺序反了就白搭;context/args加上func.apply:包装层原样透传调用现场,不改写目标函数的语义;- 用法上:
debounce(fn, 1000)返回的是新的包装函数,挂到scroll上的是它,1 秒后用户停止滚动,真实回调才执行一次。
工程账本:优化前 vs 优化后
以"输入 react 这 5 个字符,每键发一次搜索请求"为例:
| 维度 | 不加防抖 | 300ms 防抖 |
|---|---|---|
| 网络请求次数 | 5 次 | 1 次 |
| 用户体感 | 连续刷新结果,旧响应可能覆盖新结果 | 停顿后出一次结果,稳定 |
| 主线程负担 | 每次按键都渲染 | 仅末态渲染一次 |
再对照滚动场景:题目里明确裸监听器"每秒可触发 20+ 次",防抖后一段连续滚动只结算一次。两个场景共同指向同一条原则——以末态为准的事(末次搜索词、末次视口尺寸),交给防抖最划算。
追问时间:面试官真正想听的部分
追问 1:防抖 vs 节流,边界在哪?
| 对比项 | 防抖(debounce) | 节流(throttle) |
|---|---|---|
| 执行时机 | 调用停止后延迟执行一次 | 固定间隔内至多一次 |
| 控制对象 | 窗口内的调用次数 | 执行的频率上限 |
| 典型场景 | 搜索、resize 后重排 | 拖拽、滚动进度、游戏循环 |
| 末态保证 | 保证用最后一次参数 | 不保证,可能丢末态 |
记忆锚点:要"末态"选防抖,要"保底频率"选节流。
追问 2:this和参数为什么单独接住?外层return function()必须是普通函数而非箭头函数——事件监听器里this是动态的(窗口或绑定元素),箭头函数会把它锁死成定义时的作用域;arguments则原样接住所有实参,最后经func.apply一次性透传。
追问 3:首次调用能立即执行吗?能,这就是 leading edge 模式:首次调用直接执行,之后进入冷却期忽略后续调用,delay结束才解锁。但仓库给出的标准实现是 trailing(末次执行),面试先答这个,再主动补一句"需要首触立即执行时可用 leading 版",层次就出来了。
项目视角:这道题在仓库里走了哪条链路
- questions/debouncing.md——内容源。含
#### Answer、#### Good to hear等固定小节(格式约定见 question-template.md),另有<!-- tags: -->、<!-- expertise: -->隐藏注释承载标签与难度; - scripts/util.js——解析工具层。
readQuestions()按字母序读取整个questions/目录,getSection()按标题切块,getCodeBlocks()抽取代码块; - scripts/extract.js——管线核心。把每题的 question / answer / goodToHear / 难度 / 代码块组装成对象写入
data/questions.json,对应package.json里的"extractor": "node ./scripts/extract.js"; - data/questions.json——数据文件,前端唯一数据源,由 website/js/state.js 直接 import 进 Hyperapp 状态;
- website/js/components/——展示层:
Filter.js管标签过滤,Questions.js负责按难度排序渲染列表,Question.js展开单题,Markdown.js用 marked 渲染答案、Prism 高亮代码。
一条冷知识可以加分:当前提交的data/questions.json只有 103 道题,而questions/目录已有 109 个 md 文件,debouncing.md还没进数据——这道题是在数据快照之后加入的,重跑一次"extractor"脚本它就会出现在网站上。
自测卡片
- 防抖一次触发里,
clearTimeout和setTimeout各执行几次? - 持续滚动 3 秒后停止(delay=1000ms),回调执行几次?
- 外层返回箭头函数会丢掉什么?
- 需要"保证每 100ms 至少执行一次"的场景,该选防抖还是节流?
关键词收束:防抖(Debouncing)、clearTimeout+setTimeout、闭包状态、末态合并、防抖和节流的区别(debounce vs throttle)。
【免费下载链接】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),仅供参考