Notes

React 渲染性能优化:Memo, Context, 与虚拟化

一句话结论:通过 React.memo 及其 hooks 变体 (useMemo, useCallback) 精准控制组件重渲染,优化 Context 的消费粒度,并对长列表实施虚拟化,是解决 React 应用渲染性能问题的三大核心手段。


1. 精准控制重渲染:memo, useMemo, useCallback

不必要的重渲染是 React 性能下降的首要原因。当一个组件的 props 或 state 变化时,它会重渲染。如果父组件重渲染,默认情况下其所有子组件也会重渲染。

React.memo:组件级别的渲染守卫

React.memo 是一个高阶组件 (HOC),它对其包裹的组件进行浅比较 (shallow compare)。只有当 props 发生变化时,组件才会重渲染。

何时使用? 当一个组件经常因为父组件重渲染而重渲染,但其自身的 props 并未改变时。特别适用于渲染成本较高的纯展示组件。

// ExpensiveComponent.js
import React from 'react';

function ExpensiveComponent({ data }) {
  // 假设这里有复杂的计算或渲染逻辑
  console.log('Rendering ExpensiveComponent...');
  return <div>{JSON.stringify(data)}</div>;
}

// 使用 React.memo 包裹
export default React.memo(ExpensiveComponent);

常见误区:过度使用 memo 不要盲目地包裹所有组件。memo 自身的比较过程也有开销。如果一个组件的 props 几乎每次都会变,或者组件本身非常轻量,使用 memo 反而会得不偿失。

useCallback:稳定化函数 props

当你将一个函数作为 prop 传递给一个被 memo 包裹的子组件时,问题就来了。父组件每次重渲染,都会创建一个新的函数实例,导致子组件的 memo 浅比较失败,从而引发不必要的重渲染。

useCallback 会缓存这个函数实例,仅当其依赖项数组中的值发生变化时,才重新创建一个新的函数。

// ParentComponent.js
import { useState, useCallback } from 'react';
import MemoizedChild from './MemoizedChild';

function ParentComponent() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  // 如果不使用 useCallback,每次 Parent 重渲染,handleClick 都是新函数
  const handleClick = useCallback(() => {
    // 仅依赖 count,所以只有 count 变化时,handleClick 才会是新函数
    console.log(`Button clicked! Count is ${count}`);
  }, [count]);

  return (
    <div>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      {/* 当 text 变化导致 Parent 重渲染时,handleClick 保持稳定,MemoizedChild 不会重渲染 */}
      <MemoizedChild onClick={handleClick} />
    </div>
  );
}

useMemo:缓存昂贵的计算结果

useMemo 用于缓存计算结果。它接收一个“创建”函数和依赖项数组。只有当依赖项发生变化时,它才会重新计算。这对于避免在每次渲染时都执行高开销的计算非常有用。

// DataGrid.js
import { useMemo } from 'react';

function DataGrid({ rows }) {
  // 假设这是一个非常耗时的计算
  const visibleRows = useMemo(() => {
    return rows.filter(row => row.isActive).map(row => {
      // ... 更多复杂处理
      return { ...row, formattedDate: new Date(row.date).toLocaleDateString() };
    });
  }, [rows]); // 仅当 rows 数组本身变化时才重新计算

  return (
    <table>
      {visibleRows.map(row => <tr key={row.id}>...</tr>)}
    </table>
  );
}

2. Context 的性能陷阱与优化

React Context 是一个强大的状态共享工具,但也是一个常见的性能瓶颈。当 Context 的值发生变化时,所有消费该 Context 的组件都会重渲染,无论它们是否关心那部分变化的数据。

场景:一个包含用户和主题的全局 Context

// Bad approach
const GlobalContext = React.createContext({
  user: null,
  theme: 'light',
  toggleTheme: () => {},
});

// App.js
function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  
  const value = { user, theme, toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light') };

  return (
    <GlobalContext.Provider value={value}>
      <UserProfile /> {/* 关心 user */}
      <ThemeToggler /> {/* 关心 theme */}
    </GlobalContext.Provider>
  );
}

问题在于,当 toggleTheme 被调用时,theme 变化,GlobalContext.Providervalue 变成一个新对象。这导致 UserProfile 也重渲染,尽管它只关心 user,而 user 并未改变。

优化策略:拆分 Context

将不常变化和常变化的状态,或者不同领域的状态,拆分到不同的 Context 中。

// Good approach
const UserContext = React.createContext(null);
const ThemeContext = React.createContext({ theme: 'light', toggleTheme: () => {} });

// App.js
function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');

  const themeValue = useMemo(() => ({
    theme,
    toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light')
  }), [theme]);

  return (
    <UserContext.Provider value={user}>
      <ThemeContext.Provider value={themeValue}>
        <UserProfile /> {/* 只消费 UserContext */}
        <ThemeToggler /> {/* 只消费 ThemeContext */}
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

现在,当主题变化时,只有 ThemeContext 的消费者 ThemeToggler 会重渲染。UserProfile 则安然无恙。


3. 列表虚拟化 (List Virtualization)

当需要渲染成百上千个条目的列表时,即使每个条目组件都经过了 memo 优化,一次性渲染所有 DOM 节点也会导致严重的性能问题(长任务、高内存占用)。

虚拟化的思想是:只渲染视口内可见的列表项

实现原理

  1. 容器 (Container):一个具有固定高度和 overflow: scroll 的外层 div
  2. 滚动视窗 (Scroller):一个在容器内部、高度等于所有列表项总高度的 div。它的作用是撑开滚动条,模拟完整列表的存在。
  3. 可见项计算:监听容器的滚动事件,根据 scrollTop、容器高度和每个列表项的高度,计算出当前哪些索引的列表项应该在视口内。
  4. 绝对定位渲染:只渲染计算出的可见项,并使用 position: absolutetransform: translateY(...) 将它们精确地放置在滚动视窗的正确位置。

推荐库

手动实现虚拟化很复杂,特别是处理动态高度的列表项。强烈建议使用成熟的社区库:

  • TanStack Virtual (原 react-virtual):轻量、无头 (Headless),提供了核心的虚拟化逻辑,UI 完全自定义。
  • react-windowreact-virtualized 的轻量级继任者,API 简单,非常适合固定高度的列表。
  • react-virtuoso:功能强大,对动态高度、分组、无限加载等场景支持得非常好。

示例 (使用 react-window)

import { FixedSizeList as List } from 'react-window';

const MyBigList = ({ items }) => (
  <List
    height={500} // 容器高度
    itemCount={items.length} // 总项数
    itemSize={50} // 每项的固定高度
    width={300} // 容器宽度
  >
    {({ index, style }) => (
      // style 会包含 position, top, left, width, height
      <div style={style}>
        Row {items[index].name}
      </div>
    )}
  </List>
);

4. 关联阅读

cd ..