准备面试的时候,我发现自己虽然用过 lodash 的 debounce,但如果要手写实现,脑子里却一片空白。
为什么需要延迟?为什么要用闭包?立即执行模式是怎么回事?带着这些疑问,我决定从头梳理一遍防抖的原理。这篇文章是我的学习笔记,希望能帮对 debounce 好奇的你建立清晰的思路。
问题的起源:一次性能优化的思考
防抖(Debounce)这个概念最初来自硬件领域,用来解决按钮的机械抖动问题。在前端开发中,我们遇到的其实是类似的场景:用户的连续操作可能触发大量不必要的事件处理。
假设你在做一个搜索框的实时联想功能。用户输入 "react hooks" 这 11 个字符,如果每次按键都发送一次请求,那就是 11 次网络调用。但实际上,用户真正想搜索的是完整的 "react hooks",前面 10 次请求都是无意义的。
这就是防抖要解决的核心问题:在用户频繁操作时,只响应最后一次有效操作。
javascript
// 环境:浏览器
// 场景:搜索框输入,没有防抖的情况
const searchInput = document.querySelector('#search');
searchInput.addEventListener('input', (e) => {
// 假设这是一个网络请求
console.log('发送请求:', e.target.value);
// fetch(`/api/search?q=${e.target.value}`)
});
// 用户输入 "react" 时,会触发 5 次 console.log
// "r" -> "re" -> "rea" -> "reac" -> "react"
核心原理:时间轴上的延迟与取消
防抖的实现本质上就两个关键动作:
- 延迟执行:不立即执行函数,而是等待一段时间
- 取消重复:如果在等待期间又触发了事件,取消之前的等待,重新开始计时
我们可以用一个时间轴来理解这个过程:
100ms后] -->|取消前一次
重新等待300ms| D[等待中] E[第3次输入
150ms后] -->|取消前一次
重新等待300ms| F[等待中] F -->|300ms后
无新输入| G[执行函数]
用人话说就是:
给我 300 毫秒的缓冲时间,如果这段时间内你又触发了事件,我就重置计时器;
如果 300 毫秒内你没再触发,我就执行函数。
定时器的作用:clearTimeout 实现"取消"
JavaScript 的 setTimeout 返回一个定时器 ID,我们可以用 clearTimeout 来取消这个定时器。
这就是防抖"取消重复"的核心机制。
javascript
// 环境:浏览器 / Node.js
// 场景:理解 clearTimeout 的作用
let timer = setTimeout(() => {
console.log('这句话不会被打印');
}, 1000);
// 在定时器触发前取消它
clearTimeout(timer);
// 重新设置一个新的定时器
timer = setTimeout(() => {
console.log('这句话会在 1 秒后打印');
}, 1000);
闭包的必要性:保存 timer 状态
为了在多次调用之间保持 timer 的引用,我们需要用闭包。这是很多人理解防抖时的第一个难点。
javascript
// 环境:浏览器
// 场景:为什么需要闭包
// ❌ 错误的实现:每次调用都是新的 timer
function wrongDebounce(func, delay) {
return (..arg) => {
let timer; // 这个 timer 在每次调用 wrongDebounce 时都会重新创建
if(timer) clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this,args);
}, delay);
};
}
// ✅ 正确的实现:返回一个函数,形成闭包
function correctDebounce(func, delay) {
let timer; // 这个 timer 被闭包捕获,在多次调用间保持引用
return (...args) => {
// 取消上一个定时器
clearTimeout(timer);
// 重新设置定时器
timer = setTimeout(() => {
func(...args);
}, delay);
};
}
// 使用示例
const debouncedSearch = correctDebounce(() => {
console.log('执行搜索');
}, 300);
debouncedSearch(); // 第 1 次调用,设置定时器
debouncedSearch(); // 第 2 次调用,取消上一个定时器,重新设置
debouncedSearch(); // 第 3 次调用,取消上一个定时器,重新设置
// 只有最后一次会在 300ms 后执行
闭包让 timer 变量成为了一个"私有状态",所有通过 correctDebounce 返回的函数都共享这个状态。
闭包让 debounce 能够"记住"之前设置的定时器,从而在新的调用时取消它,实现"延迟执行,只执行最后一次"的效果。
从最简实现到完整版本
让我们一步步构建防抖函数,每个版本解决一个具体的问题。
版本 1:最基础的防抖(核心逻辑)
javascript
// 环境:浏览器 / Node.js
// 场景:最简单的防抖实现
// 功能:延迟执行,取消重复
function debounce(func, delay) {
let timer = null;
return function() {
// 如果已经有定时器在等待,取消它
clearTimeout(timer);
// 设置新的定时器
timer = setTimeout(() => {
func();
}, delay);
};
}
// 测试
const log = debounce(() => console.log('executed'), 300);
log(); // 不会执行
log(); // 不会执行
log(); // 300ms 后执行
这个版本虽然简单,但已经实现了防抖的核心思想。不过它有两个明显的问题:
- 没有处理函数的参数
- 没有处理
this绑定
版本 2:处理参数和 this 绑定
javascript
// 环境:浏览器 / Node.js
// 场景:处理参数传递和 this 上下文
function debounce(func, delay) {
let timer = null;
return function(...args) { // 使用剩余参数收集所有参数
const context = this; // 保存调用时的 this
clearTimeout(timer);
timer = setTimeout(() => {
// 使用 apply 调用,传入正确的 this 和参数
func.apply(context, args);
}, delay);
};
}
// 测试:在对象方法中使用
const searchBox = {
keyword: '',
search: debounce(function(value) {
this.keyword = value; // this 应该指向 searchBox
console.log('搜索:', this.keyword);
}, 300)
};
searchBox.search('react'); // 300ms 后打印 "搜索: react"
这里有两个容易混淆的点:
- 为什么要保存
this?因为setTimeout内部的箭头函数会捕获外层的this,如果不保存,当setTimeout执行时,this可能已经指向别的对象了。 - 为什么用
apply而不是直接调用 ?因为我们需要同时传入this上下文和参数数组。
版本 3:支持立即执行(leading edge)
有些场景下,我们希望第一次触发时立即执行,而不是延迟。比如提交表单按钮,用户第一次点击应该立即响应,后续的连续点击才需要防抖。
javascript
// 环境:浏览器 / Node.js
// 场景:支持立即执行模式
// 参数:immediate 为 true 时,第一次触发立即执行
function debounce(func, delay, immediate = false) {
let timer = null;
return function(...args) {
const context = this;
const callNow = immediate && !timer; // 立即执行的条件
clearTimeout(timer);
timer = setTimeout(() => {
timer = null; // 重要:重置 timer,为下次立即执行做准备
if (!immediate) {
func.apply(context, args);
}
}, delay);
// 如果是立即执行模式,且当前没有等待中的定时器,立即调用
if (callNow) {
func.apply(context, args);
}
};
}
// 测试:提交按钮防抖
const submitButton = document.querySelector('#submit');
const handleSubmit = debounce(function() {
console.log('提交表单');
}, 1000, true); // immediate = true
submitButton.addEventListener('click', handleSubmit);
// 用户连续点击 3 次:
// 第 1 次:立即执行 console.log
// 第 2 次:不执行(在 1 秒内)
// 第 3 次:不执行(在 1 秒内)
// 1 秒后:timer 被重置,下次点击又会立即执行
立即执行模式的关键在于:
callNow的判断:immediate && !timer表示"开启了立即执行 且 当前没有等待中的定时器"timer = null的重置:延迟结束后,把timer重置为null,这样下次触发时!timer才为true,才能再次立即执行
版本 4: 添加取消功能
有时候我们需要手动取消防抖,比如用户切换了页面,之前设置的防抖操作应该被取消。
javascript
// 环境:浏览器 / Node.js
// 场景:可手动取消的防抖
// 功能:返回的函数附带 cancel 方法
function debounce(func, delay, immediate = false) {
let timer = null;
const debounced = function(...args) {
const context = this;
const callNow = immediate && !timer;
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (!immediate) {
func.apply(context, args);
}
}, delay);
if (callNow) {
func.apply(context, args);
}
};
// 添加 cancel 方法
debounced.cancel = function() {
clearTimeout(timer);
timer = null;
};
return debounced;
}
// 测试:页面跳转时取消防抖
const autoSave = debounce(() => {
console.log('自动保存草稿');
}, 2000);
// 用户编辑内容
document.addEventListener('input', autoSave);
// 用户点击"退出"按钮时,取消自动保存
document.querySelector('#exit').addEventListener('click', () => {
autoSave.cancel();
console.log('已取消自动保存');
});
手写实现的关键点
在面试中手写防抖,有几个容易踩的坑:
1. 闭包陷阱:为什么要用 apply
javascript
// ❌ 错误:直接调用会丢失 this 和参数
timer = setTimeout(() => {
func(); // this 是 undefined,参数也丢失了
}, delay);
// ✅ 正确:使用 apply 传入 context 和 args
timer = setTimeout(() => {
func.apply(context, args);
}, delay);
有人会问:为什么不用 func.call(context, ...args) 或者 func.bind(context)(...args)?
call也可以,但apply接受数组参数,写起来更简洁bind会创建新函数,性能略差,虽然在这个场景下影响不大
2. this 指向问题的常见错误
javascript
// ❌ 错误:使用箭头函数定义返回的函数
function debounce(func, delay) {
let timer = null;
// 箭头函数的 this 是词法作用域,不能被 apply/call 改变
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this, args); // 这里的 this 不是调用时的 this!
}, delay);
};
}
// ✅ 正确:使用普通函数
return function(...args) {
const context = this; // 这里的 this 才是调用时的 this
// ...
};
3. 立即执行的边界条件
立即执行模式最容易写错的是 callNow 的判断逻辑:
arduino
// ❌ 错误:只判断 immediate
const callNow = immediate; // 这样每次都会立即执行!
// ✅ 正确:immediate && !timer
const callNow = immediate && !timer; // 只在第一次或延迟结束后立即执行
而且别忘了在 setTimeout 回调中重置 timer = null,否则第二轮的立即执行永远不会触发。
实战应用场景
理解了原理后,我们来看看防抖在实际开发中的几个典型场景。
场景 1:搜索框实时联想
javascript
// 环境:React 组件
// 场景:搜索框输入防抖
// 依赖:React, fetch API
import { useState, useCallback } from 'react';
function SearchBox() {
const [suggestions, setSuggestions] = useState([]);
// 使用 useCallback 配合防抖
const fetchSuggestions = useCallback(
debounce(async (keyword) => {
if (!keyword) {
setSuggestions([]);
return;
}
const response = await fetch(`/api/search?q=${keyword}`);
const data = await response.json();
setSuggestions(data.suggestions);
}, 300),
[] // 空依赖,确保 debounce 只创建一次
);
const handleInput = (e) => {
fetchSuggestions(e.target.value);
};
return (
<div>
<input type="text" onChange={handleInput} />
<ul>
{suggestions.map(item => (
<li key={item.id}>{item.text}</li>
))}
</ul>
</div>
);
}
这个例子中有个细节:为什么要用 useCallback 包裹?因为每次组件重新渲染,debounce 都会创建一个新的防抖函数,导致之前的 timer 丢失。用 useCallback 可以确保防抖函数在组件生命周期内只创建一次。
场景 2:窗口 resize 事件优化
javascript
// 环境:浏览器
// 场景:响应式布局调整
// 功能:窗口大小改变时重新计算布局
function initResponsiveLayout() {
const updateLayout = () => {
const width = window.innerWidth;
// 假设这里有复杂的 DOM 操作
if (width < 768) {
document.body.classList.add('mobile');
} else {
document.body.classList.remove('mobile');
}
};
// 初始化时执行一次
updateLayout();
// resize 时防抖执行
const debouncedUpdate = debounce(updateLayout, 200);
window.addEventListener('resize', debouncedUpdate);
// 清理函数(如果在框架中使用)
return () => {
debouncedUpdate.cancel();
window.removeEventListener('resize', debouncedUpdate);
};
}
场景 3:表单自动保存
javascript
// 环境:Vue 组件
// 场景:富文本编辑器自动保存草稿
// 依赖:Vue 3, axios
import { ref, watch } from 'vue';
import axios from 'axios';
export default {
setup() {
const content = ref('');
const lastSaved = ref(null);
const saveDraft = debounce(async (value) => {
try {
await axios.post('/api/draft', { content: value });
lastSaved.value = new Date();
console.log('草稿已保存');
} catch (error) {
console.error('保存失败:', error);
}
}, 2000);
// 监听内容变化,触发自动保存
watch(content, (newValue) => {
saveDraft(newValue);
});
return { content, lastSaved };
}
};
防抖与节流的对比
"防抖和节流有什么区别?" 我的理解是:
防抖(Debounce) :等你冷静下来再说话
- 触发机制: 连续触发只执行最后一次
- 时间特征: 从最后一次触发开始计时
- 典型场景: 搜索框输入、窗口 resize、表单验证
节流(Throttle) : 限制你说话的频率
- 触发机制: 固定时间间隔内只执行一次
- 时间特征: 从第一次触发开始计时
- 典型场景: 滚动加载、鼠标移动、游戏射击
用一个例子类比:
javascript
// 防抖:电梯等人
// 有人进来就重新等 10 秒,10 秒内没人进来才关门
debounce(closeDoor, 10000);
// 节流:定时发车
// 不管来多少人,每 10 分钟发一班车
throttle(startBus, 600000);
在 React Hooks 中的正确使用
在函数组件中使用防抖,有一些特殊的注意点。
javascript
// 环境:React 18+
// 场景:在函数组件中正确使用防抖
import { useState, useMemo, useRef, useEffect } from 'react';
function SearchComponent() {
const [keyword, setKeyword] = useState('');
// ✅ 方案 1:使用 useMemo 创建防抖函数
const debouncedSearch = useMemo(
() => debounce((value) => {
console.log('搜索:', value);
// 实际的搜索逻辑
}, 300),
[] // 空依赖,确保只创建一次
);
// 组件卸载时清理
useEffect(() => {
return () => {
debouncedSearch.cancel();
};
}, [debouncedSearch]);
const handleChange = (e) => {
const value = e.target.value;
setKeyword(value);
debouncedSearch(value);
};
return <input value={keyword} onChange={handleChange} />;
}
// ✅ 方案 2:使用自定义 Hook
function useDebounce(callback, delay) {
const callbackRef = useRef(callback);
// 保持 callback 引用最新
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const debouncedCallback = useMemo(() => {
return debounce((...args) => {
callbackRef.current(...args);
}, delay);
}, [delay]);
// 清理
useEffect(() => {
return () => debouncedCallback.cancel();
}, [debouncedCallback]);
return debouncedCallback;
}
延伸思考
在研究防抖的过程中,我产生了一些新的疑问:
- 返回值怎么处理 ?如果被防抖的函数有返回值,我们能拿到吗?
- 答案是不能直接拿到,因为
setTimeout是异步的。一种可能的方案是返回 Promise,但这又引入了新的复杂度。
- 答案是不能直接拿到,因为
- 防抖函数能取消吗 ?我们在版本 4 中实现了
cancel方法,但如果需要获取"是否有待执行的函数"这个状态呢?- 可以考虑添加
isPending方法。
- 可以考虑添加
- 最大等待时间 ?有些场景下,我们希望防抖有一个上限,比如不管用户输入多快,至少每 5 秒要执行一次。
- 这就需要结合节流的思想,实现一个"带最大等待时间的防抖"。
- 在微前端架构中 ,如果子应用被卸载,防抖函数还在等待中怎么办?
- 这涉及到生命周期管理和内存泄漏的问题。
小结
防抖看似简单,实际上涉及闭包、this 绑定、定时器管理等多个知识点。手写防抖的关键是理解"延迟与取消"的核心思想,然后一步步处理参数、this、立即执行等细节。
这篇文章是我准备面试时的思考过程,可能有理解不到位的地方。如果你有更好的实现方案或者发现了文中的问题,欢迎交流讨论。
面试时,除了能写出代码,更重要的是能解释清楚"为什么这样写"。比如:
- 为什么要用闭包?(保存 timer 状态)
- 为什么要用 apply?(传递 this 和参数)
- 立即执行模式的边界条件是什么?(immediate && !timer)
下一步我想探索的是:如何实现一个带最大等待时间的防抖?这在实际项目中可能更实用。
参考资料
- MDN - setTimeout() - 定时器 API 文档
- Lodash debounce 源码 - 工业级实现参考
- David Corbacho's Blog - Debouncing and Throttling - 可视化解释
- React 官方文档 - useCallback - 在 React 中使用防抖的最佳实践