手写防抖(Debounce)与节流(Throttle):实现支持立即执行(Leading)的版本
大家好,欢迎来到今天的编程技术讲座。我是你们的讲师,今天我们要深入探讨两个在前端开发中极其重要的性能优化技巧:防抖(Debounce) 和 节流(Throttle)。它们常用于处理高频触发事件(如输入框搜索、窗口滚动、按钮点击等),避免不必要的重复计算或网络请求。
但今天我们不只是讲基础用法,而是要手写一个支持立即执行(leading) 的完整版本——这正是很多开发者在实际项目中容易忽略的关键点。
一、什么是防抖和节流?
✅ 防抖(Debounce)
- 定义:在一段时间内,如果某个函数被多次调用,只会在最后一次调用之后延迟执行一次。
- 适用场景:用户输入搜索关键词时,防止每打一个字就发一次请求;或者表单验证频繁触发。
✅ 节流(Throttle)
- 定义:规定一个时间段内最多执行一次函数,不管在这段时间里调用了多少次。
- 适用场景:页面滚动监听、鼠标移动事件、resize事件处理。
📝 小贴士:两者本质都是通过控制函数执行频率来提升性能,但策略不同:
特性 防抖(Debounce) 节流(Throttle) 执行时机 最后一次调用后延迟执行 每隔固定时间执行一次 是否保证执行 ❌ 不一定(可能被取消) ✅ 保证周期性执行 常见用途 输入搜索、自动保存 滚动监听、拖拽
二、经典实现 vs 支持 leading 的增强版
我们先看最常见的两种实现方式:
1. 基础防抖(无 leading)
function debounce(fn, delay) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delay);
};
}
这个版本的问题是:永远不会立即执行,即使第一次调用也得等 delay 时间后才执行。这对于某些业务逻辑来说不够灵活。
2. 基础节流(无 leading)
function throttle(fn, delay) {
let lastCallTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastCallTime >= delay) {
fn.apply(this, args);
lastCallTime = now;
}
};
}
同样,它也不支持“首次立即执行”的需求。
三、为什么要引入 Leading(立即执行)?
举个例子:
✅ 用户在搜索框输入时,希望第一次输入就立刻发起请求(比如显示热门推荐),而不是等到用户停止输入再查。
✅ 在滚动事件中,我们想让页面刚加载完就立刻执行一次 scroll 处理逻辑(比如高亮当前 section),而不是等待用户滚动后再执行。
这就是为什么我们需要一个支持 leading 参数的版本!
四、手写支持 leading 的 Debounce 实现
我们来一步步构建一个完整的、可配置的防抖函数:
✅ 核心思路:
- 如果设置了
leading: true,则在第一次调用时立即执行; - 后续调用会重置定时器;
- 若定时器到期前再次调用,则不会再次执行(除非超时);
- 提供
trailing控制是否允许最后执行(默认为 true); - 使用闭包维护状态:
timeoutId,lastCallTime等。
✅ 完整代码如下:
function debounce(fn, delay, options = {}) {
const { leading = false, trailing = true } = options;
let timeoutId;
let lastCallTime = 0;
const later = () => {
const now = Date.now();
// 如果 trailing 为 false,则不执行最后一次调用
if (trailing && now - lastCallTime >= delay) {
fn.apply(this, arguments);
}
timeoutId = null;
};
const debounced = function (...args) {
const now = Date.now();
// 第一次调用时,若 leading 为 true,则立即执行
if (leading && !timeoutId) {
fn.apply(this, args);
lastCallTime = now;
}
// 清除之前的定时器
clearTimeout(timeoutId);
// 设置新的定时器
timeoutId = setTimeout(later, delay);
// 如果不是 leading 或者已经执行过一次,则记录时间戳用于 trailing 判断
if (!leading || timeoutId) {
lastCallTime = now;
}
};
// 取消功能(可选)
debounced.cancel = () => {
clearTimeout(timeoutId);
timeoutId = null;
};
return debounced;
}
🔍 关键逻辑说明:
| 条件 | 行为 |
|---|---|
leading === true 且首次调用 |
立即执行函数,并记录时间 |
| 后续调用 | 清除旧定时器,设置新定时器 |
| 定时器到期 | 执行函数(仅当 trailing 为 true) |
| cancel() | 主动清除定时器,不再执行 |
✅ 测试用例(建议复制到浏览器 console 中测试):
const log = console.log.bind(console);
const debouncedFn = debounce(
(msg) => log(`执行了:${msg}`),
1000,
{ leading: true, trailing: true }
);
// 测试1:连续调用三次,应该只执行两次(第一次立即 + 最后一次延迟)
debouncedFn("A");
setTimeout(() => debouncedFn("B"), 500); // 仍在延迟期内
setTimeout(() => debouncedFn("C"), 1200); // 超过延迟期,触发最后一次
// 输出:
// 执行了:A ← leading 触发
// 执行了:C ← trailing 触发
💡 注意:这里 C 是在 1200ms 后触发的,因为 B 和 C 之间间隔小于 1000ms,所以 B 被丢弃,最终只保留 C。
五、手写支持 leading 的 Throttle 实现
节流比防抖更简单一些,因为我们只需要控制执行频率即可。
✅ 支持 leading 的 throttle 实现:
function throttle(fn, delay, options = {}) {
const { leading = true, trailing = true } = options;
let lastCallTime = 0;
let timeoutId = null;
const throttled = function (...args) {
const now = Date.now();
// 如果没有上次调用时间,或者已超过 delay,直接执行
if (leading && !lastCallTime) {
fn.apply(this, args);
lastCallTime = now;
return;
}
// 如果距离上次调用已经超过 delay,直接执行
if (now - lastCallTime >= delay) {
fn.apply(this, args);
lastCallTime = now;
return;
}
// 如果设置了 trailing,且还没设置定时器,则注册一个尾部执行
if (trailing && !timeoutId) {
timeoutId = setTimeout(() => {
fn.apply(this, args);
lastCallTime = Date.now();
timeoutId = null;
}, delay - (now - lastCallTime));
}
};
// 取消功能(可选)
throttled.cancel = () => {
clearTimeout(timeoutId);
timeoutId = null;
};
return throttled;
}
✅ 测试示例:
const throttledFn = throttle(
(msg) => log(`执行了:${msg}`),
1000,
{ leading: true, trailing: true }
);
throttledFn("A"); // 立即执行
setTimeout(() => throttledFn("B"), 300); // 不执行
setTimeout(() => throttledFn("C"), 1200); // 执行(因为距离 A 已经超过 1s)
输出:
执行了:A
执行了:C
⚠️ 注意:这里的 trailing 机制是在中间调用未达到 delay 时,通过定时器模拟“延迟执行”,这是节流的核心技巧之一。
六、对比总结:Debounce vs Throttle with Leading
| 功能 | Debounce + Leading | Throttle + Leading |
|---|---|---|
| 第一次调用 | ✅ 立即执行 | ✅ 立即执行 |
| 中间调用 | ❌ 不执行(除非超时) | ❌ 不执行(除非超时) |
| 最后一次调用 | ✅ 延迟执行(如果 trailing=true) | ✅ 延迟执行(如果 trailing=true) |
| 执行频率 | 最多一次/延迟结束 | 每 delay 时间最多一次 |
| 是否保证执行 | ❌ 不一定(可能被取消) | ✅ 保证周期性执行 |
| 使用场景 | 输入搜索、自动保存 | 滚动监听、鼠标移动 |
七、常见陷阱 & 最佳实践
❗ 陷阱1:忘记清理定时器导致内存泄漏
- 解决方案:提供
.cancel()方法,手动释放资源。
❗ 陷阱2:this 指向错误(尤其是在类方法中)
- 解决方案:使用
fn.apply(this, args)确保上下文正确。
❗ 陷阱3:误以为 debounce 总能减少请求次数
- 实际上:如果用户快速输入并保持高速率,可能导致所有请求都被丢弃(除非你加了一个 fallback)。
✅ 最佳实践建议:
- 优先选择合适的模式:
- 搜索建议 →
debounce({ leading: true }) - 滚动监听 →
throttle({ leading: true, trailing: true })
- 搜索建议 →
- 始终提供 cancel 接口,方便组件卸载时清理。
- 不要滥用:过度封装反而增加复杂度,考虑是否真的需要这些优化。
- 结合防抖+节流:有些场景可以组合使用(如先防抖再节流),但需谨慎设计。
八、进阶:如何将这两个工具封装成通用模块?
我们可以创建一个统一的工具库文件,比如 utils/debounce-throttle.js:
// utils/debounce-throttle.js
export function debounce(fn, delay, options = {}) {
// 上面实现...
}
export function throttle(fn, delay, options = {}) {
// 上面实现...
}
// 导出常用组合
export const debounceSearch = (fn, delay) =>
debounce(fn, delay, { leading: true });
export const throttleScroll = (fn, delay) =>
throttle(fn, delay, { leading: true, trailing: true });
这样你在项目中就可以这样用:
import { debounceSearch } from './utils/debounce-throttle';
const searchHandler = debounceSearch(async (query) => {
const res = await fetch(`/api/search?q=${query}`);
renderResults(res.data);
}, 300);
九、结语
今天我们不仅手写了支持 leading 的 debounce 和 throttle,还深入理解了它们的区别、适用场景以及潜在陷阱。这些技术虽然看似简单,但在大型项目中却至关重要。
记住一句话:
“好的性能优化不是堆砌代码,而是精准控制执行节奏。”
希望今天的分享对你有帮助!如果你正在做前端性能优化,不妨试试把这些工具融入你的项目中,你会发现用户体验显著提升。
下节课我们将讲解如何用 React Hooks 实现类似的防抖节流逻辑 —— 敬请期待!
🔚