Javascript篇 | 防抖与节流
前言
防抖(debounce)和节流(throttle)是前端面试的”送分题”——但前提是你真的搞懂了。
面试中你可能遇到这些场景:
- “请手写一个防抖函数” —— 这是最基础的考法
- “说说防抖和节流的区别?分别在什么场景下使用?” —— 考你对概念的理解
- “如何实现一个支持 leading 和 trailing 选项的节流?” —— 考你对细节的掌握
这两个概念在实际开发中也非常实用。搜索框输入、窗口 resize、滚动加载、按钮防重复点击——到处都能用到。搞懂原理和实现,不仅面试加分,日常开发也更得心应手。
诊断自测
在开始之前,试着回答以下问题:
1. 防抖和节流的核心区别是什么?用一句话概括。
2. 以下代码有什么问题?
input.addEventListener('input', debounce(function(e) {
fetchSearchResults(e.target.value);
}));
3. 以下场景分别应该用防抖还是节流?
- 搜索框输入实时搜索
- 页面滚动时计算位置
- 按钮提交表单防重复点击
点击查看答案
第1题: 防抖是”等你停下来再执行”,节流是”每隔一段时间执行一次”。防抖重置计时器,节流不重置。
第2题: 缺少 delay 参数。应该是 debounce(fn, 300) 这样。另外,如果组件卸载或 DOM 移除,需要注意清理(cancel),否则可能产生副作用。
第3题:
- 搜索框输入 → 防抖(用户停止输入后再请求,避免每个字符都发请求)
- 页面滚动计算位置 → 节流(需要持续响应但降低频率)
- 按钮防重复点击 → 防抖(leading 模式)或节流(只要在一定时间内只执行一次就行)
如果都答对了,说明你基础不错!继续阅读可以查漏补缺。
从问题出发:为什么需要防抖和节流?
先来看一个真实场景。假设有一个搜索框,你希望用户输入时实时展示搜索建议:
const input = document.getElementById('search');
input.addEventListener('input', function(e) {
// 每次输入都发请求
fetchSearchResults(e.target.value);
});
用户输入 “javascript” 这 10 个字符,就会触发 10 次请求!这不仅浪费带宽,还可能因为请求返回顺序不一致导致结果错乱。
再看另一个场景——窗口 resize:
window.addEventListener('resize', function() {
// 调整布局,涉及大量 DOM 操作
recalculateLayout();
});
拖动窗口边缘时,resize 事件一秒能触发几十次,每次都重新计算布局,页面直接卡死。
这就是防抖和节流要解决的问题:控制高频事件的执行频率。
防抖(Debounce)
核心思想
事件触发后等待一段时间再执行,如果在等待期间又触发了事件,就重新计时。
打个比方:你坐电梯,门准备关了(开始计时),这时又有人进来(重新计时),等最后一个人进来后,过了几秒没人了,门才关上(执行)。
基础实现
function debounce(fn, delay) {
let timer = null;
return function(...args) {
// 每次触发都清除之前的定时器
clearTimeout(timer);
// 重新开始计时
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// 使用
const search = debounce(function(keyword) {
console.log('搜索:', keyword);
}, 300);
input.addEventListener('input', function(e) {
search(e.target.value);
});
执行流程
假设 delay = 300ms,用户快速输入 “abc”:
时间线:0ms 50ms 100ms 400ms
事件: 输入a 输入b 输入c
定时器:设置→ 清除→ 清除→ 执行fn('abc')
设置 设置 设置
只有最后一次输入(c)300ms 后没有新输入,才会真正执行。
进阶:支持 leading 模式
有时候我们希望第一次触发就立即执行,后续的触发才走防抖逻辑。比如按钮点击——第一次点击应该立刻响应:
function debounce(fn, delay, options = {}) {
let timer = null;
const { leading = false } = options;
return function(...args) {
const isFirstCall = timer === null;
clearTimeout(timer);
if (leading && isFirstCall) {
fn.apply(this, args);
}
timer = setTimeout(() => {
if (!leading) {
fn.apply(this, args);
}
timer = null; // 重置,允许下一轮 leading 执行
}, delay);
};
}
// 第一次点击立即执行,后续快速点击被忽略
const handleClick = debounce(submitForm, 1000, { leading: true });
进阶:支持 cancel
function debounce(fn, delay, options = {}) {
let timer = null;
const { leading = false } = options;
function debounced(...args) {
const isFirstCall = timer === null;
clearTimeout(timer);
if (leading && isFirstCall) {
fn.apply(this, args);
}
timer = setTimeout(() => {
if (!leading) {
fn.apply(this, args);
}
timer = null;
}, delay);
}
debounced.cancel = function() {
clearTimeout(timer);
timer = null;
};
return debounced;
}
// React 组件中使用
useEffect(() => {
const debouncedSearch = debounce(fetchResults, 300);
input.addEventListener('input', debouncedSearch);
return () => {
debouncedSearch.cancel(); // 组件卸载时取消
input.removeEventListener('input', debouncedSearch);
};
}, []);
节流(Throttle)
核心思想
在一段时间内只执行一次,无论事件触发了多少次。
打个比方:射击游戏里枪有射速限制,你可以疯狂点鼠标,但子弹每隔固定时间才会发射一颗。
实现方式一:时间戳版
function throttle(fn, interval) {
let lastTime = 0;
return function(...args) {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(this, args);
}
};
}
特点: 第一次触发立即执行,最后一次触发如果不满足间隔不会执行。
时间线: 0ms 100ms 200ms 300ms 400ms 500ms 600ms
事件: 触发 触发 触发 触发 触发 触发 触发
interval=300ms
执行: ✅ ❌ ❌ ✅ ❌ ❌ ✅
实现方式二:定时器版
function throttle(fn, interval) {
let timer = null;
return function(...args) {
if (timer) return; // 有定时器在等待,直接忽略
timer = setTimeout(() => {
fn.apply(this, args);
timer = null;
}, interval);
};
}
特点: 第一次触发不会立即执行(等 interval 后才执行),最后一次触发会执行。
时间线: 0ms 100ms 200ms 300ms 400ms 500ms 600ms 900ms
事件: 触发 触发 触发 触发 触发 触发 触发
interval=300ms
执行: ✅ ✅ ✅
两种方式对比
| 特性 | 时间戳版 | 定时器版 |
|---|---|---|
| 首次触发 | 立即执行 | 延迟执行 |
| 最后一次 | 可能不执行 | 会执行 |
| 实现方式 | 对比时间差 | 定时器锁 |
进阶:leading + trailing 结合版
实际项目中(比如 lodash),我们往往希望首次触发立即执行,最后一次触发也能执行。这就需要结合两种方式:
function throttle(fn, interval, options = {}) {
let timer = null;
let lastTime = 0;
const { leading = true, trailing = true } = options;
return function(...args) {
const now = Date.now();
// 如果不需要 leading,且是第一次调用,把 lastTime 设为当前时间
if (!leading && lastTime === 0) {
lastTime = now;
}
const remaining = interval - (now - lastTime);
if (remaining <= 0) {
// 时间到了,立即执行
if (timer) {
clearTimeout(timer);
timer = null;
}
lastTime = now;
fn.apply(this, args);
} else if (!timer && trailing) {
// 设置定时器,确保最后一次触发能执行
timer = setTimeout(() => {
lastTime = leading ? Date.now() : 0;
timer = null;
fn.apply(this, args);
}, remaining);
}
};
}
防抖 vs 节流:一图看懂
假设用户在 0-1000ms 内疯狂触发事件(每 50ms 一次),delay/interval = 300ms:
原始事件: ||||||||||||||||||||
防抖: | (只在最后一次 300ms 后执行)
节流: | | | | (每 300ms 执行一次)
选择原则:
- 只关心最终状态 → 防抖(搜索框、表单验证)
- 需要持续响应但降低频率 → 节流(滚动、拖拽、动画)
实际应用场景
搜索框(防抖)
function SearchInput() {
const [keyword, setKeyword] = useState('');
const [results, setResults] = useState([]);
// 用 useCallback + useRef 避免闭包问题
const fetchRef = useRef();
fetchRef.current = async (value) => {
const data = await fetch(`/api/search?q=${value}`);
setResults(await data.json());
};
const debouncedSearch = useMemo(
() => debounce((value) => fetchRef.current(value), 300),
[]
);
useEffect(() => {
return () => debouncedSearch.cancel();
}, []);
const handleChange = (e) => {
const value = e.target.value;
setKeyword(value);
debouncedSearch(value);
};
return (
<div>
<input value={keyword} onChange={handleChange} />
<ul>
{results.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
</div>
);
}
滚动加载(节流)
function InfiniteList() {
const handleScroll = useMemo(
() => throttle(() => {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
if (scrollTop + clientHeight >= scrollHeight - 100) {
loadMore();
}
}, 200),
[]
);
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return <div>{/* 列表内容 */}</div>;
}
窗口 resize(防抖)
useEffect(() => {
const handleResize = debounce(() => {
setDimensions({
width: window.innerWidth,
height: window.innerHeight
});
}, 250);
window.addEventListener('resize', handleResize);
return () => {
handleResize.cancel();
window.removeEventListener('resize', handleResize);
};
}, []);
按钮防重复提交(防抖 leading)
const handleSubmit = debounce(
async () => {
await submitForm(formData);
showSuccess();
},
2000,
{ leading: true }
);
面试中的代码考察要点
面试官手写代码时,重点关注以下几点:
- this 指向:用
fn.apply(this, args)而不是直接fn(args) - 参数传递:用
...args收集剩余参数 - clearTimeout:防抖要记得清除上一个定时器
- cancel 方法:加分项,展示工程思维
- leading/trailing 选项:进阶要求,展示对细节的理解
最简版手写模板(背下来):
// 防抖 —— 最后一次触发后 delay ms 执行
function debounce(fn, delay) {
let timer = null;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// 节流 —— 每 interval ms 最多执行一次
function throttle(fn, interval) {
let lastTime = 0;
return function(...args) {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(this, args);
}
};
}
常见误区
误区1:防抖和节流效果差不多,用哪个都行
虽然两者都是”降低执行频率”,但行为差异很大:
// 场景:用户连续滚动 2 秒
// 防抖(delay=300ms):只在停止滚动 300ms 后执行 1 次
// 用户滚动过程中看到的是"卡住"的效果
// 节流(interval=300ms):滚动过程中每 300ms 执行 1 次
// 用户滚动过程中能看到持续更新的效果
滚动场景用防抖会导致用户在滚动过程中看不到任何变化,体验很差。持续性交互用节流,终态型交互用防抖。
误区2:在 React 组件中直接使用 debounce
// ❌ 错误:每次渲染都创建新的 debounce 函数,等于没有防抖
function SearchInput() {
const handleChange = debounce((value) => {
fetchResults(value);
}, 300);
return <input onChange={e => handleChange(e.target.value)} />;
}
// ✅ 正确:用 useMemo 或 useRef 保持引用稳定
function SearchInput() {
const handleChange = useMemo(
() => debounce((value) => fetchResults(value), 300),
[]
);
return <input onChange={e => handleChange(e.target.value)} />;
}
误区3:节流的时间戳版和定时器版效果完全一样
前面已经对比过了,时间戳版首次立即执行但最后一次可能丢失,定时器版首次延迟但最后一次会执行。面试时一定要说清楚区别。
误区4:debounce 的 delay 越小越好
delay 设置太小等于没有防抖。太大又会让用户觉得”不响应”。一般经验值:
- 搜索框:200-500ms
- resize/scroll:100-300ms
- 按钮点击:500-2000ms
小结
防抖和节流是前端性能优化的基本功,面试中手写代码是标配考点。
核心要点
- 防抖:最后一次触发后等待 delay 再执行,期间有新触发就重新计时
- 节流:固定间隔内最多执行一次,不受触发频率影响
- 选择原则:关心最终状态用防抖,需要持续响应用节流
- leading/trailing:首次是否立即执行、最后一次是否保证执行
- React 中使用:注意用 useMemo/useRef 保持引用稳定,组件卸载时 cancel
本章思维导图
- 防抖(Debounce)
- 核心:最后一次触发后延迟执行
- 实现:clearTimeout + setTimeout
- leading 模式:首次立即执行
- cancel 方法:清理定时器
- 场景:搜索框、表单验证、resize
- 节流(Throttle)
- 核心:固定间隔执行一次
- 时间戳版:首次立即,末次可能丢
- 定时器版:首次延迟,末次保证
- 结合版:leading + trailing
- 场景:滚动、拖拽、动画
- 选择原则
- 终态型交互 → 防抖
- 持续性交互 → 节流
- React 中的使用
- useMemo 保持引用稳定
- useRef 保存最新回调
- useEffect cleanup cancel
练习挑战
挑战一:基础(⭐)
手写一个基础的 debounce 函数,确保 this 指向和参数传递正确。然后用它实现:每次 input 输入后 500ms 打印输入值。
答案与解析
function debounce(fn, delay) {
let timer = null;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
const input = document.getElementById('myInput');
const logValue = debounce(function(e) {
console.log('输入值:', e.target.value);
}, 500);
input.addEventListener('input', logValue);
关键点:
fn.apply(this, args)确保 this 指向正确(在事件监听器中 this 指向 DOM 元素)...args确保事件对象等参数被正确传递- 每次调用先
clearTimeout,保证只有最后一次生效
挑战二:进阶(⭐⭐)
实现一个支持 leading 和 trailing 选项的 debounce:
leading: true— 第一次触发立即执行trailing: true— 最后一次触发 delay 后执行- 默认
leading: false, trailing: true
const fn1 = debounce(handler, 300, { leading: true, trailing: false });
// 第一次立即执行,后续快速触发被忽略
const fn2 = debounce(handler, 300, { leading: true, trailing: true });
// 第一次立即执行,如果在 delay 内又触发了,最后一次也会在 delay 后执行
答案与解析
function debounce(fn, delay, options = {}) {
let timer = null;
const { leading = false, trailing = true } = options;
return function(...args) {
const isFirstCall = timer === null;
clearTimeout(timer);
if (leading && isFirstCall) {
fn.apply(this, args);
}
timer = setTimeout(() => {
if (trailing && !(leading && isFirstCall)) {
// 避免 leading + trailing 时第一次执行两遍
fn.apply(this, args);
}
timer = null;
}, delay);
};
}
注意这里有个细节:当 leading: true, trailing: true 时,第一次触发如果只触发了一次(没有后续触发),不应该在 delay 后再执行一次。但如果后续又触发了,最后一次应该在 delay 后执行。完善版本需要额外记录 lastArgs,有兴趣可以参考 lodash 的实现。
挑战三:综合(⭐⭐⭐)
实现一个 useThrottle Hook:
function useThrottle(value, interval) {
// 返回节流后的值
// value 变化时,throttledValue 最多每 interval ms 更新一次
}
// 使用示例
function SearchResults({ keyword }) {
const throttledKeyword = useThrottle(keyword, 500);
useEffect(() => {
if (throttledKeyword) {
fetchResults(throttledKeyword);
}
}, [throttledKeyword]);
}
答案与解析
function useThrottle(value, interval) {
const [throttledValue, setThrottledValue] = useState(value);
const lastUpdated = useRef(Date.now());
useEffect(() => {
const now = Date.now();
const elapsed = now - lastUpdated.current;
if (elapsed >= interval) {
// 距离上次更新超过 interval,立即更新
setThrottledValue(value);
lastUpdated.current = now;
} else {
// 设置定时器,在剩余时间后更新
const timer = setTimeout(() => {
setThrottledValue(value);
lastUpdated.current = Date.now();
}, interval - elapsed);
return () => clearTimeout(timer);
}
}, [value, interval]);
return throttledValue;
}
这个 Hook 结合了时间戳版和定时器版的优点:如果距离上次更新已经超过 interval,立即更新;否则设置定时器等到 interval 到期再更新。这样既有首次立即响应,最后一次变化也不会丢失。
自我检测
读完本章后,确认你能回答以下问题:
- 能用一句话解释防抖和节流的区别
- 能手写基础版 debounce(3 分钟内完成)
- 能手写基础版 throttle(时间戳版或定时器版)
- 能说出时间戳版和定时器版节流的区别(首次/末次行为)
- 能解释 leading 和 trailing 选项的含义
- 能说出 3 个以上的防抖/节流实际应用场景
- 知道在 React 中使用 debounce/throttle 需要注意引用稳定性
- 能实现 cancel 方法
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90