性能优化最佳实践

第五编 · 第十章:性能优化最佳实践的深入分析


性能优化的正确思路

很多人在 React 里做性能优化,第一反应是"用 memo"或者"用 useMemo"。但性能优化不是找到一个银弹就能解决所有问题。

React 的性能问题通常来自于:不必要的重新渲染,以及不必要的工作量。

不必要的重新渲染指的是:组件的状态或 props 没有变化,但组件还是重新执行了一遍。不必要的工作量指的是:计算了一些本不需要计算的结果,或者执行了一些本不需要执行的副作用。

优化 React 性能,本质上就是减少这两类浪费。


重新渲染是怎么发生的

在 React 里,组件会在以下情况下重新渲染:

  1. 组件自己的 state 变化了
  2. 组件接收的 props 变化了
  3. 组件的父组件重新渲染了(即使 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.memoButton 依然会每次都重新渲染,因为 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 只有在 itemsfilter 变化时才会重新计算,否则返回缓存的结果。

什么时候该用这些 Hook?

useCallbackuseMemo 不是免费的。创建和维护这些缓存本身有开销。只有当:

  1. 函数或计算结果被传递给 React.memo 包裹的子组件
  2. 函数或计算结果被用在 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.lazySuspense 允许你懒加载组件:

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 变化,还是父组件重新渲染?

测量 -> 定位 -> 优化 -> 再测量,这是性能优化的正确循环。


常见的过度优化

过度使用 useMemouseCallback。这两个 Hook 不是免费的:创建和管理缓存有开销,过度使用反而会让代码变慢。

过早优化。不要在写代码的时候就开始想着性能优化。先让功能正常工作,然后 Profiling,再针对真实瓶颈优化。

忽略真正的瓶颈。有时候性能问题不在 React 渲染里,而在网络请求、数据库查询、或者图片资源上。优化 React 渲染对这类问题没有帮助。


这一章想说的

React 性能优化的核心是减少不必要的重新渲染和不必要的工作量。React.memo 可以跳过子组件的不必要渲染,但前提是 props 引用要稳定。useCallbackuseMemo 是稳定引用的工具,但只在必要场景下才值得用。

列表性能问题是 React 最常见的性能问题来源,稳定 key 和虚拟化是有效的手段。代码分割和懒加载可以减少 bundle 体积,加快首屏加载。

性能优化之前要先测量,Profiling 才是找到瓶颈的正确方式。不要凭感觉优化,不要过度优化,不要优化错误的地方。