题目重点:Salesforce 前端/全栈电面要求用 setTimeout 实现 debounce;本文明确 trailing 训练语义、this/参数保留、cancel/flush 与确定性虚拟时钟验证。
题意与假设
这道练习题保留了面经中值得训练的核心约束,并将需要澄清的条件单独列出。现场拿到变体时,先和面试官确认这些条件。
- 训练版采用 trailing debounce:连续调用间隔小于 waitMs 时重置计时器,安静满 waitMs 后只执行最后一次的参数与 this;普通包装调用立即返回 undefined。
- waitMs 是非负有限毫秒数;cancel 清除等待中的调用,flush 立即执行等待中的最后一次并返回结果。cancel/flush 是文章追加的练习接口。
- 计时由 JS event loop 的 setTimeout 调度,不承诺在精确毫秒瞬间执行;若进程忙或页面后台限时,回调可能晚于计划时间。
解题路线
- 闭包存 timerId、pendingArgs 和 pendingThis。每次包装函数被调用时,用本次参数和调用者 this 覆盖旧待执行值;若旧计时器存在就 clearTimeout,再设新的 setTimeout。
- 计时器触发时先把内部待执行状态清空,再以 fn.apply(context,args) 调用原函数。先清状态可避免 fn 内部重入包装函数时,旧 timerId 意外覆盖新计时器。
- cancel 取消计时器并清除引用,flush 若没有 pending 返回 undefined;否则取消计时器后立刻调用同一个 invoke。所有路径用 timerId!==null 判断,不能把计时器 ID 0 当作不存在。
- 正确性不变量:有 pending timer 时只保存最近一次调用的参数与 this,任何更早计时器已被取消;静默 waitMs 后,只有当前计时器能触发。频繁连续调用可能无限延期,如需 maxWait 需要独立计时约束。
手动走一遍
waitMs=100:t=0 调用 A,计划 t=100;t=60 调用 B,取消旧计时器并计划 t=160;若之后安静,到 t≥160 只调用 B。若 t=90 执行 flush,立即用 B 的参数执行一次并清计时器;若 t=90 改为 cancel,则一次也不执行。
JavaScript 参考实现
下面的代码按上文假设实现,适合本地运行并追加自己的测试用例。
代码区可横向滚动;使用键盘时,先聚焦代码区再按方向键查看。
export function debounce(fn, waitMs, timers = { setTimeout, clearTimeout }) {
if (typeof fn !== "function" || !Number.isFinite(waitMs) || waitMs < 0) {
throw new TypeError("expected a function and nonnegative finite delay");
}
let timerId = null;
let pendingArgs = null;
let pendingThis = null;
function invoke() {
const args = pendingArgs;
const context = pendingThis;
timerId = null;
pendingArgs = pendingThis = null;
return fn.apply(context, args);
}
function debounced(...args) {
pendingArgs = args;
pendingThis = this;
if (timerId !== null) timers.clearTimeout(timerId);
timerId = timers.setTimeout(invoke, waitMs);
}
debounced.cancel = () => {
if (timerId !== null) timers.clearTimeout(timerId);
timerId = null;
pendingArgs = pendingThis = null;
};
debounced.flush = () => {
if (timerId === null) return undefined;
timers.clearTimeout(timerId);
return invoke();
};
return debounced;
}
复杂度
单次调用、cancel、flush 都只执行常数次计时器/赋值操作,算法层面时间 O(1);保存最后一次参数数组占 O(A),A 为该次实参数量。实际定时器实现成本由运行时决定。频繁调用导致长期不触发是 trailing debounce 的语义,而不是实现故障。
易错点与追问
容易失分的边界
- 只设新 timer 不清旧 timer,连续输入触发多次原函数。
- 保存第一次参数而非最新参数,导致用户输入停下后处理旧值。
- 用箭头函数包装却希望动态 this 自动传入;箭头函数的 this 为词法绑定。
- 用 `if (timerId)` 判断会把有效的 0 号计时器漏掉。
- 将 debounce 与 throttle 混淆:throttle 保证一段时间最多执行一次,trailing debounce 等待一段安静时间。
继续深挖
- 若要求 leading+trailing 同时支持,首调用立即执行后怎样避免只有一次调用却在尾部又执行一次?
- 若持续输入但必须每隔最多 maxWait 毫秒执行,如何增加独立最早等待时间并测试竞态?
- 组件卸载时调用 cancel,如何避免已失效 UI 的异步回调;若 fn 返回 Promise,flush 应返回什么?
