手写:防抖与节流函数深度解析
从原理到实现,手写完整的防抖(debounce)与节流(throttle)函数,涵盖立即执行版本与取消功能
一句话概括
防抖(debounce)让高频事件在最后一次触发后延迟执行,节流(throttle)让高频事件按固定频率执行——两者都是控制函数调用频率的核心手段,也是前端面试必考手写题。
背景
在浏览器中,scroll、resize、input、mousemove 等事件会以极高频率触发(每秒数十次甚至上百次)。如果每次触发都执行复杂逻辑(如 API 请求、DOM 操作),会严重影响性能。
防抖和节流是解决这一问题的两种经典策略:
| 策略 | 核心思想 | 典型场景 |
|---|---|---|
| 防抖 | 最后一次触发后等待 N ms 再执行 | 搜索框输入、表单验证 |
| 节流 | 每隔 N ms 最多执行一次 | 滚动监听、按钮防重复点击 |
概念与定义
防抖(Debounce)
在事件被触发 N 毫秒后再执行回调,如果在这 N 毫秒内又被触发,则重新计时。
生活类比:电梯关门——每次有人进来,关门计时器重置,直到没人进来才关门。
节流(Throttle)
在 N 毫秒内,无论触发多少次,只执行一次回调。
生活类比:地铁进站——无论多少人等待,每隔固定时间才开一次门。
最小示例
1
2
3
4
5
6
7
8
9
10
11
12
13
// 防抖:输入停止 500ms 后才搜索
const handleSearch = debounce((value) => {
fetchSearchResults(value);
}, 500);
input.addEventListener('input', (e) => handleSearch(e.target.value));
// 节流:滚动时每 200ms 最多执行一次
const handleScroll = throttle(() => {
updateScrollPosition();
}, 200);
window.addEventListener('scroll', handleScroll);
核心知识点拆解
1. 防抖的核心机制
防抖的关键在于定时器的清除与重置:
1
2
3
4
触发 → 清除旧定时器 → 设置新定时器(N ms 后执行)
触发 → 清除旧定时器 → 设置新定时器(N ms 后执行)
触发 → 清除旧定时器 → 设置新定时器(N ms 后执行)
... N ms 内无触发 → 执行!
2. 节流的两种实现思路
时间戳版:记录上次执行时间,每次触发时判断是否超过间隔
1
2
触发 → 当前时间 - 上次执行时间 >= N → 执行,更新时间戳
触发 → 当前时间 - 上次执行时间 < N → 跳过
定时器版:用定时器标记是否在冷却中
1
2
触发 → 无定时器 → 设置定时器(N ms 后执行并清除定时器)
触发 → 有定时器 → 跳过
两者的区别:
- 时间戳版:第一次立即执行,最后一次可能不执行
- 定时器版:第一次延迟执行,最后一次一定执行
实战案例
手写防抖函数(基础版)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 防抖函数
* @param {Function} fn - 需要防抖的函数
* @param {number} delay - 延迟时间(ms)
* @returns {Function} 防抖后的函数
*/
function debounce(fn, delay) {
let timer = null;
return function (...args) {
// 每次触发先清除旧定时器
if (timer) {
clearTimeout(timer);
}
// 重新设置定时器
timer = setTimeout(() => {
fn.apply(this, args); // 保持 this 指向和参数
timer = null;
}, delay);
};
}
手写防抖函数(完整版:支持立即执行 + 取消)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
* 完整防抖函数
* @param {Function} fn - 需要防抖的函数
* @param {number} delay - 延迟时间(ms)
* @param {boolean} immediate - 是否立即执行(首次触发立即执行,之后防抖)
* @returns {Function} 防抖后的函数,附带 cancel 方法
*/
function debounce(fn, delay, immediate = false) {
let timer = null;
let isInvoked = false; // 记录立即执行版是否已执行
const debounced = function (...args) {
const context = this;
// 立即执行版:第一次触发立即执行
if (immediate && !isInvoked) {
fn.apply(context, args);
isInvoked = true;
}
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
if (!immediate) {
fn.apply(context, args);
}
timer = null;
isInvoked = false; // 重置,允许下次立即执行
}, delay);
};
// 取消防抖
debounced.cancel = function () {
if (timer) {
clearTimeout(timer);
timer = null;
isInvoked = false;
}
};
return debounced;
}
使用示例:
1
2
3
4
5
6
7
8
// 普通防抖:停止输入 500ms 后搜索
const search = debounce(fetchData, 500);
// 立即执行防抖:首次点击立即执行,之后 1s 内不重复
const handleClick = debounce(submitForm, 1000, true);
// 取消防抖(如组件卸载时)
handleClick.cancel();
手写节流函数(时间戳版)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 节流函数(时间戳版)
* 特点:第一次立即执行,最后一次可能不执行
*/
function throttle(fn, interval) {
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime >= interval) {
fn.apply(this, args);
lastTime = now;
}
};
}
手写节流函数(定时器版)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 节流函数(定时器版)
* 特点:第一次延迟执行,最后一次一定执行
*/
function throttle(fn, interval) {
let timer = null;
return function (...args) {
if (!timer) {
timer = setTimeout(() => {
fn.apply(this, args);
timer = null;
}, interval);
}
};
}
手写节流函数(完整版:结合时间戳 + 定时器)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* 完整节流函数
* 特点:第一次立即执行,最后一次也执行,支持取消
* @param {Function} fn - 需要节流的函数
* @param {number} interval - 节流间隔(ms)
* @returns {Function} 节流后的函数,附带 cancel 方法
*/
function throttle(fn, interval) {
let lastTime = 0;
let timer = null;
const throttled = function (...args) {
const context = this;
const now = Date.now();
const remaining = interval - (now - lastTime); // 距下次可执行的剩余时间
if (remaining <= 0) {
// 已超过间隔,立即执行
if (timer) {
clearTimeout(timer);
timer = null;
}
fn.apply(context, args);
lastTime = now;
} else {
// 未超过间隔,设置定时器确保最后一次执行
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
fn.apply(context, args);
lastTime = Date.now();
timer = null;
}, remaining);
}
};
// 取消节流
throttled.cancel = function () {
if (timer) {
clearTimeout(timer);
timer = null;
}
lastTime = 0;
};
return throttled;
}
使用示例:
1
2
3
4
5
6
7
8
9
10
// 滚动节流:每 200ms 最多执行一次
const handleScroll = throttle(() => {
console.log('scroll position:', window.scrollY);
}, 200);
window.addEventListener('scroll', handleScroll);
// 组件卸载时取消
window.removeEventListener('scroll', handleScroll);
handleScroll.cancel();
底层原理
为什么要用 fn.apply(this, args)?
在返回的闭包函数中,this 指向调用者(如 DOM 元素)。如果直接调用 fn(...args),this 会丢失(严格模式下为 undefined,非严格模式下为 window)。
1
2
3
4
5
6
7
8
// 错误示例:this 丢失
const btn = document.querySelector('button');
btn.addEventListener('click', debounce(function() {
console.log(this); // ❌ 如果不用 apply,this 不是 btn
}, 300));
// 正确:使用 apply 保持 this 指向
fn.apply(this, args); // ✅ this 正确指向 btn
闭包在防抖/节流中的作用
防抖和节流的核心依赖闭包来保存状态:
1
2
3
4
5
6
7
8
9
function debounce(fn, delay) {
let timer = null; // ← 这个变量通过闭包被保存
return function (...args) {
// 每次调用都能访问到同一个 timer
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
timer 变量存在于 debounce 函数的作用域中,通过闭包被返回的函数持续引用,不会被垃圾回收。
内存泄漏风险
如果防抖/节流函数绑定在 DOM 元素上,且 DOM 元素被移除但事件监听未清除,闭包中的 timer 和 fn 引用会阻止垃圾回收。
最佳实践:组件卸载时调用 .cancel() 并移除事件监听。
高频面试题解析
Q1:防抖和节流的区别是什么?
答:
- 防抖:事件停止触发后 N ms 才执行,适合”等用户操作完成再响应”的场景(搜索框、表单验证)
- 节流:每 N ms 最多执行一次,适合”持续触发但需要限频”的场景(滚动、鼠标移动)
核心区别:防抖关注最后一次,节流关注执行频率。
Q2:防抖的立即执行版和普通版有什么区别?
答:
- 普通版:触发后等待 N ms 执行,适合”等待用户停止操作”
- 立即执行版:第一次触发立即执行,之后 N ms 内不再执行,适合”立即响应但防止重复”(如按钮防重复点击)
Q3:节流的时间戳版和定时器版有什么区别?
答:
| 对比项 | 时间戳版 | 定时器版 |
|---|---|---|
| 第一次触发 | 立即执行 | 延迟 N ms 执行 |
| 最后一次触发 | 可能不执行 | 一定执行 |
| 精确度 | 较高 | 受 setTimeout 影响 |
完整版结合两者:第一次立即执行,最后一次也执行。
Q4:如何在 React 中正确使用防抖/节流?
答:需要用 useRef 或 useCallback 保持函数引用稳定,避免每次渲染创建新的防抖函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ❌ 错误:每次渲染都创建新的防抖函数,防抖失效
function SearchInput() {
const handleSearch = debounce(fetchData, 500); // 每次渲染重新创建!
return <input onChange={handleSearch} />;
}
// ✅ 正确:用 useRef 保持引用稳定
function SearchInput() {
const handleSearch = useRef(debounce(fetchData, 500)).current;
return <input onChange={handleSearch} />;
}
// ✅ 或者用 useCallback(注意依赖项)
function SearchInput() {
const handleSearch = useCallback(
debounce(fetchData, 500),
[] // 空依赖,只创建一次
);
return <input onChange={handleSearch} />;
}
Q5:防抖和节流能用 requestAnimationFrame 实现吗?
答:可以,requestAnimationFrame 节流适合动画场景,每帧(约 16.7ms)执行一次:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function rafThrottle(fn) {
let rafId = null;
return function (...args) {
if (rafId) return; // 已有待执行帧,跳过
rafId = requestAnimationFrame(() => {
fn.apply(this, args);
rafId = null;
});
};
}
// 适合滚动动画、Canvas 绘制等场景
const handleScroll = rafThrottle(updateAnimation);
总结与扩展
核心要点回顾
| 要点 | 防抖 | 节流 |
|---|---|---|
| 执行时机 | 停止触发后 N ms | 每 N ms 最多一次 |
| 实现核心 | clearTimeout + setTimeout | 时间戳比较 或 定时器标记 |
| 适用场景 | 搜索框、表单验证、窗口 resize | 滚动、鼠标移动、按钮点击 |
| 关键细节 | apply 保持 this、立即执行版 | 时间戳版 vs 定时器版的差异 |
扩展阅读
- Lodash 源码:
_.debounce和_.throttle是工业级实现,支持leading/trailing选项,值得阅读 - React 中的防抖:结合
useRef、useCallback使用,避免闭包陷阱 requestAnimationFrame节流:动画场景的最佳节流方案- 下一步:第2周将深入
this指向与原型链,理解apply/call/bind的底层实现
💡 面试技巧:手写防抖/节流时,先写基础版(5行代码),再说”完整版还需要考虑 this 绑定、立即执行、取消功能”,展示你的工程思维。