题干与适用场景
为浏览器侧代码实现 debounce(fn, wait, options) 和 throttle(fn, wait, options)。返回函数需要保留最后一次调用的参数和 this,返回最近一次真正执行的结果,并提供 cancel() 与 flush()。选项包括 leading、trailing,防抖还支持 maxWait。
默认防抖只在 trailing 边缘执行。leading 表示一轮连续调用开始时立即执行。如果 leading 和 trailing 同时开启,一次孤立调用只在 leading 边缘执行一次;只有等待期内出现第二次调用,才用最新参数补一次 trailing 执行。maxWait 防止连续事件永远推迟任务。节流则保证每个 wait 长度的区间内最多真正执行一次,并支持 leading 与 trailing。
这道题归为 frontend,因为契约来自输入、滚动、窗口调整、渲染和组件生命周期。工具也能运行在 Node.js,但核心考察仍是把 UI 时间语义转换成小型状态机。实现使用 JavaScript,额外空间为 O(1)。
面试官考察点
第一个信号是先定义契约再写计时器。“防抖等待、节流限频”没有说明 leading 如何工作、一次孤立的 leading 调用是否还要 trailing、哪次参数生效,以及清理意味着什么。强回答会先写调用时间线,并明确这些选择。
第二个信号是状态推理。加入 maxWait、时钟回拨、返回值和 flush() 后,一个计时器变量已经不够。实现需要记录最后调用时间、最后真正执行时间、待处理参数与接收者、计时器句柄和最近结果;每一项状态都应对应一条契约。
第三个信号是浏览器判断。计时器只保证最早执行时间,不保证精确截止点;长任务和后台限速会让回调变晚。用 requestAnimationFrame() 处理滚动可以对齐绘制,但它本身不一定降低事件频率。有时 scrollend、IntersectionObserver 或框架清理钩子可以直接替代通用计时工具。
最后要看测试,而不是凭肉眼感受。依赖真实等待的测试既慢又容易抖动。强回答会替换时间与计时器、推进假时钟,并对边界、取消、flush 和连续输入的精确调用序列做断言。
回答前需要澄清的问题
- 默认选项是什么? 本文使用防抖
{ leading: false, trailing: true },节流{ leading: true, trailing: true }。默认值不同,孤立调用和测试结果也不同。 - 两个边缘都开启时如何处理? 一次孤立调用只执行 leading;等待期内又有调用时才执行 trailing,避免单次操作被重复处理。
- 延迟执行使用哪次参数和接收者? 使用最后一次待处理调用的参数和
this。保留第一次输入会让联想搜索或尺寸计算读取旧值。 - 输入会不会一直持续? 会的话,纯 trailing 防抖可能永远不执行;
maxWait从上一轮真正执行或当前 burst 开始计算最大推迟时间。 cancel()与flush()的契约是什么? cancel 删除待处理任务并重置一轮调用状态;flush 立即执行符合条件的 trailing 调用并返回结果,之后不能再重复执行。- 是否要求精确毫秒? 浏览器计时器无法保证精确调度。契约约束最早可执行时间和顺序,测试使用假时钟排除调度噪声。
30 秒回答框架
“我会先用时间线定义 leading 和 trailing。防抖把连续调用归成一组,通常在最后一次调用后安静 wait 毫秒才执行;节流要在连续输入期间周期性推进。我会保存最新参数与接收者、最后调用时间、最后真正执行时间、一个计时器和最近结果。
每次调用先判断现在是否可执行,否则安排剩余等待。计时器触发时重新判断,因为后来的调用可能移动 trailing 截止点。maxWait 防止一直饥饿;把 maxWait 固定为 wait 就能复用同一个状态机实现节流。cancel() 清理待处理状态,flush() 最多立即补一次 trailing。测试用假时钟,重点覆盖边界时刻、leading 加 trailing、连续输入、取消、flush 和组件卸载。”
分步骤深入解答
第一步:把文字转换成时间线
假设 wait = 100 毫秒,调用为 A@0、B@40、C@90、D@220。纯 trailing 防抖得到 C@190 和 D@320:每次调用都会移动安静期截止点,最新参数胜出。leading 与 trailing 同时开启时得到 A@0、C@190、D@220;D 是孤立调用,所以 320 毫秒不会再重复一次。
leading 与 trailing 同时开启的节流,在每个 100 毫秒窗口最多执行一次,同时保留窗口结束时的最新待处理值。第一轮会立即执行 A,再在 trailing 边界执行最新的 C。真实浏览器里的执行时间可能晚于概念边界,但不能早于它。
这条时间线直接暴露状态转换:空闲时的调用开启 burst;后续调用替换待处理数据;计时器到期后执行或重新安排;真正执行会清除待处理数据,但保留返回结果。先写这些转换,可以消除多数边界与重复 trailing 错误。
第二步:实现一个明确的状态机
下面实现遵循上述契约。shouldInvoke() 处理首次调用、安静期边界、时钟回拨和 maxWait;remainingWait() 从 trailing 截止点与最大等待截止点中选择更早者。
function debounce(fn, wait, options = {}) {
wait = Math.max(0, Number(wait) || 0);
const leading = options.leading === true;
const trailing = options.trailing !== false;
const hasMaxWait = Number.isFinite(options.maxWait);
const maxWait = hasMaxWait
? Math.max(wait, options.maxWait)
: 0;
let timerId;
let lastArgs;
let lastThis;
let lastCallTime;
let lastInvokeTime = 0;
let result;
function invoke(time) {
const args = lastArgs;
const receiver = lastThis;
lastArgs = undefined;
lastThis = undefined;
lastInvokeTime = time;
result = fn.apply(receiver, args);
return result;
}
function shouldInvoke(time) {
const sinceCall = time - lastCallTime;
const sinceInvoke = time - lastInvokeTime;
return lastCallTime === undefined
|| sinceCall >= wait
|| sinceCall < 0
|| (hasMaxWait && sinceInvoke >= maxWait);
}
function remainingWait(time) {
const sinceCall = time - lastCallTime;
const trailingWait = wait - sinceCall;
if (!hasMaxWait) return trailingWait;
const sinceInvoke = time - lastInvokeTime;
return Math.min(trailingWait, maxWait - sinceInvoke);
}
function trailingEdge(time) {
timerId = undefined;
if (trailing && lastArgs) return invoke(time);
lastArgs = undefined;
lastThis = undefined;
return result;
}
function timerExpired() {
const time = Date.now();
if (shouldInvoke(time)) return trailingEdge(time);
timerId = setTimeout(timerExpired, remainingWait(time));
}
function leadingEdge(time) {
lastInvokeTime = time;
timerId = setTimeout(timerExpired, wait);
return leading ? invoke(time) : result;
}
function cancel() {
if (timerId !== undefined) clearTimeout(timerId);
timerId = undefined;
lastArgs = undefined;
lastThis = undefined;
lastCallTime = undefined;
lastInvokeTime = 0;
}
function flush() {
if (timerId === undefined) return result;
clearTimeout(timerId);
return trailingEdge(Date.now());
}
function debounced(...args) {
const time = Date.now();
const invokeNow = shouldInvoke(time);
lastArgs = args;
lastThis = this;
lastCallTime = time;
if (invokeNow) {
if (timerId === undefined) return leadingEdge(time);
if (hasMaxWait) {
clearTimeout(timerId);
timerId = setTimeout(timerExpired, wait);
return invoke(time);
}
}
if (timerId === undefined) {
timerId = setTimeout(timerExpired, wait);
}
return result;
}
debounced.cancel = cancel;
debounced.flush = flush;
return debounced;
}
function throttle(fn, wait, options = {}) {
return debounce(fn, wait, {
leading: options.leading !== false,
trailing: options.trailing !== false,
maxWait: wait,
});
}状态数量为 O(1),每次包装函数调用执行 O(1) 工作;被包装回调自身的成本不计入工具复杂度。生产项目可以导入成熟实现;面试价值在于能解释和验证契约,而不是证明项目必须自研。
第三步:解释计时器为何需要重新判断
假设首次调用安排了 100 毫秒计时器,第二次调用在 90 毫秒到达。如果原计时器在 100 毫秒无条件执行,安静期只有 10 毫秒。timerExpired() 会重新计算 sinceCall,发现尚未安静 100 毫秒,再安排剩余 90 毫秒。
每个事件都清除并新建计时器,是纯 trailing 防抖的合理简化实现。这里的重新判断状态机值得保留,是因为它还要统一支持 leading、maxWait、返回值和节流。如果题目只要求基础防抖,应先写更小的实现,再说明扩展需要哪些状态。
第四步:用 maxWait 防止饥饿
联想搜索通常可以等待输入暂停,遥测缓冲或自动保存不能在输入持续时无限等待。设 wait = 300 毫秒、maxWait = 1000 毫秒,即使每 100 毫秒都有调用,也会在每个约 1000 毫秒最大边界至少执行一次;运行时调度仍可能让它稍晚。
maxWait 也连接了防抖和节流。令它等于 wait,连续调用就不能把真正执行推迟超过一个区间。复用同一状态机可以避免两套计时逻辑在边缘行为上漂移。这是一种实现选择,不是节流的唯一合法定义;最终仍以题目契约和测试为准。
第五步:处理生命周期和副作用
延迟任务可能比发起它的 UI 活得更久。组件清理时需要调用 cancel(),防止旧回调在卸载后更新状态、读取旧 props 或在跳转后发请求。如果产品要求离开前提交尚未保存的文本,应明确调用 flush() 后再清理;不能让每次卸载都暗中提交数据。
对异步搜索做防抖只限制请求创建,不能保证响应顺序。请求一旦发出,较慢的旧响应仍可能覆盖新结果。还要配合 AbortController、请求代次或“仅接受最新响应”的检查。限频与防止陈旧响应解决的是两个不同故障。
第六步:按真实任务选择浏览器原语
只有稳定后的值有意义时用防抖,例如输入暂停后校验。中间进度也有意义时用节流,例如周期性采样指针或滚动状态。视觉写入需要对齐绘制时用 requestAnimationFrame(),但不能声称它自动降低滚动事件频率。需求是阈值可见性时用 IntersectionObserver,明确需要滚动结束事件时考虑 scrollend。
选择规则来自行为:丢弃中间状态、保留周期进度、对齐绘制,或观察浏览器定义的阈值。凭习惯选择会造成无效工作,也可能隐藏用户可见更新。
第七步:用虚拟时间验证
替换 Date.now、setTimeout 和 clearTimeout,或启用测试框架的假计时器。记录参数与虚拟时间,推进到截止点前一刻和截止点本身。不要断言真实的 100 毫秒计时器一定在第 100 毫秒精确执行。
最小测试矩阵包括:纯 trailing burst、纯 leading burst、单次与多次调用下的 leading 加 trailing、连续输入下的 maxWait、恰好位于 wait 边界的调用、最新参数与接收者、返回值复用、截止前 cancel、截止前 flush、重复 flush、零等待,以及回调内部再次调用包装函数。UI 集成还要覆盖组件清理和乱序网络响应。
高质量示范回答
“我会先定义时间线再编码。100 毫秒的 trailing 防抖收到 0、40、90 毫秒三次调用,会在 190 毫秒之后第一个可执行时刻用最后参数执行一次。leading 加 trailing 会在 0 和 190 毫秒执行,但一次孤立的 leading 调用不会在 trailing 再重复。
我保留一个计时器、待处理参数与接收者、最后调用时间、最后真正执行时间和最近结果。计时器到期不能直接假设自己仍有资格执行,因为新调用可能移动安静期截止点。maxWait 增加第二条截止线,让连续输入无法让回调一直饥饿;节流复用同一状态机,把 maxWait 设为 wait。
实现会保留 this,返回最近结果,cancel() 清空待处理状态,flush() 最多补一次 trailing。组件清理时取消。搜索场景还要单独取消请求或校验请求代次,因为防抖无法阻止旧响应晚到。我用假时钟和精确调用序列验证这些语义,避免依赖会延迟的浏览器计时器。”
常见错误
- 没定义边缘行为就写代码 → 不同合理实现会通过不同测试 → 先写默认值和带时间戳的调用序列。
- 保留第一次参数 → 延迟回调处理旧输入 → 每次调用都替换待处理参数与接收者。
- leading 后总是再 trailing → 一次点击触发两次动作 → 只有区间内出现新的待处理调用才补 trailing。
- 连续重置却没有
maxWait→ 连续事件会让自动保存或批处理永远不运行 → 需要周期进度时加入最大截止线。 - 把计时器延迟当作精确时间 → 长任务和运行时限速会破坏断言 → 把它视为最早执行资格,并用虚拟时间测试。
- 把
requestAnimationFrame()当通用节流 → 它可能与滚动事件以相同频率运行 → 把它用于绘制对齐,需要降频时另行测量时间间隔。 - 忘记生命周期清理 → 页面跳转或卸载后仍执行延迟工作 → 在清理阶段 cancel,只有产品契约要求时才显式 flush。
- 认为防抖能消除陈旧搜索结果 → 已发请求仍可能乱序完成 → 配合请求取消或最新请求校验。
- 基础题也直接写完整状态机 → 不必要代码扩大错误面 → 先实现最小契约,再说明扩展。
追问及应对
追问一:调用恰好发生在 wait 边界会怎样?
需要先规定任务顺序。在确定性测试中,如果旧计时器任务在同一虚拟时间先执行,旧 burst 可以完成 trailing,新调用再开启下一轮;如果新调用先处理,它会在计时器重新判断前更新待处理状态。浏览器任务队列不会让同一时刻的外部事件自动具备原子性。测试必须安排明确顺序,实现则要在该顺序下保持一致。
追问二:为什么不用 setInterval 实现节流?
如果没有额外状态,interval 在没有待处理工作时也会持续唤醒。leading、最后一次 trailing、取消和空闲后重启也更难推理。由真实调用触发一次性计时器,可以明确控制下一条边界。setInterval 在契约清楚时也能实现,但加入全部语义后并不会更简单。
追问三:trailing 关闭时,flush 是否应该强制执行?
没有符合条件的 trailing 工作,所以 flush() 只返回最近一次真正执行的结果,不调用 fn。它与正常计时器到期使用同一条 trailing 判断。如果产品需要“无视选项强制执行”,那是另一套 API,应使用不同名称和测试。
追问四:怎样测试系统时钟回拨?
注入时钟,把当前值推进到小于 lastCallTime。sinceCall < 0 分支会把该状态视为可执行,避免安排巨大或负数剩余时间。可以使用单调时钟时优先使用;防御分支则防止依赖运行时时钟的工具永久卡住。
追问五:生产项目何时应该直接使用 Lodash?
当其公开契约、打包方式和项目依赖策略匹配时,优先使用经过维护的实现。自研工具要自行承担兼容测试、评审和长期维护。面试中实现它用于证明推理能力,不代表生产环境重复实现成熟依赖就是最佳选择。