前言
在前端开发中,scroll、input、resize、mousemove 等高频事件每秒可以触发数十甚至上百次回调。如果每次触发都执行 DOM 查询、网络请求或复杂计算,页面会迅速变得卡顿。防抖(Debounce)和节流(Throttle)是最经典的优化手段,而 requestAnimationFrame 则是面向视觉更新的终极方案。
本文将从零实现完整的 TypeScript 版本,深入对比各方案的适用场景,并提供可直接用于 React 项目的自定义 Hook。
问题:高频事件的性能灾难
一个简单的实验就能说明问题:
typescript
let count = 0;
window.addEventListener('scroll', () => {
count++;
console.log(`scroll fired: ${count}`);
// 假设这里有一段耗时 5ms 的 DOM 操作
heavyDomOperation();
});
在快速滚动一次页面(约 2 秒)的过程中,scroll 事件可以触发 60-120 次。如果每次回调需要 5ms,总耗时就达到 300-600ms,远超浏览器每帧 16.67ms 的预算,结果就是明显的掉帧和卡顿。
makefile
时间轴(每格 16.67ms = 1帧):
无优化: |E|E|E|E|E|E|E|E|E|E|E|E| <- 每帧都执行,大量重复计算
防抖: |.|.|.|.|.|.|.|.|.|.|.|E| <- 只在停止后执行一次
节流: |E|.|.|E|.|.|E|.|.|E|.|E| <- 固定间隔执行
rAF: |E|.|E|.|E|.|E|.|E|.|E|.| <- 每帧最多执行一次
E = 执行回调 . = 跳过
防抖(Debounce)深入剖析
核心思想
防抖的本质是 "等用户停下来再执行"。在事件持续触发期间不断重置定时器,直到事件停止触发超过指定时间后,才真正执行回调。
从零实现:基础版本
typescript
function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timerId: ReturnType<typeof setTimeout> | null = null;
return function (this: any, ...args: Parameters<T>) {
if (timerId !== null) {
clearTimeout(timerId);
}
timerId = setTimeout(() => {
fn.apply(this, args);
timerId = null;
}, delay);
};
}
// 使用示例
const handleSearch = debounce((query: string) => {
fetch(`/api/search?q=${encodeURIComponent(query)}`);
}, 300);
input.addEventListener('input', (e) => {
handleSearch((e.target as HTMLInputElement).value);
});
Leading vs Trailing Edge
防抖有两种触发模式,适用于不同场景:
less
事件触发: |A|B|C|D|·|·|·|E|F|·|·|·|
trailing: |·|·|·|·|·|·|D|·|·|·|·|F| <- 默认,停止后执行最后一次
leading: |A|·|·|·|·|·|·|E|·|·|·|·| <- 立即执行第一次,后续忽略
both: |A|·|·|·|·|·|D|E|·|·|·|F| <- 首次立即 + 停止后补最后一次
完整实现:支持 cancel / flush / maxWait / leading
typescript
interface DebounceOptions {
leading?: boolean;
trailing?: boolean;
maxWait?: number;
}
interface DebouncedFunction<T extends (...args: any[]) => any> {
(...args: Parameters<T>): void;
cancel: () => void;
flush: () => void;
pending: () => boolean;
}
function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number,
options: DebounceOptions = {}
): DebouncedFunction<T> {
const { leading = false, trailing = true, maxWait } = options;
let timerId: ReturnType<typeof setTimeout> | null = null;
let maxTimerId: ReturnType<typeof setTimeout> | null = null;
let lastArgs: Parameters<T> | null = null;
let lastThis: any = null;
let lastCallTime: number | undefined;
let lastInvokeTime = 0;
function invoke() {
const args = lastArgs;
const thisArg = lastThis;
lastArgs = null;
lastThis = null;
lastInvokeTime = Date.now();
if (args) {
fn.apply(thisArg, args);
}
}
function startTimer() {
timerId = setTimeout(() => {
timerId = null;
if (trailing && lastArgs) {
invoke();
}
clearMaxTimer();
}, delay);
}
function clearMaxTimer() {
if (maxTimerId !== null) {
clearTimeout(maxTimerId);
maxTimerId = null;
}
}
function startMaxTimer() {
if (maxWait !== undefined && maxTimerId === null) {
maxTimerId = setTimeout(() => {
maxTimerId = null;
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
}
if (lastArgs) {
invoke();
}
}, maxWait);
}
}
const debounced = function (this: any, ...args: Parameters<T>) {
lastArgs = args;
lastThis = this;
lastCallTime = Date.now();
const isFirstCall = timerId === null;
if (timerId !== null) {
clearTimeout(timerId);
}
if (leading && isFirstCall) {
invoke();
// 仍然启动定时器以跟踪后续调用
timerId = setTimeout(() => {
timerId = null;
clearMaxTimer();
}, delay);
} else {
startTimer();
}
if (isFirstCall) {
startMaxTimer();
}
} as DebouncedFunction<T>;
debounced.cancel = () => {
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
}
clearMaxTimer();
lastArgs = null;
lastThis = null;
lastCallTime = undefined;
};
debounced.flush = () => {
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
}
clearMaxTimer();
if (lastArgs) {
invoke();
}
};
debounced.pending = () => {
return timerId !== null;
};
return debounced;
}
maxWait 的作用
maxWait 解决了一个关键问题:如果用户一直不停地输入,trailing 模式的防抖可能永远不会触发。maxWait 保证即使事件持续触发,也会在指定时间内至少执行一次。
ini
无 maxWait: |A|B|C|D|E|F|G|H|I|J|·|·|X| <- 用户持续输入,一直等到停止
maxWait=1s: |A|B|C|D|E|X|F|G|H|I|J|X|·|·|X| <- 最多等 1 秒就强制执行
典型使用场景
typescript
// 1. 搜索输入 - 用户停止输入后才请求
const searchInput = document.getElementById('search') as HTMLInputElement;
const handleSearch = debounce(async (query: string) => {
const response = await fetch(`/api/search?q=${query}`);
const results = await response.json();
renderResults(results);
}, 300);
searchInput.addEventListener('input', (e) => {
handleSearch((e.target as HTMLInputElement).value);
});
// 2. 窗口 resize - 停止拖拽后重新计算布局
const handleResize = debounce(() => {
recalculateLayout();
repositionElements();
}, 250);
window.addEventListener('resize', handleResize);
// 3. 自动保存 - 停止编辑 2 秒后保存,但最多 10 秒必须保存一次
const autoSave = debounce(
(content: string) => { saveDraft(content); },
2000,
{ maxWait: 10000 }
);
节流(Throttle)深入剖析
核心思想
节流的本质是 "固定频率执行"。无论事件触发多频繁,都保证在每个时间窗口内最多执行一次。
从零实现:基础版本
typescript
function throttle<T extends (...args: any[]) => any>(
fn: T,
interval: number
): (...args: Parameters<T>) => void {
let lastTime = 0;
let timerId: ReturnType<typeof setTimeout> | null = null;
return function (this: any, ...args: Parameters<T>) {
const now = Date.now();
const remaining = interval - (now - lastTime);
if (remaining <= 0) {
// 已超过间隔,立即执行
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
}
lastTime = now;
fn.apply(this, args);
} else if (timerId === null) {
// 设置尾部调用,确保最后一次触发也能执行
timerId = setTimeout(() => {
lastTime = Date.now();
timerId = null;
fn.apply(this, args);
}, remaining);
}
};
}
Leading vs Trailing Edge
less
事件触发: |A|B|C|D|E|F|G|H|I|J|
间隔 = 3格
leading: |A|·|·|D|·|·|G|·|·|J| <- 每个窗口开始时执行
trailing: |·|·|C|·|·|F|·|·|I|·|J| <- 每个窗口结束时执行
both: |A|·|C|D|·|F|G|·|I|J| <- 开始和结束都执行
完整实现:支持 leading / trailing
typescript
interface ThrottleOptions {
leading?: boolean;
trailing?: boolean;
}
interface ThrottledFunction<T extends (...args: any[]) => any> {
(...args: Parameters<T>): void;
cancel: () => void;
}
function throttle<T extends (...args: any[]) => any>(
fn: T,
interval: number,
options: ThrottleOptions = {}
): ThrottledFunction<T> {
const { leading = true, trailing = true } = options;
let lastTime = 0;
let timerId: ReturnType<typeof setTimeout> | null = null;
let lastArgs: Parameters<T> | null = null;
let lastThis: any = null;
const throttled = function (this: any, ...args: Parameters<T>) {
const now = Date.now();
if (!leading && lastTime === 0) {
lastTime = now;
}
const remaining = interval - (now - lastTime);
lastArgs = args;
lastThis = this;
if (remaining <= 0 || remaining > interval) {
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
}
lastTime = now;
fn.apply(lastThis, lastArgs);
lastArgs = null;
lastThis = null;
} else if (timerId === null && trailing) {
timerId = setTimeout(() => {
lastTime = leading ? Date.now() : 0;
timerId = null;
if (lastArgs) {
fn.apply(lastThis, lastArgs);
lastArgs = null;
lastThis = null;
}
}, remaining);
}
} as ThrottledFunction<T>;
throttled.cancel = () => {
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
}
lastTime = 0;
lastArgs = null;
lastThis = null;
};
return throttled;
}
典型使用场景
typescript
// 1. 滚动位置追踪 - 滚动过程中持续更新但不过于频繁
const handleScroll = throttle(() => {
const scrollY = window.scrollY;
updateProgressBar(scrollY);
checkLazyLoadImages(scrollY);
}, 100);
window.addEventListener('scroll', handleScroll);
// 2. 拖拽处理 - 保证拖拽流畅的同时限制计算频率
const handleDrag = throttle((e: MouseEvent) => {
updateElementPosition(e.clientX, e.clientY);
checkDropZones(e.clientX, e.clientY);
}, 16); // ~60fps
document.addEventListener('mousemove', handleDrag);
// 3. 按钮防重复点击 - 第一次点击立即执行,后续忽略
const handleSubmit = throttle(
() => { submitForm(); },
2000,
{ leading: true, trailing: false }
);
requestAnimationFrame:面向视觉更新的终极方案
为什么 rAF 优于 setTimeout(fn, 16)
很多人认为 setTimeout(fn, 16) 就能模拟 60fps 节流,但实际上两者有本质区别:
lua
setTimeout(fn, 16) requestAnimationFrame
+-----------------------------------------+------------------------+
| 精度 | 受最小延迟限制(4ms),且可能 | 与显示器刷新率精确同步 |
| | 被其他定时器挤占 | |
+-----------------------------------------+------------------------+
| 后台标签页 | 继续执行,浪费资源 | 自动暂停,节省资源 |
+-----------------------------------------+------------------------+
| 时机 | 可能在帧中间执行,导致 | 保证在下一帧绘制前执行, |
| | 部分渲染或布局抖动 | 与浏览器渲染流水线协调 |
+-----------------------------------------+------------------------+
| 高刷屏 | 固定 16ms,在 144Hz 屏幕 | 自动适应 6.9ms(144Hz) |
| | 上浪费刷新机会 | 充分利用每一帧 |
+-----------------------------------------+------------------------+
rAF 滚动处理模式
typescript
function createRafHandler<T extends (...args: any[]) => any>(
fn: T
): (...args: Parameters<T>) => void {
let rafId: number | null = null;
let latestArgs: Parameters<T> | null = null;
return function (this: any, ...args: Parameters<T>) {
latestArgs = args;
if (rafId === null) {
rafId = requestAnimationFrame(() => {
rafId = null;
if (latestArgs) {
fn.apply(this, latestArgs);
latestArgs = null;
}
});
}
};
}
// 使用示例
const onScroll = createRafHandler(() => {
const scrollTop = document.documentElement.scrollTop;
// 更新固定导航栏的样式
navbar.classList.toggle('shrink', scrollTop > 100);
// 视差滚动效果
heroSection.style.transform = `translateY(${scrollTop * 0.3}px)`;
// 进度条
const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
progressBar.style.width = `${(scrollTop / maxScroll) * 100}%`;
});
window.addEventListener('scroll', onScroll, { passive: true });
结合状态标志防止冗余帧
在复杂场景中,可以进一步用"脏标记"优化,只在数据实际变化时请求渲染:
typescript
class RafScheduler {
private rafId: number | null = null;
private isDirty = false;
private renderFn: () => void;
constructor(renderFn: () => void) {
this.renderFn = renderFn;
}
markDirty(): void {
this.isDirty = true;
this.scheduleFrame();
}
private scheduleFrame(): void {
if (this.rafId !== null) return;
this.rafId = requestAnimationFrame(() => {
this.rafId = null;
if (this.isDirty) {
this.isDirty = false;
this.renderFn();
}
});
}
dispose(): void {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
}
// 使用示例:多个事件源共享一个渲染调度器
const state = { scrollY: 0, mouseX: 0, mouseY: 0 };
const scheduler = new RafScheduler(() => {
// 这个函数每帧最多执行一次,无论有多少事件触发
updateVisualization(state);
});
window.addEventListener('scroll', () => {
state.scrollY = window.scrollY;
scheduler.markDirty();
}, { passive: true });
document.addEventListener('mousemove', (e) => {
state.mouseX = e.clientX;
state.mouseY = e.clientY;
scheduler.markDirty();
}, { passive: true });
React Hooks 实现
useDebounce
typescript
import { useCallback, useEffect, useRef } from 'react';
function useDebounce<T extends (...args: any[]) => any>(
callback: T,
delay: number,
deps: React.DependencyList = []
): DebouncedFunction<T> {
const callbackRef = useRef(callback);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastArgsRef = useRef<Parameters<T> | null>(null);
// 始终使用最新的回调,避免闭包陷阱
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// 组件卸载时清理
useEffect(() => {
return () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
};
}, []);
const debounced = useCallback((...args: Parameters<T>) => {
lastArgsRef.current = args;
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
timerRef.current = null;
if (lastArgsRef.current) {
callbackRef.current(...lastArgsRef.current);
}
}, delay);
}, [delay, ...deps]) as DebouncedFunction<T>;
debounced.cancel = () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
debounced.flush = () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
if (lastArgsRef.current) {
callbackRef.current(...lastArgsRef.current);
}
}
};
debounced.pending = () => timerRef.current !== null;
return debounced;
}
// 使用示例
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<string[]>([]);
const search = useDebounce(async (q: string) => {
const res = await fetch(`/api/search?q=${q}`);
setResults(await res.json());
}, 300);
return (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
search(e.target.value);
}}
/>
);
}
useThrottle
typescript
function useThrottle<T extends (...args: any[]) => any>(
callback: T,
interval: number,
deps: React.DependencyList = []
): ThrottledFunction<T> {
const callbackRef = useRef(callback);
const lastTimeRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastArgsRef = useRef<Parameters<T> | null>(null);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
return () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
};
}, []);
const throttled = useCallback((...args: Parameters<T>) => {
const now = Date.now();
const remaining = interval - (now - lastTimeRef.current);
lastArgsRef.current = args;
if (remaining <= 0) {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
lastTimeRef.current = now;
callbackRef.current(...args);
} else if (timerRef.current === null) {
timerRef.current = setTimeout(() => {
lastTimeRef.current = Date.now();
timerRef.current = null;
if (lastArgsRef.current) {
callbackRef.current(...lastArgsRef.current);
}
}, remaining);
}
}, [interval, ...deps]) as ThrottledFunction<T>;
throttled.cancel = () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
lastTimeRef.current = 0;
};
return throttled;
}
useRafCallback
typescript
function useRafCallback<T extends (...args: any[]) => any>(
callback: T
): [(...args: Parameters<T>) => void, () => void] {
const callbackRef = useRef(callback);
const rafIdRef = useRef<number | null>(null);
const latestArgsRef = useRef<Parameters<T> | null>(null);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// 返回 cancel 函数,方便外部控制
const cancel = useCallback(() => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
}, []);
useEffect(() => cancel, [cancel]); // 卸载时清理
const rafCallback = useCallback((...args: Parameters<T>) => {
latestArgsRef.current = args;
if (rafIdRef.current === null) {
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null;
if (latestArgsRef.current) {
callbackRef.current(...latestArgsRef.current);
}
});
}
}, []);
return [rafCallback, cancel];
}
// 使用示例
function ParallaxComponent() {
const [offset, setOffset] = useState(0);
const [handleScroll, cancelScroll] = useRafCallback(() => {
setOffset(window.scrollY * 0.5);
});
useEffect(() => {
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
cancelScroll();
};
}, [handleScroll, cancelScroll]);
return <div style={{ transform: `translateY(${offset}px)` }} />;
}
性能对比
以下是在一次 3 秒快速滚动(浏览器刷新率 60Hz)中的实测数据对比:
scss
+-------------------+----------+-----------+-------------------+
| 方案 | 回调执行次数 | 平均帧耗时 | 适用场景 |
+-------------------+----------+-----------+-------------------+
| 无优化 | 180 次 | 12.3ms | 不推荐 |
| debounce(200ms) | 1 次 | 0.07ms | 搜索输入、表单校验 |
| throttle(100ms) | 30 次 | 2.1ms | 滚动追踪、拖拽 |
| throttle(16ms) | 120 次 | 8.2ms | 接近逐帧但不精确 |
| rAF | 60 次 | 1.8ms | 视觉更新、动画 |
+-------------------+----------+-----------+-------------------+
关键观察:
- 无优化 执行 180 次,远超实际需要的帧数,且许多计算结果在渲染前就被覆盖,属于纯粹的浪费。
- debounce 将执行次数降到 1 次,适合"只需要最终结果"的场景,但在滚动过程中没有任何视觉反馈。
- throttle(100ms) 是通用的折中方案,但对于视觉更新来说间隔偏大,可能出现不够流畅的感觉。
- rAF 精确绑定到浏览器的渲染节奏,60 次执行对应 60 帧渲染,每次计算都不浪费。
Passive Event Listeners
为什么 { passive: true } 对滚动性能至关重要
浏览器在处理触摸和滚动事件时面临一个困境:事件监听器可能调用 event.preventDefault() 来阻止默认的滚动行为。因此浏览器必须 等待 JavaScript 执行完毕 才能确定是否需要滚动页面。
rust
无 passive:
用户触摸 -> 浏览器等待JS -> JS执行完毕 -> 浏览器判断是否滚动 -> 滚动
|<--- 延迟 --->|
有 passive:
用户触摸 -> 浏览器立即滚动 (并行执行JS)
|<- 无延迟 ->|
正确使用方式
typescript
// 推荐:明确声明不会阻止默认行为
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('touchmove', handleTouch, { passive: true });
window.addEventListener('wheel', handleWheel, { passive: true });
// 注意:如果确实需要阻止滚动(如自定义滚动容器),不能用 passive
// 此时应使用 { passive: false },并承担性能代价
customScrollArea.addEventListener('wheel', (e) => {
e.preventDefault(); // passive: true 时调用会被忽略并在控制台报警
customScrollLogic(e);
}, { passive: false });
兼容性检测
typescript
function supportsPassive(): boolean {
let supported = false;
try {
const opts = Object.defineProperty({}, 'passive', {
get() {
supported = true;
return true;
},
});
window.addEventListener('testPassive', null as any, opts);
window.removeEventListener('testPassive', null as any, opts);
} catch {
// passive 不支持
}
return supported;
}
const passiveOption = supportsPassive() ? { passive: true } : false;
window.addEventListener('scroll', handleScroll, passiveOption);
决策树:何时使用哪种方案
面对高频事件优化,可以按照以下逻辑选择方案:
scss
事件需要优化吗?
|
是否涉及视觉更新?
/ \
是 否
| |
需要每帧都更新? 需要最终结果还是过程?
/ \ / \
是 否 最终结果 过程
| | | |
使用 rAF throttle debounce throttle
| (100ms) (200-500ms) (100-300ms)
| | | |
+passive 视情况加 搜索输入 实时统计
视差/动画 passive 表单校验 位置上报
进度条 resize 数据采集
各方案的速查对比:
diff
+-----------+----------+-----------+------------+-------------+
| 维度 | debounce | throttle | rAF | 无优化 |
+-----------+----------+-----------+------------+-------------+
| 执行频率 | 最低 | 中等 | 与帧率同步 | 与事件同步 |
| 首次响应 | 延迟 | 可立即 | 下一帧 | 立即 |
| 最后一次 | 保证执行 | 可配置 | 保证执行 | 保证执行 |
| 视觉流畅度 | 差 | 中等 | 最优 | 取决于回调耗时 |
| 后台标签页 | 继续执行 | 继续执行 | 自动暂停 | 继续执行 |
| 适合场景 | 等停止 | 匀速采样 | 视觉更新 | 轻量回调 |
+-----------+----------+-----------+------------+-------------+
总结
防抖、节流和 requestAnimationFrame 并不是互相替代的关系,而是针对不同场景的互补方案:
-
防抖 适合"等用户做完再处理"的场景:搜索输入、表单校验、窗口 resize 后的重排。
maxWait选项可以防止长时间不触发的问题。 -
节流 适合"过程中需要持续反馈但不用每次都响应"的场景:滚动位置上报、拖拽处理、实时数据采集。leading 和 trailing 选项可以精确控制触发时机。
-
requestAnimationFrame 是所有视觉更新的最佳选择:它与浏览器渲染流水线精确同步,自动适应不同刷新率,在后台标签页中自动暂停。配合
{ passive: true }使用效果更佳。 -
Passive event listeners 是滚动和触摸场景的必要优化,让浏览器无需等待 JavaScript 即可开始滚动。
在实际项目中,这些方案经常组合使用。例如用 rAF 处理滚动动画的同时,用防抖处理搜索输入,用节流上报用户行为数据。选择的关键在于理解每种方案的本质:防抖管"何时结束",节流管"多久一次",rAF 管"下一帧做什么"。
如果觉得有帮助,欢迎点赞收藏关注,后续会继续分享前端性能优化相关的实践。