第 15 章

Re-render 原理:React 何时重新渲染以及如何阻止

第15章:Re-render 原理:React 何时重新渲染以及如何阻止

理解'渲染不等于 DOM 更新'是把握 React 性能的起点。频繁渲染消耗 CPU,但并不总是需要阻止。

本章核心问题:React 重渲染由哪四个条件触发?如何区分'渲染'和'DOM 更新'? 读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

渲染的本质

很多开发者把"渲染"和"DOM 更新"混为一谈。这是理解 React 性能的最大误区。

React 的"渲染"是指调用你的组件函数,得到一棵描述 UI 的虚拟 DOM 树(React Element 树)。这个过程发生在内存里,不涉及任何真实 DOM 操作。只有当 React 对比新旧两棵虚拟树(Diffing),发现真正有差异的节点,才会把差异批量提交到真实 DOM。

这意味着:渲染不等于绘制。一次渲染可能完全不产生 DOM 变化,但它仍然消耗 CPU 时间——执行函数体、分配对象、遍历子树。当这个消耗积累到帧时间(16.67ms @ 60fps)以上,页面就会掉帧。

理解这个区别,才能正确定位性能问题:不是"DOM 太慢",而是"组件函数被调用太频繁或执行太慢"。

触发重新渲染的四种条件

条件一:setState 调用

最直觉的触发源。无论是 useState 的 setter、useReducer 的 dispatch,还是类组件的 this.setState,只要状态值变化,组件就会重新渲染。

function Counter() {
  const [count, setCount] = useState(0);
  console.log('Counter rendered'); // 每次 setCount 都会打印

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

关键细节:React 会做一次 Object.is 比较。如果新值和旧值相同,React 会跳过渲染(bailout)。

const [user, setUser] = useState({ name: 'Alice' });

// ❌ 即使内容相同,引用不同,依然触发渲染
setUser({ name: 'Alice' });

// ✅ 引用相同,React bailout,不渲染
setUser(prev => prev);

条件二:父组件重新渲染

这是最容易被忽略的触发源,也是"不必要渲染"的主要来源。

默认行为:父渲染,所有子组件无条件跟着渲染。

function Parent() {
  const [tick, setTick] = useState(0);
  return (
    <div>
      <button onClick={() => setTick(t => t + 1)}>Tick</button>
      <ExpensiveChild />  {/* tick 变化时,它也会重新渲染 */}
    </div>
  );
}

function ExpensiveChild() {
  console.log('ExpensiveChild rendered'); // 每次 tick 变化都打印
  return <div>我从不变化</div>;
}

原因:每次 Parent 渲染,<ExpensiveChild /> 这个 JSX 表达式都创建了一个新的 React Element 对象{ type: ExpensiveChild, props: {} }),React 看到 props 对象是全新的引用,默认走渲染流程。

条件三:Context 变化

Context.Providervalue 发生变化时,所有订阅了该 Context 的组件(通过 useContext)都会重新渲染,不管它们在组件树的哪个位置,也不管中间组件有没有用 React.memo

const ThemeContext = createContext({ color: 'blue' });

function App() {
  const [theme, setTheme] = useState({ color: 'blue' });

  return (
    // ❌ 每次 App 渲染,value 是新对象,所有消费者都重渲
    <ThemeContext.Provider value={theme}>
      <DeepChild />
    </ThemeContext.Provider>
  );
}

这里有一个陷阱:value={{ color: 'blue' }}value={theme} 行为不同。前者每次渲染都创建新对象,后者只在 setTheme 时才变。

条件四:forceUpdate(类组件)

类组件专有。调用 this.forceUpdate() 会跳过 shouldComponentUpdate,强制渲染。函数组件没有对应 API,但可以用 useReducer 模拟:

const [, forceUpdate] = useReducer(x => x + 1, 0);
// forceUpdate() 触发渲染

实际工程中极少需要,通常是响应外部(非 React 管理的)数据变化时的兜底方案。


Level 2 · 它是怎么运行的(3-5年经验)

渲染树的传播规则

React 的渲染传播遵循自上而下单向传播原则:父组件渲染时,其所有子组件默认也会渲染。这条规则不是 Bug,而是 React 的保守设计——它无法预知子组件是否依赖父组件的某个隐式状态。

传播会沿着组件树一直向下,直到:

  1. 遇到叶子节点(没有子组件)
  2. 遇到 bailout 条件(组件决定跳过渲染)

React 的 Bailout 优化

Bailout 是 React 内部的术语,指"跳过当前子树的渲染"。触发 bailout 的条件:

同引用 = 跳过子树。当 React 在新的渲染中发现某个子树的根节点 Element 与上次渲染的引用完全相同===),它会跳过整个子树。

function Parent() {
  const [count, setCount] = useState(0);

  // 把 element 提取到变量,引用在渲染间保持稳定
  const child = useMemo(() => <ExpensiveChild />, []);

  return (
    <div>
      {count}
      {child} {/* 引用不变,bailout! */}
    </div>
  );
}

这就是 React.memouseMemouseCallback 的底层逻辑基础——它们都在想办法保持引用稳定,让 React 有机会做 bailout。


Level 3 · 规范怎么定义的(资深)

用数字量化"不必要渲染"的代价

理论上"不必要渲染不好",但实际代价有多大?用 React Profiler 测量一个真实场景:

场景:一个包含 50 个 <ListItem> 的列表,父组件每秒刷新一次(计时器)
每个 ListItem:渲染耗时约 0.3ms,无任何副作用

无优化:
  每秒触发 50 次不必要渲染
  每秒额外 CPU 时间:50 × 0.3ms = 15ms
  占 60fps 帧时间(16.67ms)的 90%
  → 明显感知到的卡顿

加 React.memo 后:
  只有数据变化的 ListItem 重渲(假设每秒 2 个)
  每秒额外 CPU 时间:2 × 0.3ms = 0.6ms
  → 完全流畅

单个组件 0.3ms 看似微小,但在大型列表、高频更新的场景下,积累效应非常显著。这就是为什么"不必要渲染"值得关注,但也不是每个组件都需要 memo——优化有成本,草率 memo 可能让代码复杂度上升而收益为零。

有针对性的预防策略

明确了问题的根源,才能选择正确的工具。

策略一:状态下移(State Colocation)

最被低估的优化手段。如果一个状态只被某个子树使用,把它移进那个子树,避免污染父级。

// ❌ 状态在父级,SearchInput 的每次输入都导致 HeavyList 重渲
function Page() {
  const [query, setQuery] = useState('');
  return (
    <>
      <SearchInput value={query} onChange={setQuery} />
      <HeavyList />  {/* 被无辜拖累 */}
    </>
  );
}

// ✅ 状态内聚,HeavyList 完全不受影响
function SearchSection() {
  const [query, setQuery] = useState('');
  return <SearchInput value={query} onChange={setQuery} />;
}

function Page() {
  return (
    <>
      <SearchSection />
      <HeavyList />  {/* 安然无恙 */}
    </>
  );
}

策略二:内容提升(Children as Props)

通过 children 传递,可以让 React 的 bailout 机制发挥作用:

// ✅ HeavyChild 作为 children 传入,父级状态变化不触发它重渲
function AnimatedWrapper({ children }) {
  const [frame, setFrame] = useState(0);
  useEffect(() => {
    const id = setInterval(() => setFrame(f => f + 1), 16);
    return () => clearInterval(id);
  }, []);

  return <div style={{ opacity: frame % 2 === 0 ? 1 : 0.9 }}>{children}</div>;
}

// 使用时:
<AnimatedWrapper>
  <HeavyChild />  {/* AnimatedWrapper 的状态变化不触发 HeavyChild 重渲 */}
</AnimatedWrapper>

原因:<HeavyChild /> 这个 Element 在 App 组件中创建,App 不重渲,这个 Element 的引用就保持稳定,React bailout。

策略三:React.memo 包裹

当组件接受 props 且父组件频繁重渲时,React.memo 可以阻断传播:

const MemoizedItem = React.memo(function Item({ id, name }) {
  return <li>{id}: {name}</li>;
});

注意:React.memo 只做浅比较(每个 prop 用 Object.is)。如果 prop 是对象或函数且每次渲染都是新引用,memo 无效。

策略四:避免过早优化

并非所有重渲都需要阻止。以下情况通常不值得优化:

优化的正确顺序:测量 → 确认瓶颈 → 选择最简单的解法 → 再次测量验证。跳过测量步骤的"优化"往往是在制造技术债务。


Level 4 · 边界与陷阱(所有人)

诊断:如何发现真正的问题

在开始优化之前,先用工具确认问题存在。

方法一:React DevTools 高亮

在 React DevTools 的 Settings 中开启 "Highlight updates when components render"。每次渲染,组件会有彩色边框闪烁。颜色越深(从蓝到红),渲染越频繁。

方法二:console.log 标记法

function SuspectedComponent({ data }) {
  // 开发环境快速验证
  if (process.env.NODE_ENV === 'development') {
    console.count('SuspectedComponent render');
  }
  return <div>{data.name}</div>;
}

方法三:React Profiler API

import { Profiler } from 'react';

<Profiler id="MyList" onRender={(id, phase, actualDuration) => {
  console.log(`${id} [${phase}]: ${actualDuration.toFixed(2)}ms`);
}}>
  <MyList />
</Profiler>

actualDuration 是本次渲染及其子树花费的时间(毫秒)。phase"mount""update"

本章评分
4.8  / 5  (18 评分)

💬 留言讨论