陌上人如玉
公子世无双

4.6 重渲染的触发与范围

4.6 重渲染的触发与范围

一句话:父组件重渲染时,子组件默认也会重渲染(因为父组件重新执行时会重新调用子组件函数/创建新元素)——想阻断必须用 React.memo(且 props 引用稳定)、children 透传或状态下移。

一、什么会触发重渲染

触发原因 说明
setState(本组件) state 变化 → 该组件重新执行
父组件重渲染 ⭐ 父组件重新执行时,会重新调用子组件(见下节)
Context 变化 该 Context 的所有消费者都会重渲染(memo 拦不住)
useReducer 的 dispatch 同 state 变化
forceUpdate(类组件) 强制更新
外部的 useSyncExternalStore 订阅值变化 订阅的外部数据变化
// 最常见的触发:setState
function Counter() {
  const [count, setCount] = useState(0);
  console.log('Counter 渲染');
  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
// 点击 → setCount → Counter 重新执行(打印一次)

二、什么不会触发重渲染

// ① 直接修改 state(引用未变)
const [user, setUser] = useState({ name: 'a' });
user.name = 'b';                      // ❌ 不重渲染(React 用引用比较)

// ② 修改 ref.current
const ref = useRef(0);
ref.current++;                        // ❌ 不重渲染(这是设计如此)

// ③ 修改「组件外的变量」
let external = 0;
function Comp() {
  external++;                         // ❌ 不重渲染(React 不知道)
  return <div>{external}</div>;
}

// ④ 调用 setState 但「值相同」(可能 bailout)
setCount(count);                      // ⚠️ React 用 Object.is 比较,可能跳过
setItems(items);                      // ⚠️ 引用相同 → 跳过

// ⑤ 在事件处理里改「已经渲染出来的 props 对象」
props.list.push(x);                   // ❌ 引用未变 + 违反不可变
操作 会重渲染吗
setState(新值) ✅
setState(相同原始值) ⚠️ 可能跳过(Object.is 相等)
setState(相同引用的对象) ⚠️ 跳过
setState(内容相同但新引用的对象) ✅(引用不同)
修改 ref.current ❌
修改组件外变量 ❌
直接改 state 对象的属性 ❌(且是错误用法)
父组件重渲染 ✅
订阅的 Context/外部 store 变化 ✅

三、渲染的「传播」(核心认知)

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

  return (
    <>
      <button onClick={() => setCount((c) => c + 1)}>{count}</button>
      <Child />                    {/* ⚠️ Child 也会重新执行 */}
    </>
  );
}

function Child() {
  console.log('Child 渲染');       // ⭐ 点按钮时会打印
  return <div>我没有 props</div>;
}
为什么会这样?

① 父组件重新执行 → 返回一棵新的元素树
② 新的元素树里包含 <Child />(一个新的 React Element 对象)
③ React 需要用「新的元素」和「旧的 fiber」比较,看 Child 是否需要更新
④ 但比较之前,React 必须【先渲染 Child】——即调用 Child() 拿到它的元素树

所以:父组件重渲染 → 子组件函数【会被调用】→ 打印日志、执行计算

⚠️ 注意用词:「渲染」= 函数被调用(render 阶段),
   不等于「DOM 被更新」(commit 阶段可能发现没有差异,DOM 不动)。
   所以「重渲染」不一定是问题,只有当渲染成本高时才需要优化。
一句话记忆:
  默认情况下,React 的更新是「自顶向下、层层传播」的——
  只要父组件重渲染,整棵未被 memo 拦住的子树都会重新渲染。

四、如何阻断(四种手段)

1) React.memo

const Child = React.memo(function Child({ value }: { value: number }) {
  console.log('Child 渲染');
  return <div>{value}</div>;
});

function Parent() {
  const [count, setCount] = useState(0);
  return (
    <>
      <button onClick={() => setCount((c) => c + 1)}>{count}</button>
      <Child value={1} />           {/* ✅ props 没变 → memo 跳过 → 不渲染 */}
    </>
  );
}
memo 的生效条件(缺一不可):
① 包裹了 React.memo
② 所有 props 的引用都稳定(浅比较「相等」)

⚠️ 只要有一个 props 是「每次渲染新建的对象/数组/函数」,memo 就失效:
   <Child value={1} onClick={() => {}} options={{ a: 1 }} />
                     ↑ 新函数            ↑ 新对象

2) children 透传(结构技巧)

// ❌ Provider/容器组件重渲染时,内部子树也会重渲染
function Container() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>{count}</button>
      <ExpensiveTree />                     {/* ⚠️ 会跟着重渲染 */}
    </div>
  );
}

// ✅ 把「不变的子树」作为 children 传入
function Container({ children }: { children: React.ReactNode }) {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>{count}</button>
      {children}                            {/* ⭐ 元素由外部创建 → 不会重建 */}
    </div>
  );
}

function App() {
  return (
    <Container>
      <ExpensiveTree />                     {/* ⭐ 这个元素对象在 App 里创建,引用不变 */}
    </Container>
  );
}
原理:React Element 是不可变对象,由「谁创建」决定它的身份。
  · 写在 Container 内部 → Container 每次渲染都创建新元素 → 重渲染
  · 由 App 创建并传入 → Container 渲染时 children 是「同一个对象」
    → React 发现元素相同(reference equal)→ 跳过整棵子树

3) 状态下移

// ❌ 状态在高层 → 每次输入都让整棵树重渲染
function PageBad() {
  const [query, setQuery] = useState('');
  return (
    <div>
      <SearchBar query={query} onChange={setQuery} />
      <ExpensiveChart />                  {/* ⚠️ 被无谓地重渲染 */}
    </div>
  );
}

// ✅ 状态下移到真正用它的地方
function PageGood() {
  return (
    <div>
      <SearchSection />                   {/* ⭐ 状态在内部,只影响自己 */}
      <ExpensiveChart />                  {/* ✅ 不受影响 */}
    </div>
  );
}

function SearchSection() {
  const [query, setQuery] = useState('');
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ResultList query={query} />
    </>
  );
}

4) 把「变化的部分」与「不变的部分」拆开(技术手段)

// 场景:父组件有一个「只影响自身」的状态,但需要包裹一个静态子树
function Layout({ children }: { children: React.ReactNode }) {
  const [sidebarOpen, setSidebarOpen] = useState(false);

  return (
    <div className="layout">
      <aside className={sidebarOpen ? 'open' : ''}>...</aside>
      <main>
        <button onClick={() => setSidebarOpen((v) => !v)}>切换侧栏</button>
        {children}                        {/* ⭐ children 不受 sidebarOpen 影响 */}
      </main>
    </div>
  );
}

五、React.memo 的浅比较

// memo 默认用 Object.is 逐个比较每个 prop(浅比较)
const Child = React.memo(ChildComponent);

// 自定义比较函数(返回 true 表示「相等 → 跳过渲染」)
const Child2 = React.memo(ChildComponent, (prevProps, nextProps) => {
  return prevProps.user.id === nextProps.user.id;      // ⚠️ 只比 id
  // ⚠️ 注意:返回 true 意味着「props 相等,不要重渲染」
});
props 类型 浅比较结果
原始值相同(1 === 1、'a' === 'a') ✅ 相等 → 跳过渲染
同一个对象引用 ✅ 相等
内容相同但新对象 ❌ 不等 → 重渲染
内联箭头函数 ❌ 不等 → 重渲染
children(外部创建且父未重渲染) ✅ 相等
children(父重渲染时新建的元素) ❌ 不等
// memo + 稳定引用的完整正确用法
function Parent({ items, onSelect }: { items: Item[]; onSelect: (id: string) => void }) {
  // ① 稳定函数引用
  const handleSelect = useCallback((id: string) => onSelect(id), [onSelect]);

  // ② 稳定对象引用
  const options = useMemo(() => ({ sort: 'asc' }), []);

  // ③ 稳定数组引用(如果 items 来自 props,父组件自己稳定它)
  return <Child items={items} onSelect={handleSelect} options={options} />;
}

六、验证实验(建议自己跑一遍)

// 用一个简单的组件树观察渲染范围
function App() {
  const [count, setCount] = useState(0);
  console.log('App 渲染');

  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>count: {count}</button>
      <Static />                       {/* 无 props */}
      <MemoChild value={1} />          {/* memo + 原始值 */}
      <UnstableChild onClick={() => {}} />   {/* 内联函数 props */}
      <ChildrenContainer>
        <Static />                     {/* children 透传 */}
      </ChildrenContainer>
    </div>
  );
}
组件 点按钮时是否重渲染 原因
App ✅ state 变化
Static(函数组件,无 props) ✅ 父组件重渲染 → 子组件函数被调用
MemoChild(memo + 原始值 props) ❌ props 浅比较相等
UnstableChild(内联函数 props) ✅ 函数引用每次不同 → memo 失效
ChildrenContainer 的 children ❌ 元素由 App 创建,App 重渲染时… ⚠️ 实际会重渲染(见下)
⚠️ 一个细节纠正:
  App 自己重渲染 → 它会【重新创建】<Static /> 元素
  → children 的元素对象变了 → ChildrenContainer 的 children prop 变了
  → children 里的 Static 会重渲染

所以「children 透传」生效的前提是:
  【创建 children 的那个组件本身没有重渲染】

正确的用法是「三层」:
  App(不重渲染)
    └── Container(有 state,会重渲染)
          └── children = <ExpensiveTree />(App 创建,引用不变 → 跳过)

七、常见坑

// ① 以为「子组件没有 props 就不会重渲染」
<Child />                            // ⚠️ 父组件重渲染时它依然会重渲染

// ② 加了 memo 但 props 里有内联函数/对象
<Child onClick={() => {}} />         // ⚠️ memo 失效

// ③ 用 memo 包裹「每次都要更新」的组件(memo 的成本 > 收益)
// ④ 以为「重渲染 = DOM 更新」
// → 重渲染只是「函数被调用」;diff 后可能没有任何 DOM 操作

// ⑤ 把所有状态放在顶层(导致大范围重渲染)
// ⑥ 用 Context 传高频数据(所有消费者重渲染)
// ⑦ 在渲染中创建组件(每次都是新 type → 整个子树重建)
function Parent() {
  function Child() { return <div />; }    // ❌
  return <Child />;
}

// ⑧ 认为「用 useMemo 包住 JSX 能避免重渲染」
const el = useMemo(() => <Child />, []);
return <div>{el}</div>;                // ✅ 这个技巧确实有效(元素引用稳定)
// ⚠️ 但它等价于「把元素提到组件外」的效果,不如直接用 children/拆分

// ⑨ 用 memo + 自定义比较函数却比较错(返回 true 但实际需要更新)
// ⑩ 认为「状态提升后性能更好」(恰恰相反,传播范围更大)
// ⑪ 忘记「Context 更新会穿透 memo」
// ⑫ 用「渲染计数」判断性能问题(应该用 Profiler 看耗时与次数)
// ⑧ 的说明:useMemo 包裹 JSX 是一种「局部 children 透传」
function Parent() {
  const [count, setCount] = useState(0);

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

  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>{count}</button>
      {staticTree}                     {/* ✅ 元素对象没变 → React 跳过子树 */}
    </div>
  );
}
// ⚠️ 更好的做法通常是:把 <ExpensiveTree /> 作为 children 传进来(更清晰)

八、面试延伸

  1. 「什么会触发 React 的重渲染?」
    六种:① 本组件的 setState/dispatch;② 父组件重渲染(父组件重新执行时会重新调用子组件);③ 订阅的 Context 或外部 store 变化;④ forceUpdate(类组件);⑤ useSyncExternalStore 订阅的值变化。不会触发的情况包括:修改 ref.current、修改组件外变量、直接修改 state 对象的属性(引用不变)、setState 传入「引用相同的值」(可能 bailout)。

  2. 「为什么父组件重渲染会让子组件也重渲染?」
    因为 React 的更新是「自顶向下」的:父组件重新执行 → 产生一棵新的元素树(其中包含新的 <Child /> 元素对象)→ React 需要「先用新元素重新渲染 Child(调用 Child 函数)」才能得到它的子树,然后再与旧的 fiber 比较。所以「比较之前必须先渲染」。这也是为什么「重渲染」不等于「DOM 更新」——diff 后可能发现没有差异,DOM 就不会动。

  3. 「怎么避免子组件不必要的重渲染?」
    四种手段:① React.memo——浅比较 props,相等就跳过(前提是 props 引用稳定);② children 透传——把不变的子树由「上层」创建并传入,容器组件重渲染时它不会被重建;③ 状态下移——把状态放到真正使用它的组件里,缩小重渲染范围;④ useMemo 包裹静态 JSX——让元素引用稳定(等价于局部的 children 技巧)。此外还有「拆分为多个更小的组件」与「用外部 store + 选择器订阅」。

  4. 「React.memo 什么情况下会失效?」
    当任何一个 prop 的引用发生变化时:① 内联箭头函数(onClick={() => {}});② 对象/数组字面量(options={{ a: 1 }});③ 每次渲染新建的数组(items={list.filter(...)});④ 上游传下来的不稳定引用(父组件没用 useCallback/useMemo)。因为 memo 用的是浅比较(Object.is),引用不同就认为「变了」。所以正确用法是「memo + 稳定引用」——两者缺一不可。

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

  6. 「重渲染一定是坏事吗?」
    不是。「渲染」只是函数被调用(render 阶段),之后 React 会 diff,如果没差异就不会有任何 DOM 操作。所以:① 大多数组件的渲染成本很低(几个函数调用 + 对象创建),不需要优化;② 只有「渲染成本高」(大列表、复杂计算)或「渲染次数极频繁」(每秒几十次)时才值得优化;③ 盲目加 memo 反而会增加「比较 props」的开销与代码复杂度。判断依据应该是 Profiler 测量的耗时,而不是渲染次数。

  7. 「状态提升会让性能变差吗?」
    可能会。状态提升到高层后,状态变化会让更多组件重渲染(因为传播范围更大)。比如把「搜索框的输入值」提升到 App 层,那么每次输入都会让 App 及其整棵子树重渲染。所以原则是「状态放在需要它的最近的公共祖先」——既不要过高(传播过大),也不要过低(无法共享)。如果共享范围确实很大,考虑用「状态下移 + Context 拆分 + 选择器订阅」的方式优化。

  8. 「怎么定位「哪些组件在不必要地渲染」?」
    用 React DevTools 的 Profiler:① 录制一次交互;② 看 Flamegraph(每个组件的渲染耗时)、Ranked(按耗时排序);③ 打开「Highlight updates when components render」功能,会直接在页面上高亮渲染的组件——哪个区域闪得多,哪里的渲染就频繁。另外可以在组件里临时加 console.log 或用 useEffect 记录渲染次数来快速验证。

一句话速记

触发重渲染的是「setState / 父组件重渲染 / Context 变化 / forceUpdate / 外部订阅」;改 ref.current、改组件外变量、直接改 state 属性都不会触发;父组件重渲染会让整棵未被 memo 拦住的子树也重渲染(因为「比较之前必须先渲染」),所以「重渲染 ≠ DOM 更新」;阻断四招是「React.memo(必须 props 引用稳定)、children 透传(元素由上层创建且上层未重渲染)、状态下移、useMemo 包裹静态 JSX」;优化前先用 Profiler 测量,别只看渲染次数。

赞(0) 打赏
未经允许不得转载:陌上寒 » 4.6 重渲染的触发与范围

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫