防抖(Debounce)与节流(Throttle)的源码级实现:支持立即执行与取消功能
大家好,今天我们来深入探讨两个在前端开发中极其重要但又常被误解的性能优化技术:防抖(Debounce)和节流(Throttle)。它们广泛应用于搜索框输入、窗口缩放、滚动事件监听等高频触发场景,目的是减少不必要的函数调用,提升用户体验和系统性能。
本讲座将从理论出发,逐步推导出它们的核心逻辑,并提供完整可运行的源码级实现,包括:
- 支持“立即执行”选项
- 支持“取消”操作(即手动中断定时器)
- 代码结构清晰、注释详尽、易于扩展
一、什么是防抖和节流?
1. 防抖(Debounce)
定义:在一段时间内连续触发事件时,只在最后一次触发后等待指定延迟时间再执行一次回调函数。
适用场景:
- 用户在搜索框中输入内容,希望每停顿1秒后再发起请求。
- 实时表单校验,避免频繁 API 调用。
核心思想:延时执行 + 清除旧任务
2. 节流(Throttle)
定义:规定一个时间段内最多只执行一次回调函数,无论期间触发多少次事件。
适用场景:
- 窗口 resize 或 scroll 事件处理,防止页面卡顿。
- 滚动加载更多数据,限制频率。
核心思想:固定间隔执行 + 控制节奏
二、为什么需要防抖和节流?
想象这样一个场景:
window.addEventListener('scroll', () => { console.log('滚动了'); });如果用户快速滚动页面,可能会触发成百上千次scroll事件。每次打印日志可能只是调试用途,但如果换成请求接口、重绘 DOM 或计算复杂逻辑,就会造成严重的性能问题 —— 浏览器卡顿甚至崩溃。
解决方案就是使用Debounce / Throttle来控制执行频率。
三、核心区别对比(表格总结)
| 特性 | 防抖(Debounce) | 节流(Throttle) |
|---|---|---|
| 触发方式 | 最后一次触发后延迟执行 | 固定周期内只执行一次 |
| 是否立即执行 | ||
| 执行时机 | 停止触发后才执行 | 每隔固定时间执行 |
| 适用场景 | 输入框搜索、实时验证 | 滚动/缩放监听、鼠标移动 |
| 是否可取消 |
注意:两者都能通过
clearTimeout实现取消功能!这是很多初学者忽略的关键点。
四、源码级实现详解(带注释)
我们分别实现两个高阶函数:debounce和throttle,并支持以下特性:
immediate: boolean—— 是否立即执行cancel(): void—— 取消当前待执行的任务- 返回值是一个函数对象,包含上述方法
1. 防抖(Debounce)实现
function debounce(fn, delay = 300, immediate = false) { let timeoutId = null; function debounced(...args) { // 如果已经存在定时器,则清除它(防抖核心逻辑) if (timeoutId) clearTimeout(timeoutId); // 如果设置了立即执行且是第一次调用 if (immediate && !timeoutId) { fn.apply(this, args); // 立即执行 } // 设置新的定时器,在 delay 后执行 fn timeoutId = setTimeout(() => { timeoutId = null; // 清空状态 if (!immediate) { fn.apply(this, args); } }, delay); } // 添加 cancel 方法用于取消当前待执行的任务 debounced.cancel = function() { if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } }; return debounced; }关键点说明:
timeoutId是全局唯一标识符,用于管理定时器。immediate控制是否在首次调用时立刻执行。cancel()方法允许外部主动终止未完成的防抖任务(比如组件卸载时)。- 使用
apply保证this上下文正确传递给原函数。
示例演示:
const searchHandler = debounce((query) => { console.log(`搜索 "${query}"`); }, 500, true); // 立即执行模式 searchHandler("a"); // 立即输出:搜索 "a" searchHandler("ab"); // 清除上一个定时器,重新计时 searchHandler("abc"); // 再次清空,继续等待 // 500ms 后无新调用 → 输出:搜索 "abc" searchHandler.cancel(); // 主动取消最后的等待任务
2. 节流(Throttle)实现
function throttle(fn, delay = 300, options = {}) { const { leading = true, trailing = true } = options; let lastTime = 0; let timeoutId = null; function throttled(...args) { const now = Date.now(); // 第一次调用或距离上次执行超过 delay if (lastTime === 0 || now - lastTime >= delay) { if (leading) { fn.apply(this, args); } lastTime = now; } else { // 如果不是 leading,且 trailing 为 true,则设置尾部延迟执行 if (trailing && !timeoutId) { timeoutId = setTimeout(() => { timeoutId = null; fn.apply(this, args); lastTime = Date.now(); }, delay - (now - lastTime)); } } } // 取消方法:清除定时器并重置状态 throttled.cancel = function() { if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } lastTime = 0; }; return throttled; }关键点说明:
leading控制是否在第一次调用时立即执行(默认 true)。trailing控制是否在最后一次调用后延迟执行(默认 true)。lastTime记录上次执行的时间戳,用于判断是否满足间隔条件。trailing的实现稍微复杂一点:当事件密集发生时,最后一个事件会被延迟执行(模拟“尾部执行”行为)。
示例演示:
const handleScroll = throttle((event) => { console.log("滚动事件触发", event.type); }, 1000, { leading: true, trailing: true }); // 快速触发多次 scroll handleScroll({ type: 'scroll' }); // 立即执行 handleScroll({ type: 'scroll' }); // 忽略(未满1s) handleScroll({ type: 'scroll' }); // 忽略 setTimeout(() => handleScroll({ type: 'scroll' }), 800); // 还没到1s,不会执行 setTimeout(() => handleScroll({ type: 'scroll' }), 1200); // 超过1s,再次执行 // 如果想取消:handleScroll.cancel();五、进阶技巧:封装为类(更易管理)
有时候我们需要对多个防抖/节流函数进行统一管理和清理(如 React 组件卸载时)。我们可以将其封装为类:
class Debouncer { constructor(delay = 300, immediate = false) { this.delay = delay; this.immediate = immediate; this.timeoutId = null; } run(fn, ...args) { if (this.timeoutId) clearTimeout(this.timeoutId); if (this.immediate && !this.timeoutId) { fn.apply(this, args); } this.timeoutId = setTimeout(() => { this.timeoutId = null; if (!this.immediate) { fn.apply(this, args); } }, this.delay); } cancel() { if (this.timeoutId) { clearTimeout(this.timeoutId); this.timeoutId = null; } } } class Throttler { constructor(delay = 300, options = {}) { this.delay = delay; this.leading = options.leading ?? true; this.trailing = options.trailing ?? true; this.lastTime = 0; this.timeoutId = null; } run(fn, ...args) { const now = Date.now(); if (this.lastTime === 0 || now - this.lastTime >= this.delay) { if (this.leading) fn.apply(this, args); this.lastTime = now; } else { if (this.trailing && !this.timeoutId) { this.timeoutId = setTimeout(() => { this.timeoutId = null; fn.apply(this, args); this.lastTime = Date.now(); }, this.delay - (now - this.lastTime)); } } } cancel() { if (this.timeoutId) { clearTimeout(this.timeoutId); this.timeoutId = null; } this.lastTime = 0; } }这样可以在组件中轻松维护多个任务:
class MyComponent { constructor() { this.debounceSearch = new Debouncer(500, true); this.throttleResize = new Throttler(300, { leading: true, trailing: true }); } onSearch(query) { this.debounceSearch.run(console.log, query); } onResize(event) { this.throttleResize.run(console.log, event); } destroy() { this.debounceSearch.cancel(); this.throttleResize.cancel(); } }六、常见误区澄清
| 误区 | 正确理解 |
|---|---|
| “防抖一定会延迟执行” | immediate=true,首次调用会立即执行 |
| “节流就是每隔一段时间执行一次” | leading和trailing参数如何配置 |
| “防抖适合所有高频事件” | |
| “取消函数只能靠 clearTimeout” | timeoutId) |
七、性能测试建议(实际项目中可用)
你可以用如下方式简单测试两者差异:
const start = performance.now(); function testDebounce() { const d = debounce(() => {}, 100); for (let i = 0; i < 100; i++) { d(); } console.log('防抖耗时:', performance.now() - start); } function testThrottle() { const t = throttle(() => {}, 100); for (let i = 0; i < 100; i++) { t(); } console.log('节流耗时:', performance.now() - start); }你会发现:
- 防抖:最终只会执行一次(即使调用了100次)
- 节流:大约每100ms执行一次,共约10次左右(取决于具体实现细节)
八、结语:何时选哪个?
| 场景 | 推荐策略 |
|---|---|
| 输入框搜索、自动补全 | |
| 实时语音识别、打字速度统计 | |
| 滚动加载分页 | |
| 表单字段校验 | |
| 自动保存草稿 | |
| 大量DOM操作(如拖拽) |
最佳实践建议:
- 明确需求:你想要的是“停止后才响应”还是“固定频率响应”?
- 使用
cancel()在组件销毁或页面离开时释放资源,避免内存泄漏。 - 若需复用,推荐封装成工具函数或类,便于维护。
总结
今天我们不仅讲清楚了防抖和节流的本质区别,还给出了生产级源码实现,涵盖了:
立即执行选项(immediate)
取消功能(cancel)
完整的类型提示和文档风格
类封装形式便于管理多个任务
这些代码可以直接集成进你的项目中,无论是 Vue、React 还是原生 JS 应用都适用。
记住一句话:
“好的性能不是靠堆硬件,而是靠聪明地控制事件流。”
希望今天的分享对你有帮助!欢迎留言交流你的实战经验