性能优化的正确思路
很多人在 React 里做性能优化,第一反应是"用 memo"或者"用 useMemo"。但性能优化不是找到一个银弹就能解决所有问题。
React 的性能问题通常来自于:不必要的重新渲染,以及不必要的工作量。
不必要的重新渲染指的是:组件的状态或 props 没有变化,但组件还是重新执行了一遍。不必要的工作量指的是:计算了一些本不需要计算的结果,或者执行了一些本不需要执行的副作用。
优化 React 性能,本质上就是减少这两类浪费。
重新渲染是怎么发生的
在 React 里,组件会在以下情况下重新渲染:
- 组件自己的 state 变化了
- 组件接收的 props 变化了
- 组件的父组件重新渲染了(即使 props 没变)
第三点经常被忽视。当父组件重新渲染时,它的所有子组件默认都会重新渲染,不管 props 有没有变化。这是一个常见的性能陷阱。
React.memo 的作用和边界
React.memo 可以让子组件在 props 没变化时跳过重新渲染:
const Button = React.memo(function Button({ onClick, children }) {
return <button onClick={onClick}>{children}</button>;
});
现在 Button 只会在它的 props 变化时重新渲染。但这个优化有一个重要的前提:传递给 Button 的 props 必须是稳定的引用。
如果父组件每次渲染都创建一个新的 onClick 函数:
function Parent() {
const [count, setCount] = useState(0);
return (
// 每次 Parent 渲染,onClick 都是新的函数引用!
<Button onClick={() => setCount(count + 1)}>Click</Button>
);
}
即使用了 React.memo,Button 依然会每次都重新渲染,因为 onClick 引用每次都是新的。这就是"引用稳定"的问题。
useCallback 和 useMemo 的正确用法
useCallback 可以稳定函数引用:
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount(count + 1);
}, [count]);
return <Button onClick={handleClick}>Click</Button>;
}
现在 handleClick 只有在 count 变化时才会是新的引用。Button 不会每次都重新渲染了。
useMemo 的作用类似,用于稳定计算结果的引用:
function ExpensiveList({ items, filter }) {
const filteredItems = useMemo(() => {
return items.filter(item => item.name.includes(filter));
}, [items, filter]);
return <List data={filteredItems} />;
}
filteredItems 只有在 items 或 filter 变化时才会重新计算,否则返回缓存的结果。
什么时候该用这些 Hook?
useCallback 和 useMemo 不是免费的。创建和维护这些缓存本身有开销。只有当:
- 函数或计算结果被传递给
React.memo包裹的子组件 - 函数或计算结果被用在
useEffect的依赖数组里
这时候才有必要使用这些 Hook。其他场景下,过度使用只会增加维护成本和轻微的性能开销。
列表性能优化
列表是最常见的性能问题来源。当列表很长时,每个列表项的渲染成本会被放大。
给列表项加上稳定的 key:
// 好:使用稳定的唯一 ID
{items.map(item => <ListItem key={item.id} item={item} />)}
// 差:使用数组索引
{items.map((item, index) => <ListItem key={index} item={item} />)}
对于超长列表,考虑虚拟化:
React Window 或 react-virtualized 这样的库只渲染可见区域的列表项:
import { FixedSizeList } from 'react-window';
function VirtualizedList({ items }) {
return (
<FixedSizeList height={400} itemCount={items.length} itemSize={50}>
({ index, style }) => (
<div style={style}>{items[index].name}</div>
)
</FixedSizeList>
);
}
10000 个列表项中,如果屏幕只能显示 10 个,虚拟化只渲染 10 个 DOM 节点,而不是 10000 个。
代码分割和懒加载
不只是运行时性能,打包体积也是性能的一部分。
React.lazy 和 Suspense 允许你懒加载组件:
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={<Loading />}>
<HeavyComponent />
</Suspense>
);
}
HeavyComponent 不会在首屏加载时被打包进去,只有在渲染到这行代码时才会加载。这减少了初始 bundle 的体积,加快了首屏加载速度。
Profiling 和量化优化
优化之前要先测量,不要凭感觉。React DevTools Profiler 可以帮你找到性能瓶颈。
在 React DevTools 里打开 Profiler,记录一次用户交互(比如点击按钮),然后查看火焰图:
- 哪几个组件渲染时间最长?
- 哪个组件渲染了不应该渲染的次数?
- 重新渲染的原因是 props 变化、state 变化,还是父组件重新渲染?
测量 -> 定位 -> 优化 -> 再测量,这是性能优化的正确循环。
常见的过度优化
过度使用 useMemo 和 useCallback。这两个 Hook 不是免费的:创建和管理缓存有开销,过度使用反而会让代码变慢。
过早优化。不要在写代码的时候就开始想着性能优化。先让功能正常工作,然后 Profiling,再针对真实瓶颈优化。
忽略真正的瓶颈。有时候性能问题不在 React 渲染里,而在网络请求、数据库查询、或者图片资源上。优化 React 渲染对这类问题没有帮助。
这一章想说的
React 性能优化的核心是减少不必要的重新渲染和不必要的工作量。React.memo 可以跳过子组件的不必要渲染,但前提是 props 引用要稳定。useCallback 和 useMemo 是稳定引用的工具,但只在必要场景下才值得用。
列表性能问题是 React 最常见的性能问题来源,稳定 key 和虚拟化是有效的手段。代码分割和懒加载可以减少 bundle 体积,加快首屏加载。
性能优化之前要先测量,Profiling 才是找到瓶颈的正确方式。不要凭感觉优化,不要过度优化,不要优化错误的地方。