1. 核心概念 (Core Concept)
debounce (防抖) 和 throttle (节流) 是前端性能优化中常用的两种技术,用于限制函数在特定时间段内的执行频率。组合封装是指创建一个函数,该函数能够根据配置参数,灵活地实现防抖或节流的行为。
2. 为什么需要它? (The "Why")
- 性能优化: 显著减少高频触发事件(如窗口 resize, 滚动 scroll, 输入框 input, 鼠标移动 mousemove 等)的回调函数执行次数,降低 CPU 负载。
- 提升用户体验: 避免因频繁执行复杂计算或 DOM 操作导致的界面卡顿或无响应。
- 资源控制: 合理控制网络请求的发送频率,避免不必要的后端压力。
3. API 与用法 (API & Usage)
组合封装通常会设计一个高阶函数,该函数接收原始函数、等待时间以及配置选项作为参数,并返回一个经过处理的函数。配置选项通常用于指定是使用防抖还是节流模式。
由于标准 JavaScript 或 W3C/ECMA 规范中没有内置 debounce 和 throttle 函数,它们的实现是基于 JavaScript 的定时器 (setTimeout, clearTimeout) 构建的。这里提供一个经典的组合封装实现示例,它结合了防抖和节流的核心逻辑。
javascript
/**
* 手写通用函数,实现 debounce 或 throttle 功能
* @param {Function} func 需要被防抖或节流的函数
* @param {number} wait 等待时间 (ms)
* @param {object} options 配置选项
* @param {boolean} [options.isThrottle=false] 是否使用节流模式 (默认为防抖)
* @param {boolean} [options.leading=false] 在 debounce/throttle 开始时是否执行一次
* @param {boolean} [options.trailing=true] 在 debounce/throttle 结束后是否执行一次 (对于 throttle,表示是否在等待时间结束后执行最后一次调用)
* @returns {Function} 返回经过处理的新函数
*/
function debounceOrThrottle(func, wait, options = {}) {
let timeoutId;
let lastArgs;
let lastThis;
let lastCallTime = 0; // 用于节流
let result;
const { isThrottle = false, leading = false, trailing = true } = options;
const invokeFunc = function(time) {
const args = lastArgs;
const thisArg = lastThis;
lastArgs = lastThis = undefined; // 清空引用,便于垃圾回收
lastCallTime = time; // 记录执行时间,用于节流
result = func.apply(thisArg, args);
return result;
};
const leadingEdge = function(time) {
lastCallTime = time; // 记录首次触发时间
timeoutId = setTimeout(timerExpired, wait);
if (leading) {
// 节流模式下的 leading 执行,或防抖模式下的 leading 执行
return invokeFunc(time);
}
// 防抖模式下 leading=false 时,不立即执行,仅设置定时器
return result; // 返回上次结果
};
const remainingWait = function(time) {
// 计算距离下次允许执行的时间 (仅用于节流)
const timeSinceLastCall = time - lastCallTime;
const timeWaiting = wait - timeSinceLastCall;
// 如果 timeWaiting <= 0 或 timeWaiting > wait,说明可以立即执行或已经等待超出了一个周期
return Math.max(0, timeWaiting);
};
const timerExpired = function() {
const time = Date.now();
if (isThrottle) {
// 节流定时器触发
// 如果距离上次调用已经达到或超过 wait 间隔,且存在待处理的调用,则执行
if (trailing && lastArgs) {
return trailingEdge(time);
}
timeoutId = undefined; // 没有待处理调用,清除定时器
// 如果 leading 为 true 且没有 trailing,但定时器触发了(这种情况通常不会发生,因为 leading 模式下应该立即执行),则也清除定时器
if (!leading && !trailing) {
timeoutId = undefined;
}
} else { // Debounce 定时器触发
// 如果是 trailing 模式且存在待处理的调用,则执行
if (trailing && lastArgs) {
result = invokeFunc(time);
}
timeoutId = undefined; // 清除定时器
lastArgs = lastThis = undefined; // 确保清空
// 如果是 leading 模式且没有 trailing,但定时器触发了,则也清除定时器
if (!leading && !trailing) {
timeoutId = undefined;
}
}
// 清除定时器后,如果还有其他定时器(节流 trailing 模式可能在 trailingEdge 中再次设置),此处不做额外处理
};
const trailingEdge = function(time) {
timeoutId = undefined; // 清除当前定时器
const args = lastArgs;
const thisArg = lastThis;
lastArgs = lastThis = undefined; // 清空引用
// 执行 trailing 调用
const result = invokeFunc(time);
// 节流模式下的 trailing 可能需要重新设置定时器以处理后续的触发
if (isThrottle && lastArgs) { // 如果在执行 trailing 期间又有新的触发
timeoutId = setTimeout(timerExpired, wait);
}
return result;
};
const debouncedOrThrottled = function(...args) {
const time = Date.now();
lastArgs = args;
lastThis = this;
// === 核心逻辑区分 debounce 和 throttle ===
if (isThrottle) {
// --- Throttle 逻辑 ---
const canCall = time - lastCallTime >= wait;
if (canCall) {
// 如果可以调用 (距离上次执行已经超过 wait)
if (timeoutId) { // 清除可能存在的 trailing 定时器
clearTimeout(timeoutId);
timeoutId = undefined;
}
return invokeFunc(time); // 执行函数
} else if (trailing && !timeoutId) {
// 如果不能立即调用,但需要 trailing,并且还没有设置 trailing 定时器
timeoutId = setTimeout(timerExpired, remainingWait(time)); // 设置等待剩余时间的定时器
}
// 如果不能立即调用且不需要 trailing,或者 trailing 定时器已存在,则不执行
} else {
// --- Debounce 逻辑 ---
const isLeading = !timeoutId; // 是否是第一次触发 (在 wait 时间内)
clearTimeout(timeoutId); // 清除之前的定时器
timeoutId = setTimeout(timerExpired, wait); // 设置新的定时器
if (isLeading && leading) {
// 如果是 leading 模式且是第一次触发,则立即执行
return invokeFunc(time);
}
// 如果是 trailing 模式,或 leading=false,则等待定时器触发再执行
}
return result; // 返回上次的结果 (对于 debounce 的 trailing 或 throttle 的中间调用)
};
// 添加 cancel 方法,用于取消等待中的函数执行
debouncedOrThrottled.cancel = () => {
clearTimeout(timeoutId);
timeoutId = undefined;
lastArgs = lastThis = undefined;
lastCallTime = 0; // 重置节流状态
};
return debouncedOrThrottled;
}
// --- 使用示例 ---
// 例子 1: 防抖 (默认 trailing=true)
const debouncedSearch = debounceOrThrottle((query) => {
console.log('Searching for:', query);
}, 500);
document.getElementById('searchInput').addEventListener('input', (e) => {
debouncedSearch(e.target.value);
});
// 例子 2: 节流 (默认 trailing=true)
const throttledScroll = debounceOrThrottle(() => {
console.log('Scrolling!');
}, 300, { isThrottle: true });
window.addEventListener('scroll', throttledScroll);
// 例子 3: 防抖 leading=true
const debouncedClickLeading = debounceOrThrottle(() => {
console.log('Button clicked (debounce leading)');
}, 1000, { leading: true, trailing: false }); // leading true 常用场景是不希望最后一击无效
document.getElementById('button').addEventListener('click', debouncedClickLeading);
// 例子 4: 节流 leading=true, trailing=false
const throttledMoveLeading = debounceOrThrottle((x, y) => {
console.log('Mouse moved (throttle leading):', x, y);
}, 100, { isThrottle: true, leading: true, trailing: false }); // 常用场景是进入区域即执行一次,后续按频率执行但不执行最后一次
document.getElementById('area').addEventListener('mousemove', (e) => {
throttledMoveLeading(e.clientX, e.clientY);
});解释:
- 该组合函数通过
isThrottle参数来决定内部逻辑是执行防抖还是节流。 wait参数指定了等待时间。options对象提供了更精细的控制,如leading(是否在开始时执行) 和trailing(是否在结束时执行)。- 内部通过
setTimeout和clearTimeout管理定时器。 lastArgs,lastThis用于保存最后一次调用的参数和上下文 (this),确保函数执行时能拿到正确的参数和上下文。lastCallTime用于节流模式下记录上次函数执行的时间,以便计算是否达到wait间隔。cancel方法用于强制取消当前等待中的执行。
4. 关键注意事项 (Key Considerations)
this和参数的传递: 封装函数必须正确处理原始函数的this上下文和参数,确保在执行时能正确传递。通常使用func.apply(lastThis, lastArgs)来实现。leading和trailing选项: 理解这两个选项对防抖和节流行为的影响。- 防抖:
leading: true, trailing: true: 触发后立即执行一次,等待wait时间内再次触发则取消前一个定时器并重新设置,wait时间结束后(如果期间没有再次触发)再执行一次(这是 Lodash 的默认行为)。leading: true, trailing: false: 触发后立即执行一次,wait时间内再次触发会忽略,直到wait时间结束后才能再次触发执行(不管期间触发多少次)。leading: false, trailing: true(常见默认): 触发后不立即执行,等待wait时间,若期间再次触发则取消前一个并重新计时,直到wait时间结束后执行最后一次触发。leading: false, trailing: false: 触发后不立即执行,等待wait时间,期间再次触发会取消前一个并重新计时,wait时间结束后没有任何执行(除非再次触发)。
- 节流:
leading: true, trailing: true: 触发后立即执行一次,后续触发在wait时间内会被忽略,等到wait时间结束时如果期间有触发则再执行一次(通常是最后一次触发),然后重新开始计时。leading: true, trailing: false: 触发后立即执行一次,后续触发在wait时间内会被忽略,直到wait时间结束后才能再次触发执行。leading: false, trailing: true: 触发后不会立即执行,等待wait时间结束后执行第一次触发,后续触发在wait时间内会被忽略,等到下一个wait时间结束时执行期间的最后一次触发。leading: false, trailing: false: 触发后不会立即执行,等待wait时间结束后执行第一次触发,后续触发在wait时间内会被忽略,直到下一个wait时间结束时执行期间的最后一次触发(但不会单独执行最后一次触发)。
- 防抖:
- 清除定时器: 在封装函数返回的函数上暴露
cancel方法是一个好的实践,允许在组件卸载或其他需要中断的场景下清除定时器,避免内存泄漏或不必要的执行。 - 返回值和异常处理: 考虑封装函数本身的返回值以及被封装函数可能抛出的异常。示例中返回的是
invokeFunc的结果,实际开发中可能需要更复杂的处理。
5. 参考资料 (References)
- Lodash - debounce: https://lodash.com/docs#debounce ( Lodash 提供了非常成熟和广泛使用的 debounce 实现,是重要的参考依据)
- Lodash - throttle: https://lodash.com/docs#throttle ( Lodash 的 throttle 实现同样是业界标准)
- MDN Web Docs - setTimeout: https://developer.mozilla.org/zh-CN/docs/Web/API/setTimeout
- MDN Web Docs - clearTimeout: https://developer.mozilla.org/zh-CN/docs/Web/API/clearTimeout
- A Guide to Debouncing and Throttling in JavaScript: https://css-tricks.com/the-difference-between-throttling-and-debouncing/ (一篇经典的解释文章)
