本文将从核心思想→基础实现→生产级代码→应用场景→面试考点全链路讲解,所有代码均可直接复制到项目中使用。
前言
你一定遇到过这些场景:
- 输入框实时搜索,每输入一个字符就发一次请求
- 窗口 resize 时,页面疯狂重绘导致卡顿
- 滚动加载更多,滚动一次触发十次接口
- 按钮快速点击导致表单重复提交
这些问题的本质都是:短时间内高频触发的函数,造成了不必要的性能浪费。
而防抖(Debounce)和节流(Throttle),就是解决这类问题最核心、最常用的两个性能优化技术。
一、防抖(Debounce)
1. 核心思想
将短时间内多次触发的函数,合并为最后一次执行。
简单说就是:等你停下来,我再执行。
2. 基础版实现(理解原理)
这是最容易理解的核心逻辑,适合新手入门:
let timer = null;
input.addEventListener('keyup', function() {
// 每次触发都清除之前的定时器
if (timer) clearTimeout(timer);
// 重新开始计时
timer = setTimeout(() => {
console.log('发送搜索请求');
}, 500);
});
缺点:全局变量污染、不可复用、this 指向错误、无法传递参数。
3. 生产级实现
解决了基础版的所有问题,支持立即执行 / 非立即执行双模式,自带取消功能:
/**
* 防抖函数
* @param {Function} fn - 需要防抖的目标函数
* @param {number} delay - 延迟时间(毫秒)
* @param {boolean} immediate - 是否立即执行(默认:false)
* @returns {Function} 防抖后的函数,自带cancel方法
*/
function debounce(fn, delay, immediate = false) {
let timer = null;
const debounced = function(...args) {
// 保存正确的this上下文
const context = this;
// 每次触发都清除之前的定时器
if (timer) clearTimeout(timer);
if (immediate) {
// 立即执行模式:只有当没有定时器时才执行
const callNow = !timer;
// 定时器仅负责重置状态,不执行函数
timer = setTimeout(() => {
timer = null;
}, delay);
// 满足条件则立即执行
if (callNow) fn.apply(context, args);
} else {
// 非立即执行模式:最后一次触发后执行
timer = setTimeout(() => {
fn.apply(context, args);
timer = null;
}, delay);
}
};
// 手动取消未执行的防抖,cancel 是我们手动给防抖 / 节流函数添加的一个自定义方法,它的唯一作用是:提前终止还没执行的防抖 / 节流操作。
debounced.cancel = () => {
clearTimeout(timer); // 取消timer这个 ID 对应的定时器,让它不再执行。
timer = null; // 将 timer 变量强制恢复到「没有任何定时器在运行」的初始状态
};
return debounced;
}
// 处理函数
function handle() {
console.log(Math.random());
}
// resize事件
window.addEventListener("resize", debounce(handle, 1000));
这个写法的问题在于:它没有保存最后一次的参数和上下文。当最后一次触发事件时,它只是重置了定时器,但没有把最新的参数保存下来,所以定时器结束后也无法执行最后一次调用。
最终完美版、无任何 bug 的标准防抖:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div>
<input />
</div>
<script>
function debounce(
fn,
delay,
options = { leading: false, trailing: true },
) {
let timer = null;
let lastArgs; // 保存最后一次参数
let lastThis; // 保存最后一次上下文
const { leading, trailing } = options;
// 核心:执行函数后清空参数,杜绝重复执行
function invoke() {
fn.apply(lastThis, lastArgs);
// 关键修复:执行后清空,单次输入不会触发 trailing
lastArgs = lastThis = null;
}
function debounced(...args) {
// 保存最新的参数和上下文
lastArgs = args;
lastThis = this;
// 清除之前的定时器
if (timer) clearTimeout(timer);
// 立即执行模式:首次触发时执行
const isFirstInvoke = leading && !timer;
if (isFirstInvoke) {
invoke();
}
// 设置定时器:延迟结束后执行最后一次
timer = setTimeout(() => {
// 关键:只有存在未执行的参数,才执行(避免单次输入重复)
if (trailing && lastArgs) {
invoke();
}
timer = null;
}, delay);
}
// 取消防抖
debounced.cancel = () => {
clearTimeout(timer);
timer = lastArgs = lastThis = null;
};
return debounced;
}
// 测试函数
function handleInput(e) {
console.log("执行:", e.target.value);
}
// 配置:leading=true + trailing=true(你要的模式)
const input = document.querySelector("input");
input.addEventListener("keyup", debounce(handleInput, 1000));
</script>
</body>
</html>
4. 三种配置效果
| 配置 | 效果 | 适用场景 |
|---|---|---|
{leading:false, trailing:true} | 停止输入后执行 1 次 | 搜索联想 |
{leading:true, trailing:false} | 仅首次输入执行 1 次 | 按钮防重复点击 |
✅ {leading:true, trailing:true} | 连续输入 = 首次 + 最后一次;单次输入 = 仅 1 次 | 实时输入 / 自动保存 |
二、节流(Throttle)
1. 核心思想
保证函数在固定时间间隔内,最多只执行一次。
简单说就是:不管你触发多少次,我每隔固定时间只执行一次。
2. 生产级实现(推荐直接使用)
这是目前最标准、功能最完整的节流实现,支持leading/trailing双配置:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>标准节流测试</title>
</head>
<body>
<div>
<input placeholder="输入测试节流" />
</div>
<script>
/**
* 标准节流函数(与Lodash行为一致)
* @param {Function} fn 要节流的函数
* @param {number} delay 节流间隔(ms)
* @param {Object} options 配置项
* @param {boolean} options.leading 是否在间隔开始时执行
* @param {boolean} options.trailing 是否在间隔结束时执行
* @returns {Function} 节流后的函数
*/
function throttle(
fn,
delay,
options = { leading: true, trailing: true },
) {
const { leading, trailing } = options;
let timer = null;
let lastTime = 0;
let lastArgs = null; // 保存最后一次参数
let lastContext = null; // 保存最后一次上下文
// 执行函数的统一入口
function invokeFunc() {
if (lastArgs) {
fn.apply(lastContext, lastArgs);
lastArgs = lastContext = null; // 执行后清空,避免重复执行
}
}
function throttled(...args) {
const now = Date.now();
// 更新最新的参数和上下文(关键:解决最后一次输入丢失)
lastArgs = args;
lastContext = this;
// 第一次触发且关闭leading,把上次执行时间设为现在
if (!leading && !lastTime) {
lastTime = now;
}
const remain = delay - (now - lastTime);
// 到达冷却时间 → 执行
if (remain <= 0) {
if (timer) {
clearTimeout(timer);
timer = null;
}
invokeFunc();
lastTime = now;
}
// 冷却中,且需要trailing → 只开一次定时器
else if (trailing && !timer) {
timer = setTimeout(() => {
invokeFunc();
lastTime = Date.now();
timer = null;
}, remain);
}
}
// 取消节流
throttled.cancel = () => {
clearTimeout(timer);
timer = null;
lastTime = 0;
lastArgs = lastContext = null;
};
return throttled;
}
// 测试函数
function handleInput(e) {
console.log("执行:", e.target.value, "时间:", Date.now());
}
const input = document.querySelector("input");
// 配置:leading=true + trailing=true(最常用模式)
input.addEventListener("keyup", throttle(handleInput, 1000));
</script>
</body>
</html>
3. 四种组合模式
| 配置 | 行为 | 适用场景 |
|---|---|---|
leading: true, trailing: true(默认) | 开始执行一次,结束再补一次 | 滚动加载更多、窗口 resize |
leading: true, trailing: false | 只在开始执行一次 | 按钮点击、鼠标移动 |
leading: false, trailing: true | 只在结束执行一次 | 拖拽元素位置更新 |
leading: false, trailing: false | 无意义,不推荐使用 | - |
| 备注: |
-
leading控制周期开始时是否执行,保证响应速度 -
trailing控制周期结束时是否补执行,保证状态最终一致 -
默认配置
{ leading: true, trailing: true }适合绝大多数场景
三、防抖 vs 节流:一张表搞懂区别
| 特性 | 防抖(Debounce) | 节流(Throttle) |
|---|---|---|
| 核心逻辑 | 最后一次执行 | 每隔固定时间执行一次 |
| 执行次数 | 高频触发下只执行 1 次 | 高频触发下执行多次(固定频率) |
| 适用场景 | 等待用户操作结束后再执行 | 需要保证一定执行频率的场景 |
| 典型应用 | 输入框搜索、自动保存、按钮防重复点击 | 滚动加载、窗口 resize、拖拽、动画 |
一句话总结:
- 防抖:适合 “等你停下来再做” 的场景
- 节流:适合 “每隔一段时间做一次” 的场景
四、实际使用示例
1. 输入框实时搜索(防抖)
const searchInput = document.querySelector('.search-input');
function handleSearch(e) {
console.log('发送搜索请求:', e.target.value);
// 实际项目中这里调用接口
}
// 停止输入500ms后再发送请求
searchInput.addEventListener('keyup', debounce(handleSearch, 500));
2. 按钮防重复点击(防抖 - 立即执行)
const submitBtn = document.querySelector('.submit-btn');
function handleSubmit() {
console.log('提交表单');
// 实际项目中这里调用提交接口
}
// 点击后立即执行,3秒内再次点击无效
submitBtn.addEventListener('click', debounce(handleSubmit, 3000, true));
3. 滚动加载更多(节流)
function handleScroll() {
// 判断是否滚动到底部
if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
console.log('加载更多数据');
// 实际项目中这里调用加载更多接口
}
}
// 每隔200ms执行一次,避免频繁触发
window.addEventListener('scroll', throttle(handleScroll, 200));
4. 框架中使用(React/Vue)
重要:组件卸载时一定要调用 cancel 方法,避免内存泄漏
// React示例
useEffect(() => {
const handleResize = throttle(() => {
console.log('窗口大小变化');
}, 200);
window.addEventListener('resize', handleResize);
// 组件卸载时取消节流
return () => {
handleResize.cancel();
};
}, []);
五、常见误区与踩坑点
1. 最经典的坑:事件绑定加括号
// ❌ 错误:加了括号,函数会立即执行,不会绑定事件
window.addEventListener('resize', handle());
// ✅ 正确:不加括号,传递函数本身
window.addEventListener('resize', handle);
// ✅ 正确:防抖/节流写法也是一样
window.addEventListener('resize', debounce(handle, 500));
2. 分不清防抖和节流
- 输入框搜索:用防抖(等用户输完再搜)
- 滚动加载:用节流(每隔一段时间加载一次)
- 按钮防重复点击:用防抖(立即执行模式)
3. 组件卸载时不取消定时器
会导致内存泄漏,尤其是在单页应用中,一定要在组件卸载时调用cancel()方法。
4. 同时开启 leading 和 trailing,一个周期内会执行两次
绝对不会!一个节流周期内,函数最多只会执行一次。如果在周期开始时已经执行了 leading,那么周期结束时的 trailing 会被自动取消。
只有当周期内有新的触发,但没有触发 leading 执行时,trailing 才会在周期结束时执行。
六、高频面试题汇总
1. 什么是防抖和节流?它们有什么区别?
答:见本文第三部分。
2. 手写防抖函数
答:见本文第一部分生产级实现。
3. 手写节流函数
答:见本文第二部分生产级实现。
4. 防抖和节流的应用场景有哪些?
答:见本文第四部分。
5. 为什么防抖和节流要用闭包?
答:为了封装定时器状态(timer、lastTime),避免全局变量污染,同时保证每个防抖 / 节流函数都有自己独立的状态,互不干扰。
七、总结
防抖和节流是前端开发中最基础也最重要的性能优化技术,掌握它们不仅能解决实际开发中的性能问题,也是面试中的必考点。
本文提供的防抖和节流实现,是目前行业内最标准、最健壮的版本,可以直接复制到任何项目中使用。
最后一句话:
能用 Lodash 的
_.debounce和_.throttle就直接用,它们经过了大量生产环境的验证。但你必须理解它们的原理,这样遇到问题时才能快速排查。