各位同学,大家好!
欢迎来到今天这场关于“如何在 React 中拯救性能”的深度讲座。我是你们的讲师,一名在代码世界里摸爬滚打多年的资深工程师。今天我们不聊那些虚头巴脑的架构模式,也不聊怎么写高内聚低耦合,我们聊点更实在的——如何让你的 UI 像丝般顺滑,而不是像老牛拉破车。
假设你现在正在开发一个电商 App,或者一个复杂的后台管理系统。你有一个包含 1000 个项目的列表,或者一个巨大的图片画廊。当用户疯狂滚动鼠标滚轮,或者疯狂敲击键盘输入搜索词时,你的浏览器开始发烫,页面开始掉帧,甚至出现“卡顿”。
这时候,你是不是想大喊一声:“这破浏览器,是不是该扔了?”
别急,其实不是浏览器的问题,也不是你代码写得烂,而是你们之间缺乏沟通。高频 UI 交互是性能杀手,而防抖和节流,以及更高级的 requestAnimationFrame (rAF),就是我们与浏览器沟通的“外交辞令”。
今天,我们就来一场深度剖析,把这三个家伙掰开了、揉碎了,揉进 React 的 Hook 里,让它们成为你代码里的“瑞士军刀”。
第一部分:浏览器渲染的“老鼠赛跑”
在讲防抖和节流之前,我们必须先搞清楚一个核心概念:浏览器到底是怎么工作的?
你可能会说:“它运行 JS,然后画出来。”
太天真了。浏览器的工作其实是一场老鼠赛跑。
想象一下,屏幕的刷新率是 60Hz,也就是每秒钟刷新 60 次。这意味着,每 16.6 毫秒(1000ms / 60),浏览器就要给屏幕画一帧新的画面。
这 16.6 毫秒里发生了什么?这是一个非常紧张的时间窗口:
- JS 执行:你的 React 组件在疯狂计算,状态在更新,DOM 在重绘。
- 样式计算:浏览器计算元素该长什么样。
- 布局:浏览器计算元素在屏幕上的位置(重排)。
- 绘制:浏览器把颜色填进格子。
- 合成:浏览器把所有图层合成为最终的图像。
如果 JS 执行时间超过了 16.6 毫秒,这帧就没法按时画出来。浏览器怎么办?它只能等下一帧。这就导致了掉帧。
高频事件(如 mousemove, scroll, input)的特点是什么?触发频率极高。
如果你在 mousemove 事件里写了一段复杂的逻辑,比如计算鼠标位置、更新状态、甚至重新渲染整个组件树,那么在短短几毫秒内,你可能会触发几十次甚至上百次这样的逻辑。结果就是,JS 跑死了,浏览器连画画的空隙都没有,只能眼睁睁看着 FPS(帧率)从 60 掉到 10,最后变成 1。
所以,我们的目标很明确:在保证用户体验的前提下,减少逻辑执行的频率,让 JS 能在 16.6ms 内完成,给浏览器喘息的机会。
第二部分:防抖 —— “电梯里的人”
这时候,防抖登场了。
防抖的核心思想是:“别急,等大家都停下来了再说。”
想象一下,你站在电梯里。电梯门要关了,这时候又有一个人跑过来按了开门键。电梯不会马上开,而是会等这个人进去,然后等他按关门键,电梯才会关。如果这人又进来了,再等。
在代码里,防抖是这样的:
如果你在 1 秒内连续触发了 10 次 input 事件,防抖函数会把你这 10 次触发“吞掉”,或者说是“延迟”处理。它会等待,直到这 1 秒内没有任何新的触发,它才会真正执行你想要的那一次逻辑。
适用场景:
- 搜索框输入:用户每敲一个字,你就去请求后端 API,这太浪费了。防抖一下,用户打完字停顿 500ms,再请求。
- 窗口大小调整:用户拖拽浏览器窗口边缘时,不需要每像素都重新计算布局,等停下来了再算。
React 中的防抖实现
在 React 中,我们不能直接在组件外部定义防抖函数,因为我们需要用到组件的状态。所以,我们需要写一个自定义的 Hook:useDebounce。
这里有个坑:闭包陷阱。
如果你在 useEffect 里直接用 setTimeout,当组件重新渲染时,定时器里的回调函数会捕获旧的 value。这会导致你的防抖失效,或者逻辑跑偏。
解决方法是用 useRef 来保存定时器的 ID,以及保存最新的回调函数。
让我们来写代码:
import { useState, useEffect, useRef } from 'react';
// 这是一个通用的防抖 Hook
const useDebounce = <T extends (...args: any[]) => any>(
fn: T,
delay: number
) => {
const timerRef = useRef<NodeJS.Timeout | null>(null);
return (...args: Parameters<T>) => {
// 1. 如果之前有定时器,先把它清除掉
// 这就是“防抖”的核心:新的触发会覆盖旧的等待
if (timerRef.current) {
clearTimeout(timerRef.current);
}
// 2. 设置一个新的定时器
timerRef.current = setTimeout(() => {
fn(...args);
}, delay);
};
};
// 组件使用示例
const SearchComponent = () => {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
// 注意:这里我们使用 useDebounce 包装了 setResults
// 只有当输入停止 500ms 后,才会真正执行 setResults
const debouncedSetResults = useDebounce((newQuery: string) => {
console.log('正在搜索...', newQuery);
// 模拟 API 请求
setResults([`Result for ${newQuery}`, `More results for ${newQuery}`]);
}, 500);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
// 触发防抖后的更新
debouncedSetResults(value);
};
return (
<div>
<input type="text" onChange={handleChange} placeholder="Type something..." />
<ul>
{results.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
};
看,这个 useDebounce Hook 很简单,但它解决了核心问题。它把“高频触发”转化为了“低频执行”。
第三部分:节流 —— “自动门”
防抖适合“停止后触发”,那节流适合什么呢?
节流的核心思想是:“别急,但我得让你知道我在干活。”
想象一下商场的自动门。它不会因为你站得近就频繁开关,也不会等你完全停下来才开。它的规则是:每隔 1 秒,我检查一下,如果有人,我就开一次。不管你这 1 秒内来了一百个人,还是只来了一半个人,我都只处理一次。
适用场景:
- 滚动事件:用户疯狂滚动,我们不需要每像素都计算,我们只需要每 100ms 计算一次当前滚动到了哪里。
- 鼠标移动:虽然 rAF 更好,但节流也是一种简单的优化。
React 中的节流实现
实现节流比防抖稍微复杂一点,因为我们需要记录“上一次执行的时间”。
import { useEffect, useRef } from 'react';
const useThrottle = <T extends (...args: any[]) => any>(
fn: T,
delay: number
) => {
const lastRunRef = useRef(0);
const timerRef = useRef<NodeJS.Timeout | null>(null);
return (...args: Parameters<T>) => {
const now = Date.now();
const lastRun = lastRunRef.current;
// 如果距离上次执行时间小于 delay,说明还在冷却期
if (now - lastRun < delay) {
// 这里有个选择:是忽略,还是强制执行?
// 简单的节流通常是忽略
return;
}
// 更新最后执行时间
lastRunRef.current = now;
fn(...args);
};
};
// 组件使用示例
const ScrollListener = () => {
const [scrollTop, setScrollTop] = useState(0);
// 节流后的函数,每 200ms 最多执行一次
const throttledSetScrollTop = useThrottle((val: number) => {
setScrollTop(val);
}, 200);
useEffect(() => {
const handleScroll = () => {
// 即使滚动很快,这里每 200ms 最多触发一次
throttledSetScrollTop(window.scrollY);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [throttledSetScrollTop]); // 依赖项
return <div>Current Scroll: {scrollTop}</div>;
};
节流的缺点:
你会发现,如果你在 200ms 内滚动了很多次,中间的那几次触发是被“丢弃”的。用户可能会感觉“我怎么没反应?”或者“系统好像卡住了”。节流牺牲了部分响应性来换取性能。
第四部分:requestAnimationFrame —— 真正的“完美主义”
这时候,你可能会想:“防抖太慢,节流太生硬,有没有一个既能保证高性能,又能保证响应性的方案?”
有,那就是 requestAnimationFrame (rAF)。
rAF 是浏览器专门为动画和 UI 交互优化的 API。它的工作原理是:告诉浏览器,“我有一帧要画,你准备好,等我信号。”
rAF 的工作机制非常智能。它会:
- 检查屏幕刷新率:如果你在 60Hz 的屏幕上,它就会每 16.6ms 唤醒你一次。
- 等待空闲:如果浏览器此时正在处理其他繁重的任务(比如 JS 计算),rAF 会等到浏览器空闲时才执行你的回调。
- 帧同步:它确保你的代码在每一帧的“合成”阶段之前执行,这样画出来的东西就是和屏幕刷新同步的。
为什么 rAF 优于 setTimeout 和节流?
- setTimeout:它的时间是固定的(比如 16ms)。但如果 JS 执行花了 20ms,那么你的回调就会被推迟到下一帧,甚至下一帧,导致画面卡顿。
- rAF:它不关心时间,它只关心“帧”。它保证你的逻辑在当前帧完成,或者下一帧的最早空闲时间完成。
React 中的 rAF 实现
写一个 useRaf Hook 需要一点技巧。我们需要处理“启动”和“停止”的逻辑。
import { useEffect, useRef } from 'react';
const useRaf = (callback: () => void) => {
const animationFrameRef = useRef<number>();
useEffect(() => {
// 启动 rAF
const tick = () => {
callback();
// 请求下一帧
animationFrameRef.current = requestAnimationFrame(tick);
};
animationFrameRef.current = requestAnimationFrame(tick);
// 清理函数:组件卸载时取消请求
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [callback]); // 依赖项:如果 callback 变了,重启循环
};
// 组件使用示例
const ParallaxEffect = () => {
const [offset, setOffset] = useState(0);
useRaf(() => {
// 在这里,我们每一帧都会执行
// 比如我们可以根据鼠标位置计算偏移量
// 这里为了演示,我们用一个简单的计数器模拟高频计算
setOffset(prev => (prev + 1) % 100);
// 实际场景中,这里会是:
// const x = window.scrollY * 0.5;
// setOffset(x);
});
return <div style={{ transform: `translateX(${offset}px)` }}>I am moving!</div>;
};
等等,上面的代码有点太简单了。如果我们想在 mousemove 时使用 rAF 怎么办?上面的代码会无限循环,每帧都执行。这会导致性能问题,因为 mousemove 触发频率远高于 60fps(可能每秒几百次)。
所以,我们需要一个结合了节流和 rAF 的版本。这叫 “基于时间的 rAF 节流”。
import { useEffect, useRef } from 'react';
const useRafThrottle = (callback: () => void, delay: number = 1000 / 60) => {
const lastTimeRef = useRef(0);
const rafIdRef = useRef<number>();
useEffect(() => {
const loop = (time: number) => {
if (time - lastTimeRef.current >= delay) {
callback();
lastTimeRef.current = time;
}
rafIdRef.current = requestAnimationFrame(loop);
};
rafIdRef.current = requestAnimationFrame(loop);
return () => {
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current);
}
};
}, [callback, delay]);
};
这个 useRafThrottle 就是神器。它利用 rAF 的性能优势,但通过时间戳控制了执行频率。它比 setTimeout 更平滑,比普通的 throttle 更精确。
第五部分:实战演练 —— 打造“丝般顺滑”的无限滚动组件
光说不练假把式。我们来做一个实际的场景:无限滚动。
需求:一个包含 10000 张图片的列表。用户滚动到底部时,自动加载更多。如果逻辑写不好,滚动会卡成幻灯片。
错误示范(普通防抖/节流):
如果我们只用 throttle 监听 scroll,然后每 100ms 加载一次数据,这会瞬间发出去几百个请求,把后端搞崩。
正确示范(rAF + IntersectionObserver):
我们结合 requestAnimationFrame 和 IntersectionObserver(虽然 IntersectionObserver 是原生 API,但它也是为了性能而生)。
- IntersectionObserver:负责检测“底部加载元素”是否进入了视口。这是最高效的检测方式。
- rAF:负责在数据加载过程中,平滑地更新 UI。
import { useState, useEffect, useRef } from 'react';
const InfiniteScrollList = () => {
const [items, setItems] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const loadMoreTriggerRef = useRef<HTMLDivElement>(null);
// 模拟数据加载
const loadMoreData = async () => {
setLoading(true);
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500));
setItems(prev => [...prev, ...Array(20).fill(0).map((_, i) => prev.length + i)]);
setLoading(false);
};
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
// 当触发元素进入视口
if (entries[0].isIntersecting && !loading) {
loadMoreData();
}
},
{ threshold: 0.1 }
);
if (loadMoreTriggerRef.current) {
observer.observe(loadMoreTriggerRef.current);
}
return () => {
if (loadMoreTriggerRef.current) {
observer.unobserve(loadMoreTriggerRef.current);
}
};
}, [loading]);
// 渲染列表
return (
<div>
<ul>
{items.map((item, index) => (
<li key={index}>Item {item}</li>
))}
</ul>
<div ref={loadMoreTriggerRef} style={{ height: '20px', background: loading ? 'red' : 'green' }}>
{loading ? 'Loading...' : 'End of list'}
</div>
</div>
);
};
这个例子展示了如何利用浏览器原生的能力来优化性能。但在更复杂的场景,比如拖拽排序,rAF 是必不可少的。
第六部分:高级话题 —— 拖拽与 rAF 的绝配
假设我们要做一个拖拽功能,拖拽一个方块,方块要跟随鼠标移动。如果我们在 mousemove 里直接更新 left 和 top 样式,你会发现方块有“延迟感”,或者在某些低端机上卡顿。
为什么?因为 mousemove 的触发频率是不稳定的,而且频繁修改 DOM 样式会触发浏览器的重排。
优化方案:
- 使用
transform: translate(x, y):这比修改top/left性能好得多,因为它只触发重绘,不触发重排。 - 使用
useRaf:确保每一帧只更新一次位置。
代码示例:
const DraggableItem = () => {
const [position, setPosition] = useState({ x: 0, y: 0 });
const isDraggingRef = useRef(false);
const rafIdRef = useRef<number>();
const handleMouseDown = (e: React.MouseEvent) => {
isDraggingRef.current = true;
// 开始拖拽时,取消之前的 RAF
if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current);
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDraggingRef.current) return;
// 使用 rAF 来节流更新
rafIdRef.current = requestAnimationFrame(() => {
// 计算新位置(相对于父容器)
const newX = e.clientX;
const newY = e.clientY;
setPosition({ x: newX, y: newY });
});
};
const handleMouseUp = () => {
isDraggingRef.current = false;
// 停止拖拽后,清除 RAF
if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current);
};
if (isDraggingRef.current) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current);
};
}, []);
return (
<div style={{ position: 'absolute', left: position.x, top: position.y, width: 50, height: 50, background: 'blue' }}>
Drag Me
</div>
);
};
在这个例子中,我们使用了 requestAnimationFrame 来确保拖拽的平滑度。即使 mousemove 触发了一万次,我们的更新逻辑也只会在浏览器准备好渲染下一帧的时候执行一次。这就像给数据流加了一个“过滤器”,只留下最精华的部分。
第七部分:React Hooks 的“深水区”
在前面实现这些 Hook 的时候,我们其实绕过了一些坑。现在,让我们深入一点,聊聊 React 的闭包和依赖。
1. 依赖数组陷阱
在 useEffect 中,我们的回调函数依赖了 callback。
useEffect(() => {
// ...
}, [callback]);
这意味着,每次 callback 变化(比如你在组件里重写了函数,或者函数引用变了),useEffect 都会重新挂载监听器,并启动新的 requestAnimationFrame 循环,旧的循环会被取消。
2. 闭包导致的状态陈旧
在 useRafThrottle 中,我们直接使用了 callback。
const loop = (time: number) => {
// 这里的 callback 是闭包捕获的
if (time - lastTimeRef.current >= delay) {
callback();
lastTimeRef.current = time;
}
// ...
};
如果 callback 里面依赖了组件的 state(比如 const handleClick = () => console.log(count)),那么在 loop 函数里,count 可能永远是旧的值!
如何修复?
我们需要使用 useRef 来保存最新的 callback。
const useRafThrottle = (callback: () => void, delay: number = 1000 / 60) => {
const lastTimeRef = useRef(0);
const rafIdRef = useRef<number>();
// 使用 ref 保存最新的 callback
const callbackRef = useRef(callback);
callbackRef.current = callback;
useEffect(() => {
const loop = (time: number) => {
if (time - lastTimeRef.current >= delay) {
// 使用 ref.current 调用,确保拿到最新值
callbackRef.current();
lastTimeRef.current = time;
}
rafIdRef.current = requestAnimationFrame(loop);
};
rafIdRef.current = requestAnimationFrame(loop);
return () => {
if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current);
};
}, [delay]);
};
这招叫 “Ref 替代 State”,是 React Hooks 性能优化的核心技巧之一。记住它!
第八部分:性能优化的“奥义”与总结
好了,同学们,今天的讲座即将接近尾声。我们回顾一下今天的内容:
- 浏览器渲染周期:16.6ms 的生死时速,重排与重绘的代价。
- 防抖:适合“停止后触发”,如搜索框。用
setTimeout实现。 - 节流:适合“固定频率触发”,如滚动。用时间戳实现。
- requestAnimationFrame:动画和 UI 交互的终极武器。它帧同步,不浪费,比
setTimeout更平滑。
为什么 rAF 是最佳选择?
因为它是浏览器原生的 API,它知道你的屏幕刷新率。它不会像 setTimeout(16ms) 那样产生抖动,也不会像普通节流那样打断用户的操作流。
React 中的最佳实践:
- 对于搜索输入,使用
useDebounce。 - 对于滚动监听,使用
useRafThrottle。 - 对于拖拽和动画,必须使用
useRaf。 - 永远记得在组件卸载时清理定时器和 RAF,否则你的组件会变成内存泄漏的僵尸。
最后的忠告:
不要为了优化而优化。如果你的组件只有几个元素,性能开销微乎其微,那就别折腾了。过早的优化是万恶之源。但是,如果你的组件有几百个元素,或者正在处理 3D 图形、大数据可视化,那么请务必使用这些技术。
记住,优秀的代码不仅仅是能跑通,更是要优雅、高效、流畅。当你看到用户在操作你的应用时,流畅得像在飞,而你的浏览器风扇却很安静,那就是你作为程序员的最高荣耀。
好了,今天的讲座就到这里。下课!记得把你们的 useDebounce 和 useRaf Hook 整理好,以后写项目的时候,直接复制粘贴,让性能飞起来!