知

模块四 · 性能、调度与架构

第 14 / 18 节18 分钟阅读型

重渲染控制:先问为什么渲染,再问怎么阻止

大部分"性能问题"其实是结构问题。这一节按"状态下放 → 结构拆分 → memo → 虚拟列表"的顺序给出优化手段与判断标准。

读完这一节,你能做到

  1. 说清一次重渲染的触发链。
  2. 用状态下放与结构拆分减少渲染范围。
  3. 判断 memo/useMemo/useCallback 什么时候真的有用。
  4. 用虚拟列表解决长列表卡顿。

一、先看渲染为什么发生

React 的默认行为是:父组件渲染,所有子组件都渲染。子组件渲染的原因通常不是自己的 state 变了,而是"父亲重新执行了"。

三个问题要分开:

问题判断
渲染次数多是否由父组件引起
单次渲染慢组件本身计算量大不大
渲染后卡顿是不是 DOM 操作或布局抖动

大部分"卡"不是渲染次数,而是单次渲染做了太多事,或者长列表一次渲染了几千行。

二、状态下放

把状态放到真正需要它的组件里,而不是放在页面顶层:

// 差:输入框每次输入,整个页面重渲染
function Page() {
  const [text, setText] = useState("");
  return (
    <>
      <Input value={text} onChange={setText} />
      <ExpensiveList />
    </>
  );
}

// 好:状态下沉到 Input 内部,列表不受影响
function Input() {
  const [text, setText] = useState("");
  return <input value={text} onChange={(e) => setText(e.target.value)} />;
}

状态下放的收益,通常大于任何 memo。

三、结构拆分与 children

把"变化的部分"和"不变的部分"拆开,用 children 传递不变的部分:

function Layout({ children }) {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>{count}</button>
      {children}
    </div>
  );
}

children 是父组件已经创建好的元素,Layout 重渲染时不会重新创建它,因此子树不会跟着渲染。这是比 memo 更稳的优化。

四、memo / useMemo / useCallback

API作用生效条件
memo跳过子组件渲染props 浅比较相等
useMemo缓存计算结果依赖不变
useCallback缓存函数引用依赖不变

三个常见误区:

  1. memo 不是越多越好:浅比较本身有成本,简单组件包 memo 反而更慢。
  2. useMemo 不是语义保证:React 可以在内存压力下丢弃缓存,不能拿它保证"只计算一次"。
  3. useCallback 只在需要稳定引用时用:比如传给 memo 子组件、作为 effect 依赖。

五、Context 的分割

Context 的值变化会让所有消费者重渲染。两个做法:

  • 按领域拆成多个 Context,避免"一个值变,全体更新"。
  • 把 value 用 useMemo 包住,避免父组件每次渲染都创建新对象。

如果 Context 里既有频繁变化的值,又有稳定的方法,拆成两个 Context 更合适。

六、长列表:虚拟滚动

一次渲染 2,000 行,DOM 节点与布局计算都会爆。虚拟滚动只渲染可视区域:

  • 固定高度:直接按 scrollTop 计算区间。
  • 不定高度:先估算,渲染后测量并修正。
  • 回收节点时要清理副作用(图片懒加载、订阅)。

七、什么时候不该优化

  • 组件很小、渲染很快,memo 的收益抵不过比较成本。
  • 数据量不大,虚拟列表增加复杂度却没有体感提升。
  • 瓶颈在网络或图片,不在渲染。

先测量,再优化;优化后要能说出指标变化。

八、常见坑

  1. 给所有组件包 memo,结果代码更难读、性能没变。
  2. useMemo 依赖写错,缓存了旧值。
  3. Context value 每次新建对象,导致所有消费者重渲染。
  4. 用下标 key 导致列表项状态错位。
  5. 虚拟列表没处理不定高度,滚动跳动。

九、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 把一个页面顶层的输入框状态下放,观察列表渲染次数。
  2. 用 children 拆分布局与内容,验证子树不再重渲染。
  3. 给一个 1,000 行列表加虚拟滚动,对比滚动帧率。

② 让 AI 写同一题

把第 1 题交给 AI,补一句 先判断能不能缩小状态影响范围,再谈 memo。

③ 对比要盯的点

  • 它是不是直接给你 memo + useCallback + useMemo 三件套?那是“看起来优化”。
  • 它有没有先问“这个状态到底该放在哪一层”?没问就动手,顺序就反了。
  • 它给的数字是真的测过,还是一句“这样会更快”?

④ 一句话取舍

它给的三件套里,在你这个页面上有几件是无效的?用 Profiler 数出来。

十、自测

  1. 子组件为什么会重渲染?
  2. 状态下放为什么比 memo 更有效?
  3. useMemo 能保证"只计算一次"吗?
  4. Context 值变化影响谁?
  5. 什么时候不该做渲染优化?

参考答案要点:父组件渲染;缩小状态影响范围;不能,只是缓存提示;所有消费者;收益小于成本时。

附:memo 的浅比较细节

memo 默认用 Object.is 逐项比较 props:基本类型比值,对象/数组/函数比引用。所以下面三种情况一定会重渲染:

  • 父组件传了对象字面量 style={{ color: 'red' }}。
  • 传了内联函数 onClick={() => ...}。
  • 传了 children 里新建的元素。

修法不是无脑包 memo,而是稳定引用:对象提到组件外或用 useMemo,函数用 useCallback,children 用组合。

附:Context 拆分的案例

一个页面同时提供“用户信息”和“主题色”。用户信息变化不频繁,主题色可能频繁切换。如果放在同一个 Context,主题切换会让所有用户信息的消费者也重渲染。拆成两个 Context 后,各自变化只影响各自的消费者。

如果拆分后仍然重渲染,检查 value 是否是每次新建的对象:用 useMemo 包住 value,依赖写真正会用到的字段。

附:性能优化的决策树

卡顿?
├─ 输入卡 → 看渲染范围(状态下放、memo、children)
├─ 滚动卡 → 看 DOM 数量(虚拟列表)
├─ 首屏慢 → 看资源(分包、图片、懒加载)
└─ 切换页卡 → 看数据与计算(缓存、Worker)

先定位类型,再选手段;不要把所有优化手段一起上。

附:React.memo 与 PureComponent

两者都是浅比较 props,区别是 memo 用于函数组件,PureComponent 用于类组件。它们都只比较第一层:嵌套对象内部变化不会触发重渲染,除非你传入新引用。

需要自定义比较时,给 memo 传第二个参数:返回 true 表示“相等,跳过渲染”。但自定义比较容易写错,优先稳定引用。

附:什么时候该用虚拟列表

  • 行数超过 200 且滚动明显掉帧。
  • 行高相对固定,或可以估算。
  • 列表项没有复杂的全局副作用。

行数少、行高差异大、需要打印完整列表时,不要用虚拟滚动,复杂度不划算。

附:children 与 memo 的组合

把不变的子树作为 children 传给会频繁更新的组件,子树不会因为父组件渲染而重新创建。这比 memo 更稳定:它不依赖 props 比较,而是利用“元素已经在父组件创建好”这一事实。先缩小范围,再谈缓存;结构优化优先于 API 优化,测量先于手段。先算,再改。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解