React 性能优化最佳实践

中等 🟡React 生态
10 个标签
预计阅读时间:56 分钟
React性能优化渲染内存并发特性代码分割虚拟列表React CompilerProfilerWeb Worker

React 性能优化最佳实践

React 应用的性能直接影响用户体验,性能优化是前端开发中不可忽视的重要环节。React 提供了多种优化手段,从渲染优化、状态管理到代码分割,合理运用这些技术可以显著提升应用的响应速度和流畅度。

本文会从"为什么会卡"这个根本问题出发,逐步展开 React 的渲染机制、各类优化 API 的原理与正确用法、React 18 引入的并发特性、代码分割与虚拟列表等工程手段,并配合大量可运行代码、真实场景案例与优化前后的性能对比数据,帮助你建立一套系统化的性能优化方法论。

先理解:React 为什么会卡

在谈优化之前,必须先理解性能问题的来源。可以把浏览器每一帧的工作想象成一条流水线:JavaScript 执行 → 样式计算 → 布局(Layout)→ 绘制(Paint)→ 合成(Composite)。浏览器希望每秒完成 60 帧,也就是每帧只有大约 16.6 毫秒的预算。一旦某一帧里 JavaScript 执行时间过长(比如一次渲染算了 50 毫秒),这一帧就会"掉帧",用户看到的就是卡顿、输入延迟、滚动不跟手。

React 的工作是把组件树转换成真实 DOM。当状态变化时,React 会重新执行相关组件的渲染函数(render),生成新的虚拟 DOM 树,再通过 diff 算法找出差异,最后把差异应用到真实 DOM 上。这个过程分两个阶段:

Render 阶段(协调,Reconciliation):执行组件函数、计算虚拟 DOM、做 diff。这一阶段是纯 JS 计算,可被打断(并发模式下)。
Commit 阶段:把 diff 结果写入真实 DOM,执行 useLayoutEffect、更新 ref。这一阶段是同步、不可打断的。

性能问题的根源通常有三类

1.渲染过于频繁:一个状态变化触发了大量本不需要更新的组件重新执行 render。
2.单次渲染太重:某个组件的 render 里做了昂贵计算,或者一次要渲染上万个 DOM 节点。
3.主线程被长任务占据:大计算(排序、过滤、解析)阻塞了主线程,导致输入、动画都无法响应。

理解了这三类,后面所有优化手段都可以对号入座:React.memo / useMemo / useCallback 主要解决"渲染过于频繁";虚拟列表、代码分割解决"单次渲染太重";useTransition / useDeferredValue / Web Worker 解决"主线程被长任务占据"。

一个关键前提:默认的重渲染规则

很多人误以为"父组件渲染只会更新变化的子组件",这是错的。React 的默认规则是:当一个组件重新渲染时,它的所有子组件默认都会重新渲染,无论 props 是否变化。这是设计上的取舍——React 假设 render 是廉价的,用"全量重算 + diff"换取心智负担的降低。

也正因如此,性能优化的很多工作就是"打破这条默认规则":用 React.memo 让子组件在 props 不变时跳过 render。理解这一点,你才能明白为什么 useMemo/useCallback 经常要和 React.memo 搭配使用——单独用 useCallback 缓存函数、但子组件没被 memo 包裹,是完全无效的。

渲染优化核心策略

React.memo - 组件级缓存:

React.memo 是一个高阶组件,用于缓存函数组件的渲染结果。当组件接收相同的 props 时,React.memo 会跳过渲染,直接复用上次的结果。React.memo 接受一个可选的比较函数作为第二个参数,可以自定义 props 比较逻辑。需要注意的是,React.memo 只进行浅比较(shallow compare),如果 props 包含对象或数组,需要确保引用稳定性。React.memo 适用于纯展示组件、接收复杂 props 的组件、以及渲染开销较大的组件。

可以把 React.memo 类比成一道"门卫":每次父组件想让子组件重渲染时,门卫会先比较这次的 props 和上次的 props。如果一模一样(浅比较相等),门卫就拦下这次渲染,直接返回上次的结果;只有 props 真的变了,才放行执行 render。

useMemo 和 useCallback - 值和函数缓存:

useMemo 用于缓存计算结果,避免在每次渲染时重复执行昂贵的计算。useCallback 用于缓存函数引用,避免在每次渲染时创建新的函数实例。两者都接受依赖数组,只有当依赖项变化时才重新计算。它们本质上是同一个东西:useCallback(fn, deps) 完全等价于 useMemo(() => fn, deps)。useMemo 和 useCallback 本身也有开销(要保存缓存、比较依赖),应该根据实际场景权衡使用。对于简单的计算或不需要传递给子组件的函数,可能不需要缓存。

避免渲染中的对象和函数创建:

在组件渲染中直接创建对象或函数会导致每次渲染都创建新的引用,这会破坏 React.memo 和 useEffect 的依赖检查。解决方案包括:将静态对象和函数移到组件外部、使用 useMemo 缓存对象、使用 useCallback 缓存函数、使用状态管理库管理复杂状态。

为什么这些优化很重要:一组直观数据

下面这组数据来自一个典型的中后台管理系统(表格 + 筛选 + 图表),在优化前后用 React Profiler 与 Chrome Performance 面板测得的量级参考值:

| 场景 | 优化前 | 优化后 | 手段 |

| --- | --- | --- | --- |

| 输入筛选框每敲一个字,组件渲染次数 | 约 1200 次 | 约 40 次 | React.memo + useMemo |

| 一万行表格首次渲染耗时 | 约 1800 毫秒 | 约 90 毫秒 | 虚拟列表 |

| 一万行表格 DOM 节点数 | 约 60000 个 | 约 400 个 | 虚拟列表 |

| 大列表筛选时的输入延迟 | 约 300 毫秒 | 小于 16 毫秒 | useDeferredValue |

| 首屏 JS bundle 体积 | 约 1400 KB | 约 380 KB | 路由级代码分割 |

| 首屏可交互时间 TTI | 约 4200 毫秒 | 约 1500 毫秒 | 代码分割 + 懒加载 |

| 复杂图表拖动时的帧率 FPS | 约 22 FPS | 约 58 FPS | React.memo + Web Worker |

这些数字会随硬件与数据规模变化,但量级上的差距是真实且稳定的——正确的优化往往能带来一个数量级的提升,而不是零点几个百分点。

基础优化代码示例

javascriptCode
import React, { useState, useMemo, useCallback, useEffect, Suspense } from 'react';

// React.memo 基础用法
const MemoizedComponent = React.memo(function UserCard({ user, onSelect }) {
  return (
    <div className="user-card" onClick={() => onSelect(user.id)}>
      <img src={user.avatar} alt={user.name} />
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  );
});

// 自定义比较函数:仅当关心的字段变化时才重渲染
const UserCard = React.memo(
  function UserCard({ user, onSelect }) {
    return <div onClick={() => onSelect(user.id)}>{user.name}</div>;
  },
  (prevProps, nextProps) => {
    // 返回 true 表示"相等",跳过重新渲染
    return prevProps.user.id === nextProps.user.id &&
           prevProps.user.name === nextProps.user.name;
  }
);

// useMemo 缓存计算结果
function UserList({ users, filterText }) {
  // 只有当 users 或 filterText 变化时才重新过滤
  const filteredUsers = useMemo(() => {
    console.log('Filtering users...');
    return users.filter(user =>
      user.name.toLowerCase().includes(filterText.toLowerCase())
    );
  }, [users, filterText]);

  // 缓存排序结果,避免每次渲染都重新排序
  const sortedUsers = useMemo(() => {
    return [...filteredUsers].sort((a, b) => a.name.localeCompare(b.name));
  }, [filteredUsers]);

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

// useCallback 缓存函数
function ParentComponent() {
  const [count, setCount] = useState(0);
  const [items, setItems] = useState([]);

  // 缓存事件处理函数,使传给 memo 子组件的引用保持稳定
  const handleClick = useCallback(() => {
    setCount(prev => prev + 1);
  }, []);

  // 缓存带参数的回调
  const handleItemDelete = useCallback((id) => {
    setItems(prev => prev.filter(item => item.id !== id));
  }, []);

  // 缓存复杂对象,避免每次渲染生成新引用
  const config = useMemo(() => ({
    theme: 'dark',
    locale: 'zh-CN',
    onAction: handleClick
  }), [handleClick]);

  return (
    <div>
      <ChildComponent onClick={handleClick} config={config} />
      <ItemList items={items} onDelete={handleItemDelete} />
    </div>
  );
}

useMemo / useCallback 的正确与错误对比

useMemo 和 useCallback 是被误用最多的两个 API。核心原则只有一句:只有当缓存的值最终会被"依赖引用"的地方消费时,缓存才有意义。所谓"依赖引用"的地方,指的是 React.memo 子组件的 props、useEffect / useMemo / useCallback 的依赖数组。如果一个函数只是在本组件内部调用、不传给别人,缓存它几乎没有收益,反而增加了内存和比较开销。

javascriptCode
// ❌ 错误一:useCallback 有了,但子组件没被 memo,等于白缓存
function BadCase1() {
  const [n, setN] = useState(0);
  // 花了成本缓存 handleClick,但 PlainButton 每次父级渲染都会重渲染
  const handleClick = useCallback(() => console.log('click'), []);
  return <PlainButton onClick={handleClick} />; // PlainButton 未用 React.memo
}

// ❌ 错误二:依赖数组写错,闭包捕获旧值(stale closure)
function BadCase2({ query }) {
  const [results, setResults] = useState([]);
  // 依赖数组漏了 query,回调永远拿到首次渲染的 query
  const search = useCallback(() => {
    fetch('/api?q=' + query).then(r => r.json()).then(setResults);
  }, []); // 应为 [query]
  return <SearchButton onSearch={search} />;
}

// ❌ 错误三:给极廉价的计算加 useMemo,得不偿失
function BadCase3({ a, b }) {
  // a + b 本身就是纳秒级操作,useMemo 的比较与缓存开销更大
  const sum = useMemo(() => a + b, [a, b]);
  return <div>{sum}</div>;
}

// ✅ 正确一:memo 子组件 + useCallback 稳定引用,配合才生效
const FancyButton = React.memo(function FancyButton({ onClick }) {
  console.log('FancyButton render');
  return <button onClick={onClick}>提交</button>;
});
function GoodCase1() {
  const [n, setN] = useState(0);
  const handleClick = useCallback(() => console.log('submit'), []);
  return (
    <div>
      <span>{n}</span>
      <button onClick={() => setN(n + 1)}>+1</button>
      {/* 点 +1 时 FancyButton 不再重渲染 */}
      <FancyButton onClick={handleClick} />
    </div>
  );
}

// ✅ 正确二:useMemo 缓存真正昂贵的计算
function GoodCase2({ rows }) {
  const stats = useMemo(() => {
    // 假设 rows 有十万条,聚合计算耗时明显
    let total = 0, max = -Infinity;
    for (const r of rows) {
      total += r.amount;
      if (r.amount > max) max = r.amount;
    }
    return { total, avg: total / rows.length, max };
  }, [rows]);
  return <SummaryBar stats={stats} />;
}

// ✅ 正确三:把不依赖 props/state 的常量移到组件外,根本不需要 useMemo
const STATIC_CONFIG = { theme: 'dark', pageSize: 20 };
function GoodCase3() {
  return <Table config={STATIC_CONFIG} />;
}

React 18 并发特性:useTransition 与 useDeferredValue

React 18 最重要的能力是并发渲染(Concurrent Rendering)。它让 React 可以在 render 阶段"暂停、恢复、放弃"一次渲染,从而把渲染任务标记为"紧急"或"非紧急"。紧急更新(如输入框打字、点击)优先响应,非紧急更新(如根据输入过滤大列表)可以被打断,让位给用户交互。这从根本上解决了"主线程被长任务占据导致输入卡顿"的问题。

useTransition 用于把一段状态更新标记为"过渡(transition)",即非紧急。它返回 isPending 标志和 startTransition 函数。被 startTransition 包裹的更新,会以低优先级渲染,不会阻塞紧急更新。

useDeferredValue 用于生成一个"滞后版本"的值。当值频繁变化时,UI 中依赖这个延迟值的重活可以滞后处理,用户输入始终保持流畅。它可以理解为 React 内建的、可被打断的"防抖",但比传统防抖更聪明——它不是固定延迟,而是根据设备繁忙程度自适应。

javascriptCode
import React, { useState, useTransition, useDeferredValue, useMemo } from 'react';

// 场景:一个搜索框,输入后要过滤并渲染一个上万项的大列表
// 问题:不优化时,每次打字都要过滤+渲染上万项,输入严重卡顿

// 方案 A:useTransition —— 把"过滤大列表"标记为非紧急
function SearchWithTransition({ allItems }) {
  const [query, setQuery] = useState('');
  const [list, setList] = useState(allItems);
  const [isPending, startTransition] = useTransition();

  const handleChange = (e) => {
    const value = e.target.value;
    // 紧急更新:输入框必须立刻响应
    setQuery(value);
    // 非紧急更新:大列表过滤可以被打断
    startTransition(() => {
      const filtered = allItems.filter(item =>
        item.name.toLowerCase().includes(value.toLowerCase())
      );
      setList(filtered);
    });
  };

  return (
    <div>
      <input value={query} onChange={handleChange} placeholder="搜索..." />
      {isPending && <span className="hint">更新中...</span>}
      <BigList items={list} />
    </div>
  );
}

// 方案 B:useDeferredValue —— 更简洁,直接延迟消费值
function SearchWithDeferred({ allItems }) {
  const [query, setQuery] = useState('');
  // deferredQuery 会在设备空闲时才追上 query
  const deferredQuery = useDeferredValue(query);

  const filtered = useMemo(() => {
    return allItems.filter(item =>
      item.name.toLowerCase().includes(deferredQuery.toLowerCase())
    );
  }, [allItems, deferredQuery]);

  // 输入是否"落后于"渲染,可用来显示加载态
  const isStale = query !== deferredQuery;

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <div style={{ opacity: isStale ? 0.6 : 1 }}>
        <BigList items={filtered} />
      </div>
    </div>
  );
}

useTransition 与 useDeferredValue 如何选择? 如果你能控制那次状态更新的调用点(比如自己写 setState),用 useTransition 更直接;如果值来自 props 或上层、你无法包裹它的更新,就用 useDeferredValue 在消费侧做延迟。二者可以并存。

下面是一个真实场景的对比数据,来自一个包含两万条商品的搜索页:

| 指标 | 无优化 | useDeferredValue | useTransition |

| --- | --- | --- | --- |

| 输入单字符延迟 | 约 280 毫秒 | 小于 16 毫秒 | 小于 16 毫秒 |

| 列表更新到位耗时 | 约 280 毫秒 | 约 200 毫秒(可打断) | 约 200 毫秒(可打断) |

| 打字期间掉帧 | 严重 | 几乎无 | 几乎无 |

| 代码侵入性 | 低 | 低 | 中(需包裹更新) |

代码分割:让首屏只加载它真正需要的东西

代码分割(Code Splitting) 的核心思想是:不要把整个应用打包成一个巨大的 JS 文件让用户一次性下载,而是按需拆分,用户访问到哪个页面/功能,才加载对应的代码块(chunk)。这是优化首屏体积、TTI(可交互时间)、LCP(最大内容绘制)最有效的手段之一。

React 提供 React.lazy 配合 Suspense 实现组件级别的懒加载。构建工具(Webpack、Vite、Rollup)看到动态 import 语句时,会自动把这部分代码拆成独立 chunk。

javascriptCode
import React, { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';

// 路由级代码分割:每个页面单独打包,访问才下载
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Report = lazy(() => import('./pages/Report'));

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/analytics" element={<Analytics />} />
        <Route path="/report" element={<Report />} />
      </Routes>
    </Suspense>
  );
}

// 组件级懒加载:重型组件(如富文本编辑器、图表库)按需加载
const HeavyEditor = lazy(() => import('./HeavyRichTextEditor'));

function ArticlePage() {
  const [editing, setEditing] = useState(false);
  return (
    <div>
      <button onClick={() => setEditing(true)}>编辑</button>
      {editing && (
        <Suspense fallback={<div>编辑器加载中...</div>}>
          <HeavyEditor />
        </Suspense>
      )}
    </div>
  );
}

// 预加载(preload):在用户可能点击前提前加载,消除等待
const heavyImport = () => import('./HeavyRichTextEditor');
const HeavyEditorPreload = lazy(heavyImport);

function PreloadExample() {
  return (
    <button
      // 鼠标悬停时就开始下载,点击时几乎无等待
      onMouseEnter={() => { heavyImport(); }}
      onClick={() => {/* 打开编辑器 */}}
    >
      打开编辑器
    </button>
  );
}

某电商后台做完路由级代码分割后的对比数据:

| 指标 | 单包打包 | 路由分割后 |

| --- | --- | --- |

| 首屏 JS 体积 | 1420 KB | 385 KB |

| 首屏 JS gzip 后 | 410 KB | 118 KB |

| TTI 可交互时间 | 4200 毫秒 | 1500 毫秒 |

| LCP 最大内容绘制 | 3100 毫秒 | 1200 毫秒 |

| chunk 数量 | 1 个 | 14 个 |

需要注意:分割粒度不是越细越好,过多的小 chunk 会带来大量 HTTP 请求和网络往返开销。一般以路由为单位分割,再对超大的第三方库(图表、编辑器、地图)单独懒加载即可。

虚拟列表:长列表渲染的终极方案

当你要渲染一个上万行的表格或列表时,即使每一行都用了 React.memo,DOM 里真实存在上万个节点本身就会拖垮浏览器——布局、绘制、内存都扛不住。虚拟列表(Virtualization / Windowing) 的思路是:无论数据有多少条,只渲染当前视口(viewport)内可见的那几十条,配合滚动动态替换内容,让 DOM 节点数始终维持在一个很小的常量级别。

可以类比成公交车站的电子屏:站台后面排着一万人的队伍,但屏幕上永远只显示当前窗口能看到的十几个人,滚动时快速替换显示内容,用户感觉队伍很长,实际渲染的永远只有一屏。

react-window 是目前最轻量常用的虚拟列表库(react-virtualized 的作者精简重写版)。

javascriptCode
import React, { useCallback } from 'react';
import { FixedSizeList, VariableSizeList } from 'react-window';

// 定高列表:每行高度相同,最简单高效
function VirtualizedList({ items }) {
  // Row 必须用 style,react-window 通过绝对定位实现虚拟滚动
  const Row = useCallback(({ index, style }) => (
    <div style={style} className="row">
      <span>{index}</span>
      <span>{items[index].name}</span>
      <span>{items[index].email}</span>
    </div>
  ), [items]);

  return (
    <FixedSizeList
      height={600}
      width="100%"
      itemCount={items.length}
      itemSize={48}
      overscanCount={5} // 视口外额外渲染 5 行,滚动更顺滑
    >
      {Row}
    </FixedSizeList>
  );
}

// 不定高列表:每行高度不同(如聊天气泡、动态内容)
function VariableList({ messages }) {
  const getItemSize = useCallback(
    (index) => (messages[index].long ? 120 : 60),
    [messages]
  );

  const Row = useCallback(({ index, style }) => (
    <div style={style}>{messages[index].text}</div>
  ), [messages]);

  return (
    <VariableSizeList
      height={600}
      width="100%"
      itemCount={messages.length}
      itemSize={getItemSize}
    >
      {Row}
    </VariableSizeList>
  );
}

// 结合无限滚动加载更多
function InfiniteList({ items, loadMore, hasMore }) {
  const Row = useCallback(({ index, style }) => {
    if (index === items.length) {
      return <div style={style}>加载中...</div>;
    }
    return <div style={style}>{items[index].name}</div>;
  }, [items]);

  const handleItemsRendered = ({ visibleStopIndex }) => {
    // 快滚到底部时预加载下一页
    if (hasMore && visibleStopIndex >= items.length - 5) {
      loadMore();
    }
  };

  return (
    <FixedSizeList
      height={600}
      width="100%"
      itemCount={hasMore ? items.length + 1 : items.length}
      itemSize={48}
      onItemsRendered={handleItemsRendered}
    >
      {Row}
    </FixedSizeList>
  );
}

一个十万行数据表格的虚拟化前后对比:

| 指标 | 全量渲染 | 虚拟列表 |

| --- | --- | --- |

| 实际 DOM 节点数 | 约 300000 个 | 约 500 个 |

| 首次渲染耗时 | 约 6500 毫秒(几乎卡死) | 约 60 毫秒 |

| 滚动帧率 FPS | 约 8 FPS | 约 58 FPS |

| 内存占用 | 约 480 MB | 约 45 MB |

| 页面能否响应交互 | 否 | 是 |

虚拟列表适用于数据量大于 100 条、且每项渲染有一定成本的场景。缺点是:无法用浏览器原生 Ctrl+F 搜索到未渲染的内容、SEO 不友好、实现锚点跳转较复杂——需按业务权衡。

用 Web Worker 卸载计算:给主线程减负

前面的并发特性解决的是"让 React 渲染可被打断",但如果卡顿来自一段纯 JavaScript 大计算(比如解析 10 MB 的 CSV、对十万条数据做复杂聚合、图像处理),这段计算无论如何都会占用主线程。此时最彻底的方案是把计算搬到 Web Worker——一个独立的后台线程,算完再把结果传回主线程,主线程始终保持流畅可交互。

javascriptCode
// worker.js —— 运行在独立线程
self.onmessage = function (e) {
  const { rows } = e.data;
  // 假设这是一段耗时数百毫秒的重计算
  const result = rows
    .filter(r => r.active)
    .map(r => ({ id: r.id, score: heavyScore(r) }))
    .sort((a, b) => b.score - a.score);
  self.postMessage(result);
};

function heavyScore(r) {
  let s = 0;
  for (let i = 0; i < 5000; i++) {
    s += Math.sqrt(r.value * i) % 7;
  }
  return s;
}
javascriptCode
// 在 React 组件里使用 Web Worker
import React, { useState, useEffect, useRef, useCallback } from 'react';

function useWorker(workerFactory) {
  const workerRef = useRef(null);
  useEffect(() => {
    workerRef.current = workerFactory();
    return () => workerRef.current && workerRef.current.terminate();
  }, [workerFactory]);
  return workerRef;
}

function HeavyComputePage({ rows }) {
  const [result, setResult] = useState([]);
  const [computing, setComputing] = useState(false);
  const workerRef = useWorker(
    useCallback(() => new Worker(new URL('./worker.js', import.meta.url)), [])
  );

  const runCompute = useCallback(() => {
    const worker = workerRef.current;
    if (!worker) return;
    setComputing(true);
    worker.onmessage = (e) => {
      setResult(e.data);
      setComputing(false);
    };
    // 主线程只负责发数据、收结果,重活在 worker 里跑
    worker.postMessage({ rows });
  }, [rows, workerRef]);

  return (
    <div>
      <button onClick={runCompute} disabled={computing}>
        {computing ? '计算中(页面依然流畅)...' : '开始计算'}
      </button>
      <ResultTable data={result} />
    </div>
  );
}

对比数据:对十万条数据做一次复杂聚合排序。

| 方案 | 计算耗时 | 计算期间主线程 | 用户能否交互 |

| --- | --- | --- | --- |

| 主线程直接算 | 约 850 毫秒 | 完全阻塞 | 否,页面冻结 |

| useTransition | 约 850 毫秒 | 可被打断 | 部分可交互 |

| Web Worker | 约 850 毫秒 | 空闲 | 完全流畅 |

性能测量:用 Profiler 找到真正的瓶颈

优化的第一原则是:先测量,再优化。凭直觉猜"这里可能慢"往往南辕北辙。React 提供了两种测量工具。

其一是 React DevTools 的 Profiler 面板(图形化),可以录制一段交互,看到每个组件渲染了几次、各耗时多少、为什么渲染(props/state/hook 变化)。其二是代码里的 组件,可在生产/自动化环境里采集渲染数据。

javascriptCode
import React, { Profiler } from 'react';

// onRender 回调会在每次被包裹的子树 commit 后触发
function onRenderCallback(
  id,            // Profiler 的标识
  phase,         // "mount"(首次挂载)或 "update"(重渲染)
  actualDuration,// 本次渲染耗时(含子孙)
  baseDuration,  // 不含 memo 优化时的预估耗时
  startTime,     // 本次渲染开始时间
  commitTime     // 本次 commit 时间
) {
  if (actualDuration > 16) {
    console.warn(
      \`[慢渲染] \${id} (\${phase}) 耗时 \${actualDuration.toFixed(1)}ms\`
    );
  }
}

function App() {
  return (
    <Profiler id="UserDashboard" onRender={onRenderCallback}>
      <UserDashboard />
    </Profiler>
  );
}

// 也可以用浏览器原生 Performance API 精确测量某段逻辑
function measureExpensiveWork(work) {
  performance.mark('work-start');
  const result = work();
  performance.mark('work-end');
  performance.measure('expensive-work', 'work-start', 'work-end');
  const entry = performance.getEntriesByName('expensive-work').pop();
  console.log(\`耗时: \${entry.duration.toFixed(2)}ms\`);
  return result;
}

测量时重点关注三个信号:某个组件是否渲染次数远超预期(说明该用 memo)、某次渲染 actualDuration 是否很长(说明单次渲染太重,考虑拆分或虚拟化)、baseDuration 与 actualDuration 差距(差距大说明 memo 起了作用,差距小说明 memo 没帮上忙,白加)。

自定义防抖与节流 Hook

在有并发特性之前,防抖(debounce)和节流(throttle)是控制高频更新的经典手段,至今仍常用于搜索请求、滚动监听、窗口 resize 等场景。防抖是"等你停下来再执行",节流是"固定间隔执行一次"。

javascriptCode
import { useState, useEffect, useRef, useCallback } from 'react';

// 防抖值 Hook:值停止变化 delay 毫秒后才更新
function useDebouncedValue(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer); // 值又变了就取消上一次
  }, [value, delay]);
  return debounced;
}

// 防抖回调 Hook
function useDebouncedCallback(callback, delay = 300) {
  const timerRef = useRef(null);
  const callbackRef = useRef(callback);
  callbackRef.current = callback; // 始终指向最新回调,避免闭包陈旧

  return useCallback((...args) => {
    if (timerRef.current) clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => {
      callbackRef.current(...args);
    }, delay);
  }, [delay]);
}

// 节流回调 Hook:固定间隔最多执行一次
function useThrottledCallback(callback, interval = 200) {
  const lastRun = useRef(0);
  const callbackRef = useRef(callback);
  callbackRef.current = callback;

  return useCallback((...args) => {
    const now = Date.now();
    if (now - lastRun.current >= interval) {
      lastRun.current = now;
      callbackRef.current(...args);
    }
  }, [interval]);
}

// 使用:搜索框防抖,避免每敲一个字就发一次请求
function SearchBox() {
  const [text, setText] = useState('');
  const debouncedText = useDebouncedValue(text, 400);

  useEffect(() => {
    if (!debouncedText) return;
    fetch('/api/search?q=' + encodeURIComponent(debouncedText))
      .then(r => r.json())
      .then(/* setResults */);
  }, [debouncedText]);

  return <input value={text} onChange={(e) => setText(e.target.value)} />;
}

传统防抖与 useDeferredValue 有何区别?防抖是固定延迟、会丢弃中间态、且不可打断;useDeferredValue 是自适应延迟、React 内建、可被更高优先级打断。对纯前端过滤/渲染,优先用 useDeferredValue;对需要节流网络请求的场景,防抖仍不可替代。

状态管理优化

状态提升与下沉:

状态提升是指将状态移动到最近的共同父组件,状态下沉是指将状态移动到使用它的组件中。合理的状态位置可以减少不必要的渲染,简化数据流。对于多个组件共享的状态,应该提升到最近的共同父组件;对于只有一个组件使用的状态,应该下沉到该组件中。一个常见的性能陷阱是:把一个只有局部子树关心的状态放在了顶层,导致每次它变化整棵树都重渲染——把它下沉到真正使用的子组件,就能把重渲染范围缩到最小。

状态共置(colocation)与内容提升(lifting content up):

如果一个高频变化的状态不得不放在较高层,可以用"把不变的内容通过 children 传入"的技巧,让不变部分不随状态重渲染。

javascriptCode
// ❌ 高频 color 状态放在顶层,导致昂贵的 <ExpensiveTree /> 每次都重渲染
function BadColorApp() {
  const [color, setColor] = useState('red');
  return (
    <div style={{ color }}>
      <input value={color} onChange={(e) => setColor(e.target.value)} />
      <ExpensiveTree /> {/* 与 color 无关,却被连累重渲染 */}
    </div>
  );
}

// ✅ 技巧一:把状态下沉到只需要它的小组件
function GoodColorApp() {
  return (
    <div>
      <ColorPicker /> {/* color 状态封装在这里 */}
      <ExpensiveTree />
    </div>
  );
}
function ColorPicker() {
  const [color, setColor] = useState('red');
  return (
    <div style={{ color }}>
      <input value={color} onChange={(e) => setColor(e.target.value)} />
    </div>
  );
}

// ✅ 技巧二:通过 children 把不变内容"提"进来,隔离重渲染
function ColorContainer({ children }) {
  const [color, setColor] = useState('red');
  return (
    <div style={{ color }}>
      <input value={color} onChange={(e) => setColor(e.target.value)} />
      {children} {/* children 引用稳定,color 变时不重渲染 */}
    </div>
  );
}
function App2() {
  return (
    <ColorContainer>
      <ExpensiveTree />
    </ColorContainer>
  );
}

不可变数据更新:

React 依赖引用比较来检测状态变化,直接修改状态会导致 React 无法检测到变化。使用展开运算符、Object.assign 或 immer 库进行不可变更新,确保每次更新都创建新的引用。immer 库提供了更直观的 API,可以在修改草稿的同时保持不可变性。

javascriptCode
import { produce } from 'immer';

function OptimizedUpdates() {
  const [items, setItems] = useState([]);

  // 函数式更新:不依赖外部 items,useCallback 可空依赖
  const addItem = useCallback((item) => {
    setItems(prev => [...prev, item]);
  }, []);

  // 批量更新:一次 setState 合并多条
  const addMultipleItems = useCallback((newItems) => {
    setItems(prev => [...prev, ...newItems]);
  }, []);

  // immer 处理深层不可变更新,写法直观又安全
  const updateItem = useCallback((id, updates) => {
    setItems(produce(draft => {
      const item = draft.find(i => i.id === id);
      if (item) Object.assign(item, updates);
    }));
  }, []);

  return <ItemList items={items} onAdd={addItem} onUpdate={updateItem} />;
}

批量更新机制:

React 18 引入了自动批处理(automatic batching),将多个状态更新合并为一次渲染。在事件处理器、setTimeout、Promise、原生事件处理器中的更新都会自动批处理(React 17 只在合成事件里批处理)。对于需要立即读取 DOM 的极少数场景,可以使用 flushSync 强制同步更新,但它会牺牲批处理带来的性能优势,应谨慎使用。

javascriptCode
import { flushSync } from 'react-dom';

// React 18 中,下面三次更新自动合并为一次渲染(即使在 setTimeout 里)
setTimeout(() => {
  setA(1);
  setB(2);
  setC(3); // 只触发一次渲染
}, 0);

// 需要在更新后立刻读取 DOM 时,才用 flushSync(会打断批处理)
function scrollToBottomAfterAdd(newMsg) {
  flushSync(() => {
    setMessages(prev => [...prev, newMsg]);
  });
  // 此时 DOM 已更新,可安全读取滚动高度
  listRef.current.scrollTop = listRef.current.scrollHeight;
}

外部状态订阅 useSyncExternalStore:

当你需要订阅一个 React 之外的数据源(如自定义 store、浏览器 API、第三方状态库),React 18 提供了 useSyncExternalStore。它能保证在并发渲染下读取到一致的快照,避免"撕裂(tearing)"——即同一次渲染里不同组件读到不同版本的数据。

javascriptCode
import { useSyncExternalStore } from 'react';

// 订阅浏览器在线状态
function useOnlineStatus() {
  return useSyncExternalStore(
    (callback) => {
      window.addEventListener('online', callback);
      window.addEventListener('offline', callback);
      return () => {
        window.removeEventListener('online', callback);
        window.removeEventListener('offline', callback);
      };
    },
    () => navigator.onLine,       // 客户端快照
    () => true                    // 服务端快照(SSR)
  );
}

// 订阅一个极简自定义 store
function createStore(initial) {
  let state = initial;
  const listeners = new Set();
  return {
    getState: () => state,
    setState: (next) => {
      state = typeof next === 'function' ? next(state) : next;
      listeners.forEach(l => l());
    },
    subscribe: (l) => { listeners.add(l); return () => listeners.delete(l); }
  };
}

const counterStore = createStore(0);

function Counter() {
  const count = useSyncExternalStore(
    counterStore.subscribe,
    counterStore.getState
  );
  return <button onClick={() => counterStore.setState(c => c + 1)}>{count}</button>;
}

清理副作用防止内存泄漏

内存泄漏是长时间运行的单页应用的隐形杀手。定时器、事件监听、订阅、未完成的请求如果不在组件卸载时清理,会导致回调持有已卸载组件的引用,内存持续增长,甚至在卸载后调用 setState 报警告。useEffect 的返回函数就是专门用来清理这些副作用的。

javascriptCode
import React, { useState, useEffect } from 'react';

// 清理 fetch 请求,避免卸载后 setState 与竞态
function DataFetcher({ url }) {
  const [data, setData] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function fetchData() {
      try {
        const response = await fetch(url, { signal: controller.signal });
        const json = await response.json();
        setData(json);
      } catch (error) {
        if (error.name !== 'AbortError') {
          console.error(error);
        }
      }
    }

    fetchData();
    // 卸载或 url 变化时中断上一次请求
    return () => controller.abort();
  }, [url]);

  return data ? <DataDisplay data={data} /> : <Loading />;
}

// 清理定时器与事件监听
function Clock() {
  const [now, setNow] = useState(Date.now());
  useEffect(() => {
    const timer = setInterval(() => setNow(Date.now()), 1000);
    const onResize = () => console.log('resize');
    window.addEventListener('resize', onResize);
    return () => {
      clearInterval(timer);              // 清定时器
      window.removeEventListener('resize', onResize); // 清监听
    };
  }, []);
  return <div>{new Date(now).toLocaleTimeString()}</div>;
}

对于大型数据缓存,考虑使用 WeakMap 和 WeakSet——它们对键是弱引用,当键对象不再被其他地方引用时,垃圾回收器可以自动回收对应缓存,避免手动清理遗漏导致的泄漏。

React Compiler:自动优化的未来

React 团队推出的 React Compiler(原 React Forget)是一个编译期优化工具。它会在构建时自动分析组件,插入等价于 useMemo / useCallback / React.memo 的记忆化逻辑,让开发者无需手写这些缓存 API 就能获得接近手动优化的性能。

它的意义在于:手写 useMemo/useCallback 心智负担重、容易写错依赖、容易过度使用。React Compiler 通过静态分析精确地知道哪些值需要记忆、依赖是什么,从而生成更可靠的优化代码。它遵循 React 规则(组件纯函数、Hooks 规则),因此代码越符合规范,编译器优化得越好。

需要强调:即便有了 React Compiler,理解本文的优化原理依然重要——编译器解决的是"渲染过于频繁"这一类,而虚拟列表、代码分割、Web Worker、并发特性这些工程手段是编译器无法自动替你做的。此外,在编译器尚未全面普及、或存在无法被自动优化的边界场景时,手动优化仍是必备技能。

javascriptCode
// 启用 React Compiler 后(示意),下面的组件无需手写 useMemo/useCallback
// 编译器会自动为 expensiveList 和 handleClick 插入记忆化
function ProductList({ products, keyword }) {
  const expensiveList = products
    .filter(p => p.name.includes(keyword))
    .sort((a, b) => b.sales - a.sales);

  const handleClick = (id) => {
    console.log('clicked', id);
  };

  return (
    <ul>
      {expensiveList.map(p => (
        <ProductCard key={p.id} product={p} onClick={handleClick} />
      ))}
    </ul>
  );
}

其他优化策略与常见坑

图表等重型第三方组件的重渲染:

图表库(ECharts、Recharts、D3)的重新绘制成本极高。常见坑是:父组件任意状态变化都让图表整体重绘。解决方案是用 React.memo 包裹图表容器,并确保传入的 data、option 引用稳定(用 useMemo 缓存),只有数据真正变化时才重绘。某监控大盘把六个实时图表用 memo + 稳定引用优化后,拖动布局时 FPS 从 22 提升到 58。

列表 key 的正确使用:

用数组索引 index 作为 key 是常见错误。当列表发生插入、删除、排序时,index 会错位,导致 React 复用错误的 DOM/状态,出现输入框内容错乱、动画异常、性能变差。应使用数据的稳定唯一标识(如 id)作为 key。

useEffect 依赖导致的连锁重渲染:

在 useEffect 里 setState、又把该 state 放进依赖,容易形成渲染死循环或频繁多余渲染。要仔细核对依赖数组,必要时用函数式更新去掉不必要的依赖。

过度优化:

到处滥用 memo/useMemo/useCallback 反而增加代码复杂度和轻微开销。原则是:先测量确认瓶颈,再针对性优化,不要凭感觉给每个函数都套 useCallback。

优化手段适用场景对照表(总结)

| 优化手段 | 主要解决 | 典型场景 | 代价/注意 |

| --- | --- | --- | --- |

| React.memo | 渲染过于频繁 | 纯展示子组件、列表项 | 仅浅比较,需配合稳定引用 |

| useMemo | 单次渲染有昂贵计算 | 大数组过滤/排序/聚合 | 廉价计算别用,反增开销 |

| useCallback | 函数引用稳定 | 传给 memo 子组件的回调 | 子组件未 memo 则无效 |

| useTransition | 主线程被长任务占据 | 输入触发大列表更新 | 需能包裹更新调用点 |

| useDeferredValue | 高频值拖累重渲染 | 搜索过滤大列表 | 消费侧延迟,自适应 |

| React.lazy + Suspense | 首屏体积过大 | 路由/重型组件懒加载 | 分割粒度别过细 |

| 虚拟列表 react-window | 单次渲染 DOM 太多 | 上万行表格/长列表 | 原生搜索/SEO 受限 |

| Web Worker | 纯 JS 大计算阻塞 | CSV 解析、复杂聚合 | 数据需可序列化传递 |

| 防抖/节流 Hook | 高频事件/请求 | 搜索请求、scroll/resize | 网络节流仍首选防抖 |

| useSyncExternalStore | 外部数据源订阅 | 自定义 store、浏览器 API | 避免并发撕裂 |

| immer/不可变更新 | 状态更新可被检测 | 深层嵌套状态 | 保证引用变化 |

| 状态下沉/children 隔离 | 缩小重渲染范围 | 局部高频状态 | 合理规划状态位置 |

| Profiler 测量 | 定位真实瓶颈 | 优化前的诊断 | 先测量再优化 |

| React Compiler | 自动记忆化 | 减少手写 memo | 需遵守 React 规则 |

总结

React 性能优化不是零散技巧的堆砌,而是一套有内在逻辑的方法论:先用 Profiler 测量、定位瓶颈属于"渲染过于频繁""单次渲染太重""主线程被长任务占据"中的哪一类,再选择对应手段。渲染频繁用 React.memo / useMemo / useCallback(或交给 React Compiler);单次太重用虚拟列表、代码分割、组件拆分;主线程阻塞用 useTransition / useDeferredValue / Web Worker。同时做好状态位置规划、不可变更新、副作用清理,避免内存泄漏与连锁重渲染。最重要的一条原则始终是:先测量,再优化,不要过度优化——把精力花在真正影响用户体验的瓶颈上。