ToolkitX
知识库工具箱

性能优化

useMemo, useCallback, React.memo, lazy

25min·高级

01. React 为什么会重渲染

React 的重渲染机制:当 state 或 props 变了,组件就要重新执行得到新的虚拟 DOM,然后跟旧的虚拟 DOM 比较(reconciliation),如果有变化就更新真实 DOM。 触发重渲染的三种情况: 1. 组件自己的 state 变了——这是正常的。 2. 父组件重渲染了——子组件哪怕 props 没变也会跟着重渲染。这是 React 的默认行为,不是 bug。 3. 消费的 Context value 变了——所有用到这个 Context 的组件都要重渲染。 性能优化的思路不是阻止渲染(渲染本身很快),而是避免不必要的渲染导致的大规模虚拟 DOM 比较。大部分情况 React 已经够快了——先测量再优化。 React DevTools 的 Profiler 能帮你看到哪个组件渲染了、花了多少毫秒。React.memo、useMemo、useCallback 是针对特定场景的工具,不是到处都撒。
jsx
// 父组件渲染 → 子组件一定渲染(默认)
function Parent() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>+</button>
      <Child name="Alice" />  {/* 虽然 props 没变,Parent 渲染 Child 也渲染 */}
    </div>
  );
}

02. React.memo——跳过 props 没变的渲染

React.memo 是一个高阶组件——它包裹你的组件,在 props 没变时跳过渲染。React 会浅比较(shallow compare)前后的 props 对象。 但 React.memo 不是免费的——每次都要比较 props 也是有开销的。只在以下场景用: 1. 组件渲染开销大(如渲染了几百个子元素) 2. props 经常不变但父组件频繁渲染 3. 传给 memo 组件的 props 本身需要稳定(用 useMemo/useCallback 包裹) React.memo(MyComponent, arePropsEqual?)——可以传第二个参数自定义比较函数。返回 true 表示 props 一样不需要渲染。 注意:给 memo 组件传内联对象或函数(如 { name: 'Alice' } 或 () => {}),每次都是新引用,memo 比较失效。
jsx
const ExpensiveList = React.memo(function ExpensiveList({ items }) {
  return (
    <ul>
      {items.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
});

// 父组件里用 useMemo 确保 items 引用稳定
function Parent() {
  const [count, setCount] = useState(0);
  const items = useMemo(() => [{ id: 1, name: 'Alice' }], []);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <ExpensiveList items={items} />
    </div>
  );
}
传内联对象给 memo 组件等于白 memo——每次渲染对象是新引用,浅比较永远不等。用 useMemo 稳定引用。

03. useMemo 与 useCallback——缓存值和函数

useMemo——缓存计算结果。如果依赖项没变,每次渲染都返回同一个缓存值。适合计算成本高的操作(大数组过滤排序、复杂数学运算)。 useCallback——缓存函数引用。它是 useMemo(fn, deps) 的语法糖——useCallback(fn, deps) 等于 useMemo(() => fn, deps)。 实际使用时要注意: 1. 不要什么值都 useMemo——简单计算(加减乘除、字符串拼接)不需要缓存。useMemo 本身有开销。 2. useCallback 的主要用途是给 memo 子组件传稳定的回调函数。如果没有配合 React.memo,useCallback 基本没收益。 3. 依赖项一定要写全——ESLint 的 react-hooks/exhaustive-deps 规则会提醒你。漏写 deps 可能导致 bug。
jsx
// useMemo——缓存昂贵的过滤结果
function FilteredList({ users, filterText }) {
  const filteredUsers = useMemo(() => {
    return users.filter(u =>
      u.name.toLowerCase().includes(filterText.toLowerCase())
    );
  }, [users, filterText]);

  return <ul>{filteredUsers.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

// useCallback——稳定回调给 memo 子组件
function Parent() {
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []); // 依赖为空,函数引用永远不变

  return <MemoButton onClick={handleClick} />;
}

const MemoButton = React.memo(function Button({ onClick }) {
  console.log('Button rendered');
  return <button onClick={onClick}>Click</button>;
});
useMemo 和 useCallback 不要无脑加。只有当确实有性能问题时才用。React 官方也建议「先写再测再优化」。

04. 懒加载——React.lazy 与 Suspense

React.lazy 让组件按需加载——只在第一次被渲染时才下载对应的 JS 文件。这能大幅减少初始加载的包体积。 用法:const MyComponent = React.lazy(() => import('./MyComponent'))。配合 Suspense 在加载中时显示 loading 界面。 常见做法:路由级别的代码分割——每个 Route 的 element 都是一个 lazy 加载的组件。首页只加载首页的代码,用户点了其他页面才加载对应代码。 Suspense 的 fallback 可以是任何 React 组件。加载快的话 flashing 问题——用 startTransition 让旧页面多留一会,用户体验更好。 另外可以用 loadable-components(第三方库)做更高级的懒加载(服务端渲染友好、支持预加载)。
jsx
import { lazy, Suspense } from 'react';

// 懒加载路由组件
const Home = lazy(() => import('./pages/Home'));
const UserDetail = lazy(() => import('./pages/UserDetail'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <Suspense fallback={<div className="loading">Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/users/:id" element={<UserDetail />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}
lazy 只能用在组件渲染的结果上。如果有多个兄弟组件,可以各自有自己的 Suspense,各自独立 loading。

05. 列表渲染的性能——key 与虚拟滚动

列表渲染是 React 里最容易出性能问题的地方: key 的重要性——React 用 key 来追踪列表里的每一项。key 应该是一个稳定唯一的 ID(如数据库 ID),千万不要用数组索引(index)。用 index 的话,列表顺序变了会导致 React 错误地匹配元素,轻则渲染不对,重则输入框内容错位。 虚拟滚动——如果列表有几千上万项,不能一次性渲染全部 DOM。用虚拟滚动库(react-window、react-virtuoso)只渲染可视区域的内容。页面看着是一个长长的列表,实际 DOM 里只有几十个元素。 列表里的 inline function——map 里面 onClick={() => handleClick(item.id)} 每次渲染创建新函数。虽然不是大问题,但如果列表很大且跟 React.memo 配合,考虑把 item.id 存到 DOM 的 data 属性里,统一用事件委托处理。
jsx
// key 用 index 的坑
// 不要在列表顺序可变时用 index
{items.map((item, index) => (
  <li key={index}>{item.name}</li>  // 顺序变了 key 对不上!
))}

// 正确的做法
{items.map(item => (
  <li key={item.id}>{item.name}</li>
))}

// 虚拟滚动
import { FixedSizeList } from 'react-window';

function BigList({ items }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={50}
    >
      {({ index, style }) => (
        <div style={style}>{items[index].name}</div>
      )}
    </FixedSizeList>
  );
}
react-window 把几千 DOM 节点缩减到几十。不仅渲染快,内存占用也小。但虚拟滚动的每个 item 都应该是定高的(或用动态高度模式)。

知识测验

1/5正确 0

父组件渲染,子组件一定会跟着渲染吗?

下一节

测试

下一节