文章

React 性能优化体系全景

面试向讲清 React 性能优化的完整链路:先用 Profiler 量化再动手,用状态下放与 children 提升减少渲染次数, 用 memo 三件套与虚拟列表降低单次渲染成本,用并发特性把不急的更新降级,并给出 React Compiler 时代的最新答案。

React 性能优化体系全景

一句话概括

React 性能优化只有三件事:少渲染几次(状态放对地方)、每次渲染少干点活(算得少、DOM 少)、把不急的更新往后排(并发特性)。

面试官问”你怎么优化一个 React 应用”,他想要的不是”我会用 useMemo/useCallback”——这是 2021 年的答案。他想要的是一套有优先级的动作顺序:先量化定位,再改结构(状态位置、组件边界),最后才是加 memo。而且 2025 年 10 月 React Compiler 已经 1.0 稳定了,”所有组件都包一层 memo”这种说法现在说出来是要扣分的。

记住这条主干,剩下的都是细节:

1
2
3
量化(Profiler)→ 结构性优化(状态下放 / children 提升 / Context 拆分)
              → 渲染传播优化(memo 三件套)→ 单次渲染成本(虚拟列表 / key)
              → 调度优化(useTransition)→ 首屏加载(分包 / 懒加载)

核心知识点

1. 先量化:别凭感觉优化

结论:没有 Profiler 数据的优化都是瞎猜。

React DevTools 的 Profiler 面板有两个功能是定位浪费渲染的关键:

  • Highlight updates:开着它操作页面,闪黄框的组件就是在做”白工”的重渲染。
  • “Why did this render?”:直接告诉你这次渲染是因为 props 变了、state 变了还是父组件带的。
1
2
3
4
5
6
7
8
9
// ❌ 错误姿势:看到组件就包 memo,包完也不知道有没有变快
const Card = memo(function Card({ user }) { /* ... */ })

// ✅ 正确姿势:先量,再改,改完再量一遍
// 1. Profiler 录制一次交互 → 看 ranked / flamegraph
// 2. 找到"渲染耗时最长"或"渲染次数异常多"的那一层
// 3. 针对这一层动手

面试里补一句会加分:memo 本身不是免费的——它要给每个组件存一份上一次的 props 和渲染结果,每次渲染还要做一遍浅比较。一个渲染成本极低的叶子组件(比如一个 <span>),包 memo 的成本可能比收益还高。

2. 结构性优化第一招:把状态下放到真正需要它的地方

结论:状态放得越高,一次 setState 牵连的组件越多。

这是性价比最高、也最容易被忽略的优化——它不需要任何 API,只需要把 state 挪个位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ❌ 输入框的 state 放在最顶层:每敲一个字,整个页面 1000 个组件全部重渲染
function App() {
  const [keyword, setKeyword] = useState('')
  return (
    <div>
      <SearchInput value={keyword} onChange={setKeyword} />
      <HugeTable />      {/* 跟 keyword 毫无关系,却被迫重渲染 */}
      <SideBar />
    </div>
  )
}

// ✅ 把 state 关进真正用它的组件里(state colocation)
function App() {
  return (
    <div>
      <SearchInput />    {/* keyword 只在 SearchInput 内部流转 */}
      <HugeTable />
      <SideBar />
    </div>
  )
}

如果 keyword 确实要和兄弟组件共享,那就把用到 keyword 的那部分单独抽成一个小组件,让 state 留在那个小组件的顶层,而不是整个页面的顶层。

3. 结构性优化第二招:children 提升(把不变的部分当插槽传进去)

结论:父组件重渲染时,通过 props.children 传进来的 JSX 不会跟着重渲染。

这是”状态下放”做不了时的替代方案。原理很简单:children 是父组件的父级创建好传下来的,父组件自己重渲染时不会重新创建它,React 拿到的 element 引用没变,自然跳过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ❌ 滚动位置一变,HeavyList 跟着重渲染(虽然数据一个字没变)
function ScrollPanel() {
  const [top, setTop] = useState(0)
  return (
    <div onScroll={e => setTop(e.target.scrollTop)}>
      <HeavyList items={items} />
    </div>
  )
}

// ✅ 把 HeavyList 作为 children 传进来,ScrollPanel 重渲染时它被跳过
function ScrollPanel({ children }) {
  const [top, setTop] = useState(0)
  return <div onScroll={e => setTop(e.target.scrollTop)}>{children}</div>
}

// 使用方:<Page> 重渲染时创建一次 HeavyList,之后 ScrollPanel 自己怎么滚都不影响它
<ScrollPanel><HeavyList items={items} /></ScrollPanel>

顺带一条面试高频:Context 导致的重渲染也靠拆解决。Context 的 value 一变,所有消费者全部重渲染,而且 Context 没有 selector 机制。所以:

  • 把”几乎不变的配置(主题、权限、语言)”和”高频变化的数据(表单值、鼠标位置)”拆成两个 Provider;
  • 高频共享状态别用 Context,用 Zustand / Jotai 这类带 selector 订阅的库;
  • provider 的 value 用 useMemo 包一下,避免 {a, b} 每次都是新对象。

4. memo / useMemo / useCallback:只在三种情况下值得用

结论:这三个 API 不是”性能优化开关”,它们是”引用稳定器”。 官方文档原话是:memoization is a performance optimization, not a guarantee——缓存是优化,不是承诺。

三个正当使用场景,记住就能答:

场景用什么为什么
真的有昂贵计算(上万条数据排序 / 过滤 / 图表布局)useMemo省掉的是实打实的 CPU
给 memo 包过的子组件传对象/数组/函数useMemo / useCallback否则 memo 的浅比较每次都失败,白包
值要进 useEffect 依赖数组useMemo / useCallback引用不稳定会导致 effect 反复触发(经典死循环请求)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Parent({ onSelect }) {
  const [count, setCount] = useState(0)

  // ❌ 每次渲染都是新函数 → Row 的 memo 完全失效
  const bad = id => onSelect(id)

  // ✅ 引用稳定 → Row 的 memo 才真正生效
  const good = useCallback(id => onSelect(id), [onSelect])

  return items.map(item => <Row key={item.id} item={item} onPick={good} />)
}
const Row = memo(function Row({ item, onPick }) { /* ... */ })

React.memo 的四个”失效时刻”,面试特别爱问:

  1. props 里有渲染期新建的对象 / 数组 / 函数(Object.is({}, {}) === false,浅比较必失败);
  2. props 里传了 JSX 子元素(<Comp><Child /></Comp> 每次都是新的 element 对象);
  3. 组件自己用了会变的 Context——memo 只管 props,管不了 context;
  4. 组件自己的 state 变了——memo 同样管不着。

另外 memo 可以传第二个参数自定义比较,但必须比较每一个 prop(包括函数),漏一个就是诡异 bug,而且别做深比较,那玩意儿可能比重渲染还慢。

5. 降低单次渲染成本:虚拟列表 + 稳定的 key

结论:一万行表格再怎么 memo 也救不回来,少渲染 9900 个 DOM 才是正解。

1
2
3
4
5
6
7
8
// ✅ 只渲染可视区的十几行,DOM 数量恒定
import { FixedSizeList } from 'react-window'

<FixedSizeList height={600} width="100%" itemCount={rows.length} itemSize={48}>
  {({ index, style }) => <Row style={style} data={rows[index]} />}
</FixedSizeList>

大列表的性能优先顺序:虚拟列表 > 分页/服务端过滤 > 前端 memo。能分页就别全量,能在服务端 filter 就别拉全量到前端。

配套的两条硬规则:

  • key 要稳定且唯一:用数组下标当 key,在列表头部插入/删除时,React 会按位置比对,导致后面所有行的 DOM 被错误复用(输入框内容串位就是这么来的)。用业务 id。
  • 不要在组件内部定义组件:function Parent() { function Child() {} } 每次渲染 Child 都是新函数 = 新的 element type,React 会卸载再挂载整棵子树,state 全丢。输入框打字时失焦,八成是这个原因。

6. 调度优化:把不急的更新降级

结论:算不完就别硬算,让紧急的先渲染。

1
2
3
4
5
6
7
8
const [isPending, startTransition] = useTransition()

function onSearch(text) {
  setInput(text)                              // 紧急:输入框必须立刻回显
  startTransition(() => setKeyword(text))     // 不急:大列表筛选可以慢半拍
}

useTransition 包更新动作,useDeferredValue 包一个值拿到”滞后副本”——两者本质都是把更新标记为低优先级、可被紧急更新打断。详细机制见本系列的并发特性篇,这里只要答得出”输入框卡顿时用 startTransition 把筛选降级”就够了。

7. 2026 年必须知道的变量:React Compiler

结论:React Compiler 于 2025 年 10 月发布 1.0 稳定版,它让大部分手写 memo 变成历史。

它是构建期的 Babel 插件(babel-plugin-react-compiler),静态分析组件后自动插入记忆化代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 你写的(不用写任何 memo)
function List({ items, filter }) {
  const visible = items.filter(i => i.tag === filter)
  return <ul>{visible.map(i => <Row key={i.id} item={i} />)}</ul>
}

// 编译器产出的(概念示意,缓存粒度是每个表达式,比手写 useMemo 还细)
function List({ items, filter }) {
  const $ = useMemoCache(4)
  let visible
  if ($[0] !== items || $[1] !== filter) {
    visible = items.filter(i => i.tag === filter)
    $[0] = items; $[1] = filter; $[2] = visible
  } else {
    visible = $[2]
  }
  // ...JSX 同样被缓存
}

面试里关于 Compiler 要能说出这四点:

  1. 粒度是”每个值”,不是”整个组件”——比手写 useMemo 更细,而且能在 early return 之后这种手写不了的地方插缓存。
  2. 它靠”Rules of React”做静态分析:组件必须纯、不能在渲染期改 props/state、Hook 不能条件调用。违反了它就静默跳过这个组件的优化,不报错。
  3. 兼容 React 17+:19 原生支持,17/18 需要额外装 react-compiler-runtime。只处理函数组件,class 组件跳过。
  4. useMemo/useCallback 没被废弃,它们退化为”精确控制”的逃生舱:最常见的是作为 effect 依赖需要稳定引用时,以及 Compiler 保守跳过的代码区域。

所以现在最标准的回答是:“开了 Compiler 的新项目,写直白的代码让编译器去记忆化,只在确实昂贵的计算和 effect 依赖上保留手写;老项目不要批量删 memo,删除会改变编译产物,要改就逐个测。”

8. 首屏加载:React 侧能做的那点事

这一层严格说不是”渲染性能”,但面试官问”页面首屏慢”时你总得接得住:

  • 路由级代码分割:const Page = lazy(() => import('./Page')) + <Suspense fallback>,把首屏用不到的 JS 拆出去;
  • 组件级懒加载:富文本编辑器、图表库、地图这种大依赖,用到再加载;
  • 预加载:在用户可能点进去之前(hover / 空闲时)触发 import(),把等待时间藏起来;
  • 产物分析:rollup-plugin-visualizer 看一眼 bundle,通常能揪出”某个库被重复打进两个 chunk”或”moment 全量 locale”这种蠢问题。

其实你每天都在用

  1. 搜索框边打字边卡:useDeferredValue 或 useTransition 把筛选降级,输入框立刻不卡了。
  2. 后台管理系统的万行表格:react-window / react-virtuoso 一上,DOM 从 1 万个变 20 个,滚动丝滑。
  3. 表单页每敲一个字整页闪:状态没下放,把受控 state 收到表单组件内部就解决了。
  4. 切换 Tab 卡顿半秒:把 Tab 内容包进 startTransition,配合 isPending 给个骨架屏。
  5. 弹窗里的重型组件跟着父页面重渲染:把弹窗内容作为 children 传进去,或者干脆用 Portal + 独立状态。
  6. 切换主题时全站重渲染:主题这种低频 Context 没问题;但如果你把”当前选中的行 id”也塞进同一个 Context,那每次点行全站重渲染——拆开。
  7. 输入框打字时失焦:八成是在父组件里定义了子组件,element type 每次都变,React 把它卸载重挂了。

常见误解(FAQ)

❌ 误区1:”所有组件都包上 memo / useCallback,性能肯定更好”

这是最典型的错误答案。memo 要存旧 props、要比对,本身就有成本;而且只要 props 里有渲染期新建的对象/函数,它立刻失效,你还得多写一串 useCallback 去配合,依赖数组写错又引入过期闭包。官方立场是默认不用,测出来慢了再加。2026 年还要补一句:开了 Compiler 之后这些基本由编译器代劳。

❌ 误区2:”memo 包了组件,props 没变就不会重渲染了”

三个漏洞:组件自己的 state 变了照样渲染;它消费的 Context 变了照样渲染;props 里但凡有一个是渲染期新建的对象/数组/函数/JSX,浅比较就失败。官方文档也明确写了——memoization 是优化,不是保证,React 仍可能重渲染它。

❌ 误区3:”useMemo 能加速所有计算”

对 20 条数据做 .map() 再包个 useMemo,记账成本(比对依赖 + 存旧值)比省下的还多。useMemo 只在你明确知道”这段计算很贵”或者”这个引用必须稳定”时才值得。

❌ 误区4:”key 用数组下标没关系,反正列表顺序不变”

顺序一变就出事:在头部插入一条,React 按位置复用,把原来的第 1 行当成新的第 1 行,输入框的值、勾选状态、动画状态全部串位。用业务 id,别用 index。

❌ 误区5:”有了 React Compiler,性能优化就不用学了”

恰恰相反。Compiler 只能优化”记忆化”这一件事,它救不了状态放错位置导致的全树重渲染,也救不了一万行 DOM。而且它依赖你遵守 Rules of React——代码写得不纯,它就静默跳过。渲染模型该懂还得懂。

一句话总结

优化的顺序永远是:先量出来哪里慢(Profiler)→ 改结构让该渲染的才渲染(状态下放 / children 提升 / Context 拆分)→ 再用 memo 三件套稳住引用 → 最后用虚拟列表和并发特性兜底;至于手写 memo,React Compiler 已经把它从”必答题”变成了”加分题”。

本文由作者按照 CC BY 4.0 进行授权