滚动页面时卡顿、搜索框每敲一个字就发一次请求、窗口拖动时页面掉帧、按钮被连点导致重复下单……这些前端性能事故的背后,往往都指向同一个根源:高频事件没有做频率控制。节流(Throttle)与防抖(Debounce)是解决这类问题的两把利器,也是前端面试与工程实践中绕不开的核心知识点。本文将从浏览器事件模型、函数执行时机、时间戳与定时器的底层差异讲起,手写生产级实现,剖析 Lodash 源码思路,并结合滚动加载、搜索联想、按钮防重复点击、拖拽、Canvas 绘制等真实场景,系统性地讲透这两个技术。30分钟深度学习,让你的页面真正"丝滑"起来。
为什么需要节流与防抖
浏览器中的很多事件是"高频触发"的:用户每一次滚动、每一次移动鼠标、每一次敲击键盘,都会触发对应的事件回调。如果回调中包含了 DOM 操作、网络请求或复杂计算,页面就会迅速陷入卡顿。理解问题的规模,是优化的第一步。
window.addEventListener('scroll', () => {
// 每次滚动都执行:读取布局属性
const top = document.body.scrollTop;
const h = window.innerHeight;
// 强制同步布局(Layout Thrashing)
document.querySelectorAll('.item').forEach(el => {
el.style.top = top + h + 'px';
});
});
/* 结果:1 秒滚动可能触发 60~100 次回调 */
/* 每次回调都写样式 + 读布局 → 掉帧、卡顿 */
大部分场景只需要其中 1~10 次的执行结果
节流与防抖的本质,就是用一个高阶函数包裹业务回调,在时间维度上限制它的执行次数。它们不改变业务逻辑,只改变"什么时候执行、执行几次"。
防抖(Debounce):等你不闹了再执行
防抖的核心思想是:事件被触发后不立即执行,而是等待一个延迟时间;如果在这段时间内事件又被触发,则重新计时。只有当事件"安静"超过延迟时间后,回调才真正执行一次。
function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
// 关键:每次触发都清掉上一个定时器
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
/* 使用 */
const onSearch = debounce(function (kw) {
console.log('发起请求:', kw);
}, 500);
input.addEventListener('input', e => onSearch(e.target.value));
适合"等用户停下来再处理"的场景
节流(Throttle):按固定节奏执行
节流的核心思想是:在指定的时间间隔内,回调最多执行一次。无论事件触发多频繁,执行频率都被"卡"在一个固定的节奏上,就像给水龙头装上了一个限流阀。
function throttle(fn, wait = 200) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= wait) {
last = now;
fn.apply(this, args);
}
};
}
/* 方案二:定时器实现(延迟执行,保证最后一次) */
function throttleTimer(fn, wait = 200) {
let timer = null;
return function (...args) {
if (timer) return;
timer = setTimeout(() => {
fn.apply(this, args);
timer = null;
}, wait);
};
}
节奏恒定,不因触发频率变化而改变
核心差异:一张表看懂两者
很多人能背出定义,却在实际场景中选错方案。关键要理解两者在"执行时机"上的根本差异。
const log = debounce(() => console.log('防抖'), 300);
const logT = throttle(() => console.log('节流'), 300);
// 假设 1 秒内连续触发 20 次
// 防抖 → 控制台只打印 1 次 "防抖"
// 节流 → 控制台约打印 3~4 次 "节流"
/* 选择口诀 */
/* 只关心"最终结果" → 防抖 */
/* 需要"过程反馈" → 节流 */
生产级实现:带 immediate、cancel 与返回值
基础版实现只够应付面试。真实项目中,我们还需要"立即执行"、"取消"、"获取返回值"等能力。下面给出两个可直接落地的完整实现。
function debounce(fn, delay = 300, immediate = false) {
let timer = null;
let result;
function debounced(...args) {
const context = this;
if (timer) clearTimeout(timer);
if (immediate) {
const callNow = !timer;
timer = setTimeout(() => { timer = null; }, delay);
if (callNow) {
result = fn.apply(context, args);
}
} else {
timer = setTimeout(() => {
fn.apply(context, args);
timer = null;
}, delay);
}
return result;
}
debounced.cancel = function () {
clearTimeout(timer);
timer = null;
};
return debounced;
}
function throttle(fn, wait = 200, options = {}) {
let last = 0;
let timer = null;
const { leading = true, trailing = true } = options;
function throttled(...args) {
const context = this;
const now = Date.now();
if (!last && !leading) last = now;
const remaining = wait - (now - last);
if (remaining <= 0 || remaining > wait) {
if (timer) {
clearTimeout(timer);
timer = null;
}
last = now;
fn.apply(context, args);
} else if (!timer && trailing) {
timer = setTimeout(() => {
last = leading ? Date.now() : 0;
timer = null;
fn.apply(context, args);
}, remaining);
}
}
throttled.cancel = function () {
clearTimeout(timer);
timer = null;
last = 0;
};
return throttled;
}
这四个开关,是"能上生产"的分水岭
实战场景一:搜索联想与输入校验
搜索框是防抖最经典的应用场景。用户输入"javascript"这 10 个字符,如果不做处理会发出 10 次请求,而实际上只需要最后一次结果。
let requestId = 0;
const handleSearch = debounce(async function (keyword) {
if (!keyword.trim()) return;
const currentId = ++requestId;
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
const data = await res.json();
// 只渲染最新一次请求的结果
if (currentId !== requestId) return;
renderSuggestions(data);
} catch (err) {
console.error('搜索失败', err);
}
}, 400);
/* 输入框绑定 */
searchInput.addEventListener('input', e => {
handleSearch(e.target.value);
});
/* 组件卸载 / 页面隐藏时清理 */
window.addEventListener('beforeunload', () => {
handleSearch.cancel();
});
实战场景二:滚动加载与位置上报
滚动监听必须用节流,因为我们需要在滚动过程中"周期性"判断是否触底。如果错用防抖,用户一直滚动就不会触发加载,体验反而更差。
let loading = false;
const handleScroll = throttle(async function () {
if (loading) return;
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
// 距离底部小于 200px 时加载下一页
if (scrollTop + clientHeight >= scrollHeight - 200) {
loading = true;
await loadNextPage();
loading = false;
}
}, 200);
window.addEventListener('scroll', handleScroll, { passive: true });
/* 注意:passive: true 告诉浏览器不会 preventDefault */
/* 浏览器可以立即滚动,不必等待 JS 执行完成 */
实战场景三:按钮防重复点击与表单提交
用户疯狂点击"提交订单",可能造成重复下单。这种场景需要"立即执行版防抖":第一次点击马上生效,冷却期内的点击全部忽略。
function debounceImmediate(fn, delay = 1000) {
let timer = null;
return function (...args) {
if (timer) return; // 冷却中,直接忽略
fn.apply(this, args);
timer = setTimeout(() => {
timer = null;
}, delay);
};
}
/* 应用到提交按钮 */
const submitBtn = document.querySelector('#submit');
submitBtn.addEventListener('click', debounceImmediate(async () => {
submitBtn.disabled = true;
submitBtn.textContent = '提交中…';
try {
await submitOrder();
toast('提交成功');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = '提交';
}
}, 1000));
进阶:用 requestAnimationFrame 做帧级节流
对于与视觉渲染强相关的场景(拖拽、鼠标跟随、Canvas 动画),用 setTimeout 做节流并不理想——它可能与浏览器的重绘节奏错位。此时 requestAnimationFrame 是更优解,它保证回调在下一帧渲染前执行,天然对齐 60fps。
function rafThrottle(fn) {
let ticking = false;
return function (...args) {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
fn.apply(this, args);
ticking = false;
});
};
}
/* 拖拽场景 */
const onDrag = rafThrottle(e => {
box.style.transform =
`translate(${e.clientX}px, ${e.clientY}px)`;
});
document.addEventListener('mousemove', onDrag);
/* 对比:setTimeout 节流 16ms 也可能与重绘错位 */
/* rAF 由浏览器统一调度,与渲染帧严格同步 */
每帧一次,稳定 60fps,不掉帧
框架生态中的节流与防抖
实际项目中,我们通常不需要从零手写。Lodash、Vue、React 生态都提供了成熟的方案,理解它们的差异能帮你做出更合适的选择。
import { debounce, throttle } from 'lodash-es';
const search = debounce(keyword => {
fetchResult(keyword);
}, 300, {
leading: false,
trailing: true,
maxWait: 1000 // 最长等待时间,防止无限延迟
});
search.cancel(); // 取消
search.flush(); // 立即执行
/* 2. Vue 3:自定义指令封装 */
const debounceDirective = {
mounted(el, binding) {
const { value, arg = 300 } = binding;
el._handler = debounce(value, Number(arg));
el.addEventListener('click', el._handler);
},
unmounted(el) {
el.removeEventListener('click', el._handler);
el._handler.cancel(); // 必须清理,防止内存泄漏
}
};
import { useRef, useCallback, useEffect } from 'react';
function useDebounce(fn, delay = 300, deps = []) {
const fnRef = useRef(fn);
useEffect(() => {
fnRef.current = fn;
}, [fn]);
return useCallback(
debounce((...args) => fnRef.current(...args), delay),
deps
);
}
/* 组件中使用 */
function SearchBox() {
const [keyword, setKeyword] = useState('');
const search = useDebounce((val) => {
api.search(val).then(setList);
}, 400, []);
return (
<input
value={keyword}
onChange={e => {
setKeyword(e.target.value);
search(e.target.value);
}}
/>
);
}
支持 maxWait
自动清理
避免闭包陷阱
避坑指南:六个常见错误
节流与防抖看似简单,但实际使用中有大量细节容易踩坑。下面这些错误,几乎每个人都犯过至少一个。
const obj = {
name: '张三',
say: debounce(function () {
console.log(this.name); // undefined
}, 300)
};
/* ✅ 解决:内部使用 fn.apply(this, args) */
/* ❌ 坑 2:每次渲染都创建新的防抖函数 */
/* React 中:onChange={debounce(handle, 300)} → 每次都新建 */
/* ✅ 解决:useMemo / useCallback 缓存实例 */
/* ❌ 坑 3:忘记清理定时器 */
/* 组件卸载后定时器仍执行 → 内存泄漏 / 报错 */
/* ✅ 解决:暴露 cancel 方法,在卸载钩子中调用 */
/* ❌ 坑 4:滚动场景误用防抖 */
/* 用户持续滚动 → 永远不触发加载 */
/* ✅ 解决:滚动用节流,或用 IntersectionObserver */
const bad = debounce(async () => {
const a = await fetchA();
const b = await fetchB(a);
}, 300);
/* 防抖只保证"开始执行"的时机 */
/* 不保证异步任务不并发,仍需竞态处理 */
/* ❌ 坑 6:节流参数过大导致"无响应感" */
/* 拖拽场景用 500ms 节流 → 元素明显迟滞 */
/* ✅ 解决:视觉类场景用 rAF,交互类 16~50ms */
/* 时间参数参考表 */
/* 输入搜索 300 ~ 500 ms */
/* 窗口 resize 200 ~ 300 ms */
/* 滚动监听 100 ~ 200 ms */
/* 拖拽 / 动画 使用 rAF */
/* 按钮防连点 500 ~ 1000 ms */
调试、监控与单元测试
写完不代表做对。我们需要手段去验证"到底执行了几次""延迟是否符合预期",并在长期运行中监控性能表现。
let count = 0;
const tracked = debounce(() => {
count++;
console.log(`执行次数:${count}`);
}, 300);
/* 2. 用 performance 测量真实耗时 */
const measure = throttle(() => {
const start = performance.now();
heavyTask();
const cost = performance.now() - start;
if (cost > 16) {
console.warn(`任务耗时 ${cost.toFixed(2)}ms,可能掉帧`);
}
}, 200);
/* 3. 用 PerformanceObserver 监控长任务 */
const observer = new PerformanceObserver(list => {
list.getEntries().forEach(entry => {
if (entry.duration > 50) {
console.log('长任务:', entry.duration);
}
});
});
observer.observe({ entryTypes: ['longtask'] });
import { debounce } from './utils';
describe('debounce', () => {
jest.useFakeTimers();
test('连续调用只执行一次', () => {
const fn = jest.fn();
const debounced = debounce(fn, 300);
debounced();
debounced();
debounced();
jest.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledTimes(1);
});
test('cancel 后不再执行', () => {
const fn = jest.fn();
const debounced = debounce(fn, 300);
debounced();
debounced.cancel();
jest.advanceTimersByTime(500);
expect(fn).not.toHaveBeenCalled();
});
});
最佳实践与总结
- 先判断场景:只关心最终结果用防抖,需要过程反馈用节流
- 视觉动效用 rAF:拖拽、动画、鼠标跟随优先考虑 requestAnimationFrame
- 暴露 cancel:任何需要清理的场景(组件卸载、路由切换)都必须能取消
- 缓存函数实例:在 React / Vue 中避免每次渲染重新创建防抖函数
- 合理设置时间:输入 300~500ms,滚动 100~200ms,按钮 500~1000ms
- 注意竞态:防抖只控制触发时机,异步请求仍需处理返回顺序
- 优先考虑原生方案:IntersectionObserver 替代滚动监听,ResizeObserver 替代 resize
- 性能监控常态化:用 PerformanceObserver 持续发现长任务,而不是等用户投诉
export { debounce, throttle, rafThrottle };
/* 使用建议速查 */
/* 搜索输入 → debounce(fn, 400) */
/* 按钮防连点 → debounce(fn, 800, true) */
/* 滚动加载 → throttle(fn, 200) */
/* 窗口 resize → debounce(fn, 250) 或 throttle */
/* 拖拽 / 动画 → rafThrottle(fn) */
/* 核心心法 */
/* 不是"让代码少执行",而是"让关键代码在对的时机执行" */
节流与防抖是前端性能优化中最基础、也最容易被低估的技术。它们的价值不只是"少执行几次",更在于让页面的响应节奏与用户的感知节奏保持一致——该即时反馈的绝不延迟,该合并处理的绝不重复。掌握它们背后的时间控制思想,你就能在滚动、拖拽、输入、动画等各类高频场景中游刃有余,写出真正流畅的 Web 应用。