防抖与节流《回顾基础篇》
防抖和节流一直是面试过程中的的高频题目,一般会安排在手写题。2025年了,前端逐渐被AI蚕食,还有一部分人坚信手写的更香,所以就来回顾一下。
其实自工作以来,遇到需要防抖和节流的场景并不多,一方面是工作中全是ToB项目,没有ToC项目中那么多不确定性,其次这种问题一般需要甲方要求,所以使用到的频率并不多。另一方面是我们还可以使用lodash这种库,直接完成,没有手写的必要。但今天我们来回顾一下,如何写一个高质量的,库级的防抖节流。
防抖
说到防抖先说防抖的场景。一般来说,当需要避免持续和后端交互的时候就要防抖。比如,搜索框实时搜索,每输入一个字就要调用一次后端接口,这样严重影响性能。可以使用防抖。频繁点击搜索按钮,也可以使用防抖。
也就是说:当事件持续触发时,不执行;只有当事件停止触发 n 毫秒后才执行一次。
但实际业务中可能会出现多种场景:
-
用户需要掌握整个防抖过程的生命周期:
用户可以在选择是第一次触发和最后一次触发。
-
允许用户取消定时、立即执行、获取状态。
-
允许函数拥有返回值。
如何设计一个好的防抖函数。首先我们使用最简代码进行实现。
const debounce = (fn, delay) => {
let timer = null;
return function (...arguments) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, arguments);
}, delay);
};
};
可以看到,防抖函数就是在普通函数外再添加一个setTimeout。如果timer存在就清空定时器,直到最后一次触发。现在已经能满足80%的需求了。但是要想达到库级别的防抖,需要内部做更多的校验来确保它的稳定性,比如第一个参数是不是一个函数等。
function debounce(func, wait = 0, options = {}) {
// 1. 把用户传进来的配置拆出来
const { leading = false, trailing = true, maxWait } = options;
// 2. 简单防御性编程:func 必须是函数
if (typeof func !== 'function') {
throw new TypeError('Expected a function');
}
// 3. 下面这些变量就是“状态仓库”,全部闭包在 debounce 内部
let timerId; // setTimeout 返回的 id
let lastCallTime; // 最近一次调用 debounced 的时间
let lastInvokeTime = 0; // 最近一次真正执行 func 的时间
let lastArgs; // 最近一次调用时传进来的参数
let result; // 缓存 func 的返回值
/* ─────────── 工具函数区 ─────────── */
// 真正执行 func 的函数
function invokeFunc(time) {
const args = lastArgs;
lastArgs = undefined;
lastInvokeTime = time;
result = func.apply(this, args);
return result;
}
// 第一次触发时的处理(只在 leading 模式下可能用到)
function leadingEdge(time) {
lastInvokeTime = time;
timerId = setTimeout(timerExpired, wait);
return leading ? invokeFunc(time) : result;
}
// 计算下一次 setTimeout 还要等多久的辅助函数
function remainingWait(time) {
const timeSinceLastCall = time - (lastCallTime || 0);
const timeSinceLastInvoke = time - lastInvokeTime;
const timeWaiting = wait - timeSinceLastCall;
return maxWait !== undefined
? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
: timeWaiting;
}
// 判断是否应该立即执行 func
function shouldInvoke(time) {
const timeSinceLastCall = time - (lastCallTime || 0);
const timeSinceLastInvoke = time - lastInvokeTime;
return (
lastCallTime === undefined || // 第一次调用
timeSinceLastCall >= wait || // 距离上次调用已超过 wait
timeSinceLastCall < 0 || // 系统时间倒退(极少见)
(maxWait !== undefined && timeSinceLastInvoke >= maxWait) // 超过最大等待
);
}
// setTimeout 到了之后要做的事
function timerExpired() {
const time = Date.now();
if (shouldInvoke(time)) {
return trailingEdge(time);
}
// 如果还不能执行,就重新算一个剩余时间继续等
timerId = setTimeout(timerExpired, remainingWait(time));
}
// 尾部触发:真正执行 func(trailing 模式)
function trailingEdge(time) {
timerId = undefined;
if (trailing && lastArgs) {
return invokeFunc(time);
}
lastArgs = undefined;
return result;
}
/* ─────────── 对外暴露的控制函数 ─────────── */
function cancel() {
if (timerId !== undefined) clearTimeout(timerId);
lastInvokeTime = 0;
lastArgs = lastCallTime = timerId = undefined;
}
function flush() {
return timerId === undefined ? result : trailingEdge(Date.now());
}
function pending() {
return timerId !== undefined;
}
/* ─────────── 真正的 debounced 函数 ─────────── */
function debounced(...args) {
const time = Date.now();
const isInvoking = shouldInvoke(time);
lastArgs = args;
lastCallTime = time;
// 第一次或 maxWait 到期,需要立即执行
if (isInvoking) {
if (timerId === undefined) return leadingEdge(lastCallTime);
if (maxWait !== undefined) {
timerId = setTimeout(timerExpired, wait);
return invokeFunc(lastCallTime);
}
}
// 普通情况:设个定时器等
if (timerId === undefined) {
timerId = setTimeout(timerExpired, wait);
}
return result;
}
// 把三个控制方法挂到 debounced 自己身上
debounced.cancel = cancel;
debounced.flush = flush;
debounced.pending = pending;
return debounced;
}
节流
节流与防抖类似,都是为了节省后端资源的方式,但处理思想并不相同。防抖是等动作停下才执行,而节流是根据自己的节奏执行,就像一个精密的火车。比如,设计一个自动保存,每隔一段时间保存一次,就可以用到。首先我们使用最简代码实现。
function throttle(fn, wait) {
let last = 0; // 上次执行时间
return function (...args) {
const now = Date.now();
if (now - last >= wait) { // 间隔够了就执行
last = now;
fn.apply(this, args);
}
};
}
首先我们定义了一个last变量,他就像一个水印,记录了第一次执行的时间,之后每次的时间就和第一次的做比较,只有现在的时间 - 初始的时间 大于设定等待的时间,才会继续执行。那么如何定制一个库级的截流函数呢?
// throttle.js
function throttle(func, wait, options) {
options = options || {};
const leading = options.leading !== false; // 默认 true
const trailing = options.trailing !== false; // 默认 true
let timerId; // 定时器 id
let lastInvokeTime = 0; // 上次真正执行时间
let lastArgs; // 最后一次参数
let result; // 缓存返回值
function invokeFunc() {
lastInvokeTime = Date.now();
result = func.apply(this, lastArgs);
lastArgs = undefined;
return result;
}
function remainingWait() {
return wait - (Date.now() - lastInvokeTime);
}
function timerExpired() {
timerId = undefined;
if (trailing && lastArgs != null) invokeFunc.call(this);
}
function throttled(...args) {
const now = Date.now();
const isInvoking = now - lastInvokeTime >= wait;
lastArgs = args;
// 第一次触发且 leading 为 true
if (lastInvokeTime === 0 && !leading) lastInvokeTime = now;
if (isInvoking) {
if (timerId) {
clearTimeout(timerId);
timerId = undefined;
}
invokeFunc.call(this);
} else if (!timerId && trailing) {
timerId = setTimeout(timerExpired.bind(this), remainingWait());
}
return result;
}
throttled.cancel = function () {
if (timerId) clearTimeout(timerId);
lastInvokeTime = 0;
timerId = lastArgs = undefined;
};
throttled.flush = function () {
return timerId ? invokeFunc.call(this) : result;
};
throttled.pending = function () {
return timerId !== undefined;
};
return throttled;
}
可能有初学者说,为什么执行之后变量没有被清空,而是读取上次调用的值。因为闭包的原因。虽然每次会返回一个新的函数,但是都是访问的同一个作用域的变量,这个变量并没有被清除,所以才能实现。要想了解的更清楚,下一篇
什么是闭包《回顾基础篇》