首页 / React 学习笔记 / 37

REACT · Vol.VI · LESSON 37 · 性能优化与部署

重渲染剖析:memo 何时真正有用

进阶核心#性能#渲染

重渲染的三个入口,只有三个

上一篇第 36 篇收官卷五:路由管「去哪」,Query 管「数据从哪来」,骨架齐了。卷六回答下一个问题——快不快。第一站先把「重渲染」这个词钉准确,结论拍在这:组件重渲染,只有三个入口

  1. 自身 setState(useReducer 的 dispatch 也算);
  2. Context 变化——订阅了该 Context 的组件跟着重渲染(第 15 篇);
  3. 父组件重渲染——父渲染,子默认跟着渲染,不管 props 变没变

注意第三条:很多人以为「props 变了所以子组件重渲染」——这是因果倒置。props 变化不是触发器,它是父组件重渲染的伴生结果:父渲染时带着新 props 往下传,子组件默认重跑一遍。用代码验证:

Parent.tsx · 一个永远不变的 props
import { useState } from "react";

function Child({ label }: { label: string }) {
  console.log("Child 渲染了");
  return <p>{label}</p>;
}

function Parent() {
  const [count, setCount] = useState(0);
  console.log("Parent 渲染了");

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      {/* label 从没变过,但 Parent 一渲染,Child 必跟着渲染 */}
      <Child label="固定文案" />
    </div>
  );
}

点五下按钮,控制台五组「Parent + Child」成对出现。可 Child 渲染了,DOM 动了吗?没有。重渲染是「重新执行函数组件 + 与上次输出做 diff」(第 5 篇)——Child 两次输出一样,diff 结果为空,DOM 纹丝不动。记住:重渲染 ≠ DOM 更新。固定成本是「函数执行 + diff」,通常便宜,贵的例外是组件树庞大、列表超长。优化的目标不是「零重渲染」,而是砍掉「输入没变还全量重算」的浪费。

后端类比:重算报表 ≠ 写库

重渲染像「把报表再算一遍」——重新执行只读方法;DOM 更新才像「写库」。只读方法跑一百遍数据库毫发无损,只是费 CPU。优化思路不是「让方法别跑」(那会破坏 UI = f(state) 的正确性),而是让「入参没变的方法」直接返回上次结果——这不就是缓存嘛,React.memo 马上登场。

React 为什么默认不自动跳过?因为它假设组件是纯函数:同 props 必同输出。默认全量重渲染保证正确性优先,性能留给你主动声明——先保证对,再谈快。

默认传染:一个输入框,全家跟着跑

看一个真实形状的页面。订单页 OrderPage 持有关键词状态,侧边栏和订单表都不依赖它:

OrderPage.tsx · 改造前的现场
import { useState } from "react";

function SidePanel() {
  console.log("SidePanel 渲染了");
  return <aside>侧边栏(与关键词无关)</aside>;
}

function OrderTable({ orders }: { orders: string[] }) {
  console.log("OrderTable 渲染了");
  return <ul>{orders.map((o) => <li key={o}>{o}</li>)}</ul>;
}

export default function OrderPage() {
  const [keyword, setKeyword] = useState("");
  const orders = ["ORD-1001", "ORD-1002", "ORD-1003"];

  return (
    <div>
      <input
        value={keyword}
        onChange={(e) => setKeyword(e.target.value)} // 每敲一键触发一次 setState
      />
      <SidePanel />
      <OrderTable orders={orders} />
    </div>
  );
}

每敲一个字符,控制台三行一组地刷:OrderPage → SidePanel → OrderTable。订单表要是有 200 行、每行 8 个节点,等于每敲一键做一次两千节点规模的函数重跑 + diff。关键词只影响搜索框,另外两个组件纯属陪跑——重渲染会沿组件树向下传染,默认没有免疫机制。

后端类比:全量跑批与增量

默认重渲染像全量跑批:上游批次一到,下游所有任务无条件重跑。你要的是增量——入参没变的任务直接跳过。React 给的开关就是 React.memo:给组件声明「入参没变就别叫我」。

React.memo:浅比较这道闸门

React.memo 是包在组件外面的一层 HOC(第 26 篇见过的模式):重渲染从父组件传染过来时,memo 先拦一道,对旧 props 和新 props 做一遍浅比较——每个 key 用 Object.is 比对;全部相等就跳过整个组件的渲染,直接复用上次输出。试试:

OrderPage.tsx · 包了 memo,但只成功了一半
import { memo } from "react";

// memo 包裹:重渲染传染过来时先做 props 浅比较,全相等就跳过
const OrderTable = memo(function OrderTable({ orders }: { orders: string[] }) {
  console.log("OrderTable 渲染了");
  return <ul>{orders.map((o) => <li key={o}>{o}</li>)}</ul>;
});

export default function OrderPage() {
  const [keyword, setKeyword] = useState("");

  return (
    <div>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      {/* 坏了:每次渲染都新建数组,引用必不同,memo 白包 */}
      <OrderTable orders={["ORD-1001", "ORD-1002", "ORD-1003"]} />
    </div>
  );
}

每敲一键,OrderTable 照样渲染——数组字面量每次渲染都新建数组,引用必然不同,浅比较必然失败。这是 memo 最经典的失效场景,重灾区列全:

  • 对象 / 数组字面量:orders={[...]}style={{ color: "red" }}
  • 内联箭头函数:onClick={() => doIt()}——每次新引用;
  • children 塞 JSX:<Card><b>内容</b></Card> 的 children 也是 props,同理。

memo 只看引用,不看内容。修复的钥匙第 11 篇早就给过你:useMemo 钉值的引用,useCallback 钉函数的引用:

OrderPage.tsx · 稳定引用,memo 才能命中
import { useCallback, useMemo, useState } from "react";

export default function OrderPage() {
  const [keyword, setKeyword] = useState("");

  // 值用 useMemo 钉住引用:依赖不变就不造新数组(第 11 篇)
  const orders = useMemo(
    () => ["ORD-1001", "ORD-1002", "ORD-1003"],
    []
  );

  // 函数用 useCallback 钉住引用:keyword 变了才换新函数
  const handleExport = useCallback(() => {
    console.log("导出订单,关键词:", keyword);
  }, [keyword]);

  return (
    <div>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <OrderTable orders={orders} onExport={handleExport} />
    </div>
  );
}

现在再敲关键词:OrderPage 渲染(自己 setState,天经地义),SidePanel 和 OrderTable 全部安静——传染被闸门拦下了。

父组件重渲染 子组件被 memo 包裹? 不看 props, 整棵子树照常渲染 props 浅比较(Object.is) 全部相等 有引用不同 跳过渲染 直接复用上次的输出,DOM 零操作 照常重渲染 对象字面量 / 内联函数永远「不同」 浅比较只看引用,不看内容
图 1memo 的闸门:包了的组件先做 props 浅比较,引用全等才放行跳过——稳定引用是生效前提。
后端类比:缓存命中条件——入参引用可哈希

@Cacheable(key = "#orderId") 能命中,因为 String 的 hashCode 稳定;每次 new 一个没重写 equals 的对象当 key,缓存永远 miss。memo 的 props 浅比较就是「对入参做相等性检查」,内联对象 / 函数 = 每次都换 key = 永远 miss。所以 useMemo / useCallback 不是点缀,它们是 memo 这张缓存表的 key 稳定性保证——先有稳定 key,才谈得上命中。

memo 支持第二参数自定义比较函数(如深比较),慎用:深比较有成本,漏字段还会造成「该渲染不渲染」的灵异 bug。想深比较时,先想想是不是该把对象拆成几个原始值 props。

三件套分工,以及 memo 自身的成本

useMemo、useCallback、memo 经常被混着叫「性能优化三件套」,但各管一段,一张表钉死:

工具作用层级管什么后端类比
React.memo组件级props 浅比较全等 → 跳过整个组件的渲染方法级缓存 @Cacheable
useCallback值级(函数)函数引用稳定,供 memo 比对、供依赖数组用稳定的缓存 key
useMemo值级(任意值)对象 / 数组引用稳定 + 昂贵计算结果缓存稳定的 key + 结果缓存

记忆口诀:memo 管「组件跳不跳过」,useCallback 管「函数引用稳不稳」,useMemo 管「值引用稳不稳、重算要不要」。useCallback 单独用几乎没意义,它和 memo 是配套的。

另一半真相:memo 不是免费的。每次父组件渲染它都要做一遍 props 比对;组件越小越纯,「重渲染」越便宜,「比对」的相对开销越高——给渲染成本约等于零的小组件包 memo 是负优化。memo 留给「渲染贵、props 经常不变」的体重级选手:列表项、大表格、图表。

谁是体重级选手?先测量,再优化。用 React DevTools 的 Profiler 录一次交互,看谁渲染次数多、单次耗时长;也可以临时包一层 <Profiler> 组件,在 onRender 回调里拿 id / phase / duration 给热点排序(第 41 篇细讲)。

标准流程由此固定:测量 → 找热点 → 稳定引用 → memo 包热点 → 再测量确认。跳过第一步的优化,大概率是 memo 撒胡椒面:代码量翻倍,热点那一个组件的收益却被平均掉。

核心要点
  • 重渲染只有三个入口:自身 setState、Context 变化(第 15 篇)、父组件渲染。「props 变了」不是独立入口,是父渲染的伴生结果。
  • 重渲染 ≠ DOM 更新:函数重跑 + diff(第 5 篇),DOM 多半纹丝不动;要防的是大树里的无效重跑。
  • memo 是 props 浅比较的闸门:内联对象 / 函数每次都是新引用,必穿闸——useMemo / useCallback 稳定引用是 memo 生效的前提(第 11 篇)。
  • memo 有比较成本:小而纯的组件别包;先 Profiler 测量(第 41 篇),对「渲染贵、props 稳」的热点下手。
  • 分工:memo 管组件跳过,useCallback 管函数引用,useMemo 管值引用与昂贵计算——三件套要配套使用。

收官:memo 治不了的病,留给虚拟列表

把本篇的判断收进速查表,下次遇到「页面卡」对着开方:

症状处方出处
父组件一打字,无关子组件跟着跑memo + 稳定引用,挡住传染本篇
包了 memo 还是每次都跑揪出 props 里的内联对象 / 函数,useMemo / useCallback 钉住本篇 + 第 11 篇
列表项全都 memo 了,滚动还是卡病根是节点数量,不是重渲染——换思路第 38 篇
不知道该从哪优化先 Profiler 测出热点,再动手第 41 篇

本章回顾

重渲染只有三个入口,且重渲染 ≠ DOM 更新——只是函数重跑加一次 diff,多数时候便宜且正确。memo 的价值是用 props 浅比较拦住「输入没变还重跑」的浪费,但命门是引用:内联对象和函数会让闸门永远失效,useMemo / useCallback 因此是配套件;memo 自身也有比较成本,先测量、对热点下手。然而 memo 有治不了的病:列表有 1 万行时,哪怕每行都 memo、一次都不重渲染,几万个 DOM 节点的挂载与布局照样卡——它省的是重复劳动,省不出绝对工作量。下一篇第 38 篇换釜底抽薪的思路:虚拟列表——1 万行数据,只渲染看得见的那 30 行。

Comments · 评论