每天有数十亿次 input、scroll、resize 事件在浏览器中触发。一个搜索框的 input 事件每秒可以触发 30-60 次,一次页面滚动可以触发每秒 100+ 次事件。不做防抖和节流处理,你的 API 调用量会暴增 10-50 倍,页面帧率会从 60fps 掉到 15fps。这两个概念每个前端开发者都知道,但真正理解它们的边界行为、能手写一个生产级实现的人少之又少。本文将从底层原理出发,带你从零实现 Debounce 和 Throttle,覆盖所有边界情况,并与 lodash 等成熟库做深度对比。
🎯 一、Debounce 防抖:延迟执行的工程艺术
1.1 核心原理与基础实现
防抖的核心思想:在事件触发后等待 N 毫秒,如果在这 N 毫秒内又被触发了,就重新计时。只有当事件停止触发超过 N 毫秒后,回调才会真正执行。就像电梯关门——每次有人进来,关门计时器就重置。
// 基础 debounce 实现:trailing 模式
function debounce(fn, delay) {
let timerId = null;
return function (...args) {
if (timerId !== null) clearTimeout(timerId);
timerId = setTimeout(() => {
fn.apply(this, args);
timerId = null;
}, delay);
};
}
这个基础版有几个问题:不支持 leading(前缘触发)模式,没有 cancel() 和 flush() 方法,也不支持返回值。
1.2 生产级 Debounce:Leading + Trailing
lodash 的 _.debounce 支持 leading 和 trailing 两个选项。leading: true 表示事件第一次触发时立即执行,trailing: true(默认)表示在防抖等待期结束后执行。
| leading | trailing | 行为 | 适用场景 |
|---|---|---|---|
false |
true |
只在停止操作后执行 | 搜索框、窗口 resize |
true |
false |
只在第一次触发时执行 | 按钮防重复点击 |
true |
true |
首尾各执行一次 | 滚动加载 |
// 生产级 debounce:支持 leading/trailing/cancel/flush
function debounce(fn, delay, options = {}) {
const { leading = false, trailing = true } = options;
let timerId = null;
let lastArgs = null;
let lastThis = null;
let result = null;
function invokeFunc() {
const args = lastArgs, thisArg = lastThis;
lastArgs = null; lastThis = null;
result = fn.apply(thisArg, args);
return result;
}
function trailingEdge() {
timerId = null;
if (trailing && lastArgs) return invokeFunc();
lastArgs = null; lastThis = null;
return result;
}
function debounced(...args) {
lastArgs = args; lastThis = this;
if (leading && timerId === null) invokeFunc();
if (timerId !== null) clearTimeout(timerId);
timerId = setTimeout(trailingEdge, delay);
return result;
}
debounced.cancel = () => {
if (timerId !== null) { clearTimeout(timerId); timerId = null; }
lastArgs = null; lastThis = null;
};
debounced.flush = () => {
if (timerId !== null) return trailingEdge();
return result;
};
debounced.pending = () => timerId !== null;
return debounced;
}
⚠️ 警告:
leading: true, trailing: true时,如果事件在防抖期间持续触发,回调会执行两次——一次在开头(leading),一次在结尾(trailing)。在搜索场景中可能导致重复请求。
⏱️ 二、Throttle 节流:固定频率的精确控制
2.1 两种经典实现
节流与防抖的关键区别:节流保证回调以固定的最大频率执行,防抖只在操作结束后执行一次。节流有时间戳和定时器两种实现方式:
// 时间戳实现:首次立即执行,停止后不执行最后一次
function throttleTimestamp(fn, interval) {
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
return fn.apply(this, args);
}
};
}
// 定时器实现:首次延迟执行,停止后执行最后一次
function throttleTimer(fn, interval) {
let timerId = null;
return function (...args) {
if (timerId === null) {
timerId = setTimeout(() => {
fn.apply(this, args);
timerId = null;
}, interval);
}
};
}
💡 提示:大多数生产场景需要同时支持 leading 和 trailing——事件开始时立即响应,结束时也处理最后一次。下面的完整实现正是为此设计。
2.2 生产级 Throttle 实现
// 生产级 throttle:支持 leading/trailing/cancel/flush
function throttle(fn, interval, options = {}) {
const { leading = true, trailing = true } = options;
let timerId = null;
let lastArgs = null;
let lastThis = null;
let lastInvokeTime = 0;
let result = null;
function invokeFunc(time) {
const args = lastArgs, thisArg = lastThis;
lastArgs = null; lastThis = null;
lastInvokeTime = time;
result = fn.apply(thisArg, args);
return result;
}
function trailingEdge() {
timerId = null;
if (trailing && lastArgs) return invokeFunc(Date.now());
lastArgs = null; lastThis = null;
return result;
}
function throttled(...args) {
const now = Date.now();
lastArgs = args; lastThis = this;
if (lastInvokeTime === 0 && !leading) lastInvokeTime = now;
const wait = Math.max(0, interval - (now - lastInvokeTime));
if (wait <= 0 || wait > interval) {
if (timerId !== null) { clearTimeout(timerId); timerId = null; }
lastInvokeTime = now;
invokeFunc(now);
} else if (trailing && timerId === null) {
timerId = setTimeout(trailingEdge, wait);
}
return result;
}
throttled.cancel = () => {
if (timerId !== null) { clearTimeout(timerId); timerId = null; }
lastArgs = null; lastThis = null; lastInvokeTime = 0;
};
throttled.flush = () => timerId !== null ? trailingEdge() : result;
throttled.pending = () => timerId !== null;
return throttled;
}
📊 三、性能对比与选型指南
3.1 执行时序对比
假设事件以 100ms 间隔持续触发,对比 debounce(300ms) 和 throttle(300ms) 的行为:
事件触发: ● ● ● ● ● ● ● ● ● ● ●
时间(ms): 0 100 200 300 400 500 600 700 800 900 1000
Debounce(300ms trailing):
回调执行: ✅ ✅
说明: 最后一次触发后 300ms 执行
Throttle(300ms, leading+trailing):
回调执行: ✅ ✅ ✅ ✅
说明: 第一次立即执行,之后每 300ms 执行一次
3.2 基准测试数据
| 实现方案 | 调用 10000 次 | 实际执行 | 执行率 | 包体积(gzip) |
|---|---|---|---|---|
| 无防护 | 10,000 | 10,000 | 100% | 0 |
| debounce(300ms) | 10,000 | ~3 | 0.03% | ~0.3KB |
| throttle(300ms) | 10,000 | ~33 | 0.33% | ~0.3KB |
| lodash.debounce | 10,000 | ~3 | 0.03% | ~1KB |
| requestAnimationFrame | ~600 | ~600 | 6% | 0KB(原生) |
⚠️ **警告:**lodash 的 debounce/throttle 包体积约 1KB(gzip),功能完善但如果你只需要基础功能,手写实现只需 0.3KB。在包体积敏感的项目中,建议自行实现或使用
just-debounce-it(~0.3KB)。
3.3 选型决策树
- ✅ 搜索框/自动保存 →
debounce(trailing),等用户停下来再处理 - ✅ 按钮防重复提交 →
debounce(leading),第一次点击立即响应 - ✅ 滚动/拖拽事件 →
throttle,保证固定频率执行 - ✅ 动画/视觉更新 →
requestAnimationFrame,与刷新率同步 - ✅ 不确定 →
debounce(trailing, 200-300ms),最安全的默认选择
💡 提示:对于滚动动画、拖拽反馈、Canvas 绘制等视觉更新场景,
requestAnimationFrame比 throttle 更优——它自动与浏览器 16.6ms 刷新周期同步,无需手动指定时间间隔。
🔧 四、TypeScript 类型安全实现
在 TypeScript 项目中,debounce 需要保留原函数的参数类型和返回值类型:
// TypeScript 类型安全的 debounce
type Debounced<T extends (...args: any[]) => any> = {
(...args: Parameters<T>): ReturnType<T> | undefined;
cancel: () => void;
flush: () => ReturnType<T> | undefined;
pending: () => boolean;
};
function debounce<T extends (...args: any[]) => any>(
fn: T, delay: number,
options: { leading?: boolean; trailing?: boolean } = {}
): Debounced<T> {
const { leading = false, trailing = true } = options;
let timerId: ReturnType<typeof setTimeout> | null = null;
let lastArgs: Parameters<T> | null = null;
let lastThis: any = null;
let result: ReturnType<T> | undefined;
const invokeFunc = () => {
result = fn.apply(lastThis, lastArgs!);
lastArgs = null; lastThis = null;
return result;
};
const trailingEdge = () => {
timerId = null;
if (trailing && lastArgs) return invokeFunc();
lastArgs = null; lastThis = null;
return result;
};
const debounced = function (this: any, ...args: Parameters<T>) {
lastArgs = args; lastThis = this;
if (leading && timerId === null) invokeFunc();
if (timerId !== null) clearTimeout(timerId);
timerId = setTimeout(trailingEdge, delay);
return result;
} as Debounced<T>;
debounced.cancel = () => {
if (timerId !== null) { clearTimeout(timerId); timerId = null; }
lastArgs = null; lastThis = null;
};
debounced.flush = () => timerId !== null ? trailingEdge() : result;
debounced.pending = () => timerId !== null;
return debounced;
}
⚠️ 五、常见陷阱与避坑指南
5.1 this 绑定与 React 清理
// ❌ 错误写法:this 丢失 + 组件卸载后仍执行
class SearchComponent {
constructor() {
this.el.addEventListener('input', debounce(this.onSearch, 300));
}
onSearch(e) { console.log(this.service); } // this === undefined
}
// ✅ 正确写法:显式绑定 + cleanup
function SearchBox() {
const [results, setResults] = useState([]);
useEffect(() => {
const search = debounce(async (query) => {
const data = await fetch(`/api/search?q=${query}`);
setResults(await data.json());
}, 300);
const input = document.getElementById('search');
input.addEventListener('input', search);
return () => { search.cancel(); input.removeEventListener('input', search); };
}, []);
return <div>{results.map(r => <p key={r.id}>{r.title}</p>)}</div>;
}
📌 **记住:**在 React 中使用 debounce/throttle,必须在 useEffect 的 cleanup 函数中调用
.cancel()。否则组件卸载后 trailing 回调仍会执行,导致 setState on unmounted component 警告和内存泄漏。
5.2 AbortController 双重取消
当 debounce 回调涉及 fetch 请求时,取消旧请求比取消回调更重要:
// debounce + AbortController 双重取消
function createDebouncedSearch() {
let controller = null;
const search = debounce(async (query) => {
if (controller) controller.abort();
controller = new AbortController();
try {
const resp = await fetch(`/api/search?q=${query}`, { signal: controller.signal });
console.log(await resp.json());
} catch (err) {
if (err.name !== 'AbortError') console.error(err);
}
}, 300);
search.cleanup = () => { search.cancel(); controller?.abort(); };
return search;
}
5.3 不该用 debounce/throttle 的场景
| 场景 | ❌ 错误方案 | ✅ 正确方案 |
|---|---|---|
| 表单提交防重复 | debounce | button.disabled + loading 状态 |
| 键盘快捷键 | throttle | keydown + e.repeat 过滤 |
| 滚动动画 | throttle | requestAnimationFrame |
| CSS transition 回调 | debounce | transitionend 事件 |
✅ 总结与推荐
- ✅ 搜索/过滤:
debounce(fn, 200-300ms)+ trailing - ✅ 按钮防抖:
debounce(fn, 1000-2000ms)+ leading - ✅ 滚动/拖拽:
throttle(fn, 16-100ms)或requestAnimationFrame - ✅ API 请求:debounce +
AbortController双重取消 - ✅ React 中使用:必须在 cleanup 中调用
.cancel()
推荐库:lodash-es/debounce(功能完善,~1KB)、just-debounce-it(轻量,~0.3KB)、或自行实现(完全可控,~0.3KB)。
⚡ **关键结论:**Debounce 和 Throttle 不是「知道概念就行」的简单工具函数——它们的
this绑定、leading/trailing模式、cancel/flush方法、以及与 React hooks 的集成,每一个细节都可能在生产环境中引发 bug。手写一个生产级实现不仅能帮你深入理解原理,更能在面试中展示你对 JavaScript 异步编程的扎实功底。