陌上人如玉
公子世无双

5.4 减少重渲染

5.4 减少重渲染

一句话:减少重渲染的优先级排序是——「状态下移 → 组件拆分 → children 透传 → 引用稳定 + memo → 外部 store 选择器」;结构性手段优先于工具性手段。

一、先理解传播机制

默认规则(见 4.6):
  状态变化 → 该组件重渲染 → 它的子组件【也会重渲染】
  (因为父组件重新执行时会重新创建子元素)

所以要减少重渲染,有两条路:
① 让「状态变化的影响范围」更小(结构手段)
② 在「传播路径上」加拦截(工具手段)

⭐ 优先①:它不增加复杂度、不依赖引用稳定、也不容易踩坑

二、手段一:状态下移(最优先)

// ❌ 状态在高层 → 每次输入都让整页重渲染
function PageBad() {
  const [query, setQuery] = useState('');
  const [showSidebar, setShowSidebar] = useState(true);

  console.log('Page 渲染');

  return (
    <div>
      <Sidebar open={showSidebar} />
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ResultList query={query} />
      <ExpensiveChart />                {/* ⚠️ 每次输入都跟着重渲染 */}
    </div>
  );
}

// ✅ 状态下移:把状态放到真正使用它的地方
function PageGood() {
  const [showSidebar, setShowSidebar] = useState(true);   // 只有它影响的范围小
  console.log('Page 渲染(只在切换侧栏时)');

  return (
    <div>
      <Sidebar open={showSidebar} />
      <SearchSection />                {/* ⭐ 输入状态在内部 */}
      <ExpensiveChart />               {/* ✅ 不受输入影响 */}
    </div>
  );
}

function SearchSection() {
  const [query, setQuery] = useState('');
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ResultList query={query} />
    </>
  );
}
判断标准:
  「这个状态需要被其它组件读取/修改吗?」
  ├─ 不需要 → 放到「最近的使用它的组件」里(下移)
  └─ 需要 → 提到「最近的公共祖先」(但不要更高)

三、手段二:组件拆分

// ❌ 一个巨型组件:任何一个状态变化都让整个组件重渲染
function Dashboard() {
  const [filter, setFilter] = useState('all');
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [modalOpen, setModalOpen] = useState(false);

  console.log('Dashboard 渲染');        // ⚠️ 任何一个状态变化都会打印

  return (
    <div>
      <FilterBar value={filter} onChange={setFilter} />
      <ChartArea />
      <TableArea selectedId={selectedId} onSelect={setSelectedId} />
      <Modal open={modalOpen} onClose={() => setModalOpen(false)} />
    </div>
  );
}

// ✅ 拆分:每个区域自己管理自己的状态
function DashboardGood() {
  return (
    <div>
      <FilterSection />        {/* 自己管 filter */}
      <ChartArea />            {/* 完全静态,永远不因外部状态重渲染 */}
      <TableSection />         {/* 自己管 selectedId */}
      <ModalSection />         {/* 自己管 modalOpen */}
    </div>
  );
}
拆分的原则:
① 按「状态的所有权」拆(谁的状态就放在谁的组件里)
② 把「静态子树」独立出来(它永远不需要因外部状态更新)
③ 把「昂贵子树」独立出来(让它可以被 memo 隔离)
④ 组件粒度别过细(拆太碎会增加层级与 props 传递)

四、手段三:children 透传

// ❌ 容器组件有状态 + 内部直接写子树 → 子树跟着重渲染
function ContainerBad({ data }: { data: Data }) {
  const [count, setCount] = useState(0);
  console.log('Container 渲染');

  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>{count}</button>
      <ExpensiveTree data={data} />       {/* ⚠️ 会跟着重渲染 */}
    </div>
  );
}

// ✅ 把「不依赖状态」的子树作为 children 传入
function ContainerGood({ children }: { children: React.ReactNode }) {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>{count}</button>
      {children}                          {/* ⭐ 由外部创建的元素,引用不变 → 跳过 */}
    </div>
  );
}

function App({ data }: { data: Data }) {
  return (
    <ContainerGood>
      <ExpensiveTree data={data} />       {/* 由 App 创建 */}
    </ContainerGood>
  );
}
为什么有效?
  React Element 是「不可变对象」,由「谁创建」决定身份:
  · 写在 Container 内部 → Container 每次渲染都创建新元素 → 子树重渲染
  · 由外部(App)创建并传入 → Container 渲染时 children 是同一个对象
    → React 发现元素引用相同 → 跳过整棵子树

⚠️ 前提:创建 children 的那个组件(App)本身没有重渲染。
// 变体:用 useMemo 包裹「静态 JSX」(局部版 children 技巧)
function Component() {
  const [count, setCount] = useState(0);

  const staticTree = useMemo(() => <ExpensiveTree />, []);   // ⭐ 引用稳定

  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>{count}</button>
      {staticTree}
    </div>
  );
}

五、手段四:引用稳定 + memo

// 只有当「子组件是 memo 的」且「props 引用稳定」时,才有效
const Row = React.memo(function Row({ item, onDelete }: RowProps) {
  console.log('Row 渲染', item.id);
  return (
    <li>
      {item.name}
      <button onClick={() => onDelete(item.id)}>删除</button>
      {/* ⚠️ 这里的箭头函数是 Row 内部的,不影响 Row 自身的 memo */}
    </li>
  );
});

function List({ items, onDelete }: { items: Item[]; onDelete: (id: string) => void }) {
  // ⭐ 稳定回调引用
  const handleDelete = useCallback((id: string) => onDelete(id), [onDelete]);

  return (
    <ul>
      {items.map((it) => (
        <Row key={it.id} item={it} onDelete={handleDelete} />
        //                         ↑ 引用稳定 → 只有 item 变化时才重渲染
      ))}
    </ul>
  );
}
⚠️ 常见的「memo 白加」情况:
① props 里有内联箭头函数 → 引用变化 → 失效
② props 里有对象/数组字面量 → 失效
③ 「链路上层」没有 memo → 上层重渲染会重新创建子元素 → 失效
④ 每次渲染都会 filter/map 出新数组 → 失效

所以顺序是:先「稳定引用」(useCallback/useMemo/提到模块外),再 memo。

六、手段五:外部 store 的选择器订阅

// 场景:状态需要跨组件共享,但变化频繁(用 Context 会全量重渲染)
// ✅ 用 Zustand 的选择器订阅
const useStore = create<{ a: number; b: number }>(() => ({ a: 0, b: 0 }));

function OnlyA() {
  const a = useStore((s) => s.a);       // ⭐ 只订阅 a
  console.log('OnlyA 渲染');             // b 变化时不会渲染
  return <div>{a}</div>;
}
方案 重渲染范围
Context 所有消费者(value 变了就全量)
Redux/Zustand 选择器 只有选中值变化的组件
useSyncExternalStore + 自研选择器 细粒度(需要自己实现)

七、优化决策顺序

遇到「重渲染过多」问题时的推荐顺序(从结构到工具):

① 状态下移 —— 让状态离使用者更近(成本最低、收益最大)
② 组件拆分 —— 把「静态」「昂贵」「独立状态」的子树分开
③ children 透传 —— 把不依赖状态的子树从「内部」移到「外部」
④ 引用稳定 —— 常量提到模块外、useCallback/useMemo 稳定必要引用
⑤ React.memo —— 在前三步都做过之后再考虑(否则大概率失效)
⑥ Context 拆分 / 状态库选择器 —— 跨组件共享的场景
⑦ useTransition / useDeferredValue —— 无法减少「工作量」时,降低它的优先级
⑧ 虚拟滚动 —— 真正的大列表问题(DOM 数量是根源)
⚠️ 为什么不先上 memo?
  · memo 依赖「引用稳定」,而引用稳定又要写 useCallback/useMemo
  · 这一串操作增加了代码复杂度,而且经常「看起来加了优化,实际没生效」
  · 而「状态下移」不需要任何额外机制,只是把状态挪个位置
  → 所以结构性手段优先。

八、常见坑

// ① 只加 memo,不改结构(大概率失效)
// ② 给所有组件加 memo(增加比较开销与维护成本)
// ③ 提升状态后「忘记自己也重渲染了」
// ④ children 透传但「创建 children 的组件也在重渲染」
function App() {
  const [x, setX] = useState(0);        // ⚠️ App 自己在重渲染
  return <Container><ExpensiveTree /></Container>;   // → children 每次都重建
}
// ✅ 把有状态的组件下沉,让 App 保持稳定

// ⑤ 用「memo + 自定义比较函数」却比较错(该更新时不更新)
// ⑥ 把「状态」放到 Context 里然后期待 memo 拦截(拦不住)
// ⑦ 为了「减少重渲染」而把状态放进 ref(UI 不更新了)
// ⑧ 用「重渲染次数」而不是「耗时」判断优化效果
// ⑨ 优化了「渲染次数」但没优化「单次耗时」(瓶颈仍在)
// ⑩ 忘记「每次渲染都新建的对象/数组/函数」是最常见的失效原因
// ⑪ 用 key 变化来「避免重渲染」(其实是卸载重建,更慢)
// ⑫ 拆分过度(组件太多、props 传递混乱,可读性下降)

九、面试延伸

  1. 「怎么减少 React 的重渲染?」
    按优先级:① 状态下移——把状态放到真正使用它的组件里,缩小影响范围(成本最低、最有效);② 组件拆分——按「状态所有权」与「静态/昂贵子树」拆分;③ children 透传——把不依赖状态的子树由外层创建并传入,容器重渲染不会重建它;④ 引用稳定 + React.memo——先让 props 引用稳定(useCallback/useMemo/常量提到模块外),memo 才生效;⑤ 外部 store 的选择器订阅(跨组件高频共享时)。结构性手段优先于工具性手段。

  2. 「为什么优先『状态下移』而不是 memo?」
    因为 memo 生效需要「所有 props 引用稳定」——这又要求写 useCallback/useMemo、维护依赖数组,代码复杂度上升且很容易失效(一个内联函数就让全部白费)。而「状态下移」只是把状态声明挪到更近的组件里——不需要任何额外机制、不会失效、也不增加心智负担。它直接缩小了「重渲染的影响范围」,是性价比最高的手段。

  3. 「children 透传为什么能减少重渲染?」
    因为 React Element 是不可变对象,它的身份由「谁创建」决定。如果子树写在容器组件内部,容器每次重渲染都会创建新的元素对象 → 子树重渲染;如果子树由上层组件创建并作为 children 传入,只要上层没有重渲染,children 就是同一个元素对象 → React 发现引用相同 → 跳过整棵子树的渲染。前提是「创建 children 的那个组件本身是稳定的」。

  4. 「memo 为什么经常失效?」
    因为 memo 用的是浅比较——只要任何一个 prop 的引用变了就失效。最常见的四个原因:① 内联箭头函数(onClick={() => {}});② 对象/数组字面量(options={{ a: 1 }});③ 每次渲染新创建的派生数据(items={list.filter(...)});④ 链路上层没有 memo(上层重渲染会重新创建子元素,导致 props 变化)。所以「memo 必须配合引用稳定使用」。

  5. 「状态提升会让性能变差吗?」
    可能。状态提升到高层后,它的变化会让更多组件重渲染(传播范围更大)。比如把「搜索框的输入值」提升到 App 层,每次输入都会让 App 及整棵子树重渲染。所以原则是「状态放在「需要它的最近的公共祖先」」——不要比这个位置更高。如果共享范围确实很大(跨路由、跨页面),则应改用「Context 拆分 + 状态库选择器订阅」,而不是继续提升。

  6. 「怎么判断优化是否真的有效?」
    三条:① 用 Profiler 对比优化前后的「渲染次数」与「耗时」(不能只看次数,因为「渲染 10 次 × 0.1ms」本来就无所谓);② 看用户体验指标(输入是否跟手、交互响应时间、FPS);③ 确认没有引入新问题(如「该更新的没更新」——这是 memo/自定义比较函数最常见的副作用)。「没有测量就没有优化」——凭感觉加 memo 经常是负优化。

  7. 「useTransition 和『减少重渲染』是什么关系?」
    它们是不同层面的手段:减少重渲染(下移/拆分/children/memo)是「做更少的工作」;useTransition/useDeferredValue 是「在工作量不变的情况下,让不紧急的工作让路」——昂贵更新仍会执行,只是不阻塞紧急更新(如输入回显)。所以在「无法进一步减少工作量」时(比如必须渲染一个大列表),用它们改善可交互性;而真正的「DOM 太多」问题要靠虚拟滚动。

  8. 「大列表卡顿怎么优化?」
    分三层:① key 用稳定唯一 ID(让 React 能正确复用节点);② 行组件 memo + 稳定的 props(处理函数用 useCallback 或用 data-* + 事件委托,避免每行一个闭包);③ 虚拟滚动——只渲染可视区域的几十个节点,用占位元素撑起总高度(把 DOM 节点从「1 万」降到「几十」)。如果列表是「过滤/排序」的结果,还要注意「不要在渲染中反复 filter/sort」(用 useMemo);如果输入驱动过滤,可以用 useDeferredValue 让输入保持流畅。

一句话速记

减少重渲染的顺序是「状态下移(最优先)→ 组件拆分 → children 透传 → 引用稳定 + memo → 外部 store 选择器 → useTransition → 虚拟滚动」;结构性手段优先(memo 依赖引用稳定,容易失效、增加复杂度);children 透传有效的原理是「元素由谁创建决定身份」(前提是创建者不重渲染);memo 失效的四大原因是「内联函数、对象/数组字面量、每次新造的派生数据、链路上层没 memo」;判断优化效果看 Profiler 的耗时而非渲染次数。

赞(0) 打赏
未经允许不得转载:陌上寒 » 5.4 减少重渲染

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫