为什么 React 中使用控制反转不会触发重新渲染

2,899 阅读6分钟

控制反转,是面向对象编程中的一种设计原则,可以用来减低计算机代码之间的耦合度。

控制反转的应用

首先来看一段常见的 React 代码

import { useState } from "react"

const Father = () => {
  const [count, setCount] = useState(0);
  console.log("Father rendered");
  return (
    <>
      <p>I am Father p tag, {count}</p>
      <button onClick={() => setCount(count + 1)}>Add Count</button>
      <br />
      <Children />
    </>
  );
};

const Children = () => {
  console.log("Children rendered");
  return <p>I am Children p tag</p>;
};

这段代码最终会在屏幕上显示出几个 p 标签,打开 Chrome DevTool 中的 Console,会显示父组件和子组件在首次渲染时的打印。这里父组件和子组件分别对应 Father 和 Children,下文中统称父组件和子组件。

React 控制反转与重新渲染1.webp

点击 Add Count 按钮触发 state 更新,同时观察 Console 标签页,可以看到,Father 和 Children 组件都触发了重新渲染:

React 控制反转与重新渲染2.webp

这是因为父组件 state 更新触发重新渲染,连带子组件一起重新渲染,但是实际上在子组件中,并没有用到来自父组件的状态或是自身状态发生了变化,既然状态并没有发生变化不存在重新渲染的需要,那么这个组件更新就是没有必要的。如果子组件的渲染开销比较大,这可能会是一个比较严重的性能问题。

那么如何解决这个问题呢?可能第一反应是给子组件加上 React.memo 进行 props 浅层比较,但是秉承着能不用就不用的原则,我们探索一些其他的解决办法,比如说控制反转(Inversion of Control)

从代码中可以看到,<br /> 标签和子组件其实并不需要来自父组件的状态,仅仅是 <p> 标签和 <button> 按钮依赖 state。

我们改动一下上面的代码,在父组件和子组件之间添加一个 IOC 组件(FatherIoc):

import { useState } from "react";

const Father = () => {
  console.log("Father rendered");
  return (
    <FatherIoc>
      <br />
      <Children />
    </FatherIoc>
  );
};

const FatherIoc = ({ children }) => {
  const [count, setCount] = useState(0);
  return (
    <>
      <p>I am Father p tag, {count}</p>
      <button onClick={() => setCount(count + 1)}>Add Count</button>
      {children}
    </>
  );
};

const Children = () => {
  console.log("Children rendered");
  return <p>I am Children p tag</p>;
};

在这个 IOC 组件中,我们把原先父组件中依赖 state 的部分放在了这里,将状态进行下放,其余不依赖的部分则通过组件的 children 属性传递给 IOC 组件,这时我们再去页面上查看 DevTool:

React 控制反转与重新渲染3.webp

我们连续点击 5 次 Add Count 按钮,count 变成了 5,触发 5 次更新,但是 Console 始终只停留在页面第一次渲染时打印的信息,说明这 5 次更新子组件和父组件都没有重新渲染,目标达成。

为什么

从上面的代码中,我们并没有使用 React.memo,仅仅修改了一下代码就完成了减少页面渲染次数的目的,这种模式叫做控制反转,在这里我们先不讨论控制反转的具体释义,我们仅仅从 React 的角度看看为什么控制反转能达到减少渲染的目的。

省流描述

因为 children 来自于父组件,子组件的重新渲染并不会导致其也重新渲染

完整描述

在 React 中组件最终会被转换为一个个 Fiber 节点,这些 Fiber 节点连起来就形成了一颗 Fiber 树(或者称为 vdom 树),在这棵树中判断节点是否可以复用有以下几个条件:

  1. Fiber 节点的 type 属性是否发生变化;
  2. Fiber 节点的 props 属性是否发生变化;
  3. Fiber 节点的 state 是否发生变化;
  4. Fiber 节点的 context 是否发生变化;

如果以上的条件都为否,那么就可以判断这个节点没有发生变化不需要重新渲染,但是由于 React 并没有采用 Vue 那样的方式可以细粒度的找出哪些节点发生了更新,React 每次触发更新都会用上一次生成的旧 Fiber (current)树与 ReactElement 作对比生成新的 Fiber (workInProgress)树,过程中再判断上述几个条件,找出发生了变化的 Fiber 节点,打上标记,最后再将其更新到页面上

这样的逻辑是可行的,但是每次都从头开始对比整棵树难免会做很多没有必要的比较,听起来也不够 React。有没有一个办法可以在父节点对比时就提前知道其自身的子节点有没有发生变化,如果都没有发生变化就直接跳过后续子节点的对比流程呢?这样无疑是能省下很多不必要的操作的。

所以在 React 触发更新之后,会从当前触发更新的节点开始向上对其所有的父节点打上有子节点需要更新的标记,这时在上面提到的用于判断能否复用的 4 个条件中要加多额外的一项:子节点是否存在变化,满足原有的 4 个条件,表示当前节点并没有发生变化可以进入复用逻辑,如果连同满足额外的第 5 个条件,那么表示当前节点以及其所有的子节点都可以跳过对比直接复用。

理论讲完了,我们回到代码,先从有 IOC 组件的代码开始, 父组件(Father)中满足上述原有条件中的 4 个,但是其子组件(FatherIoc)中存在状态更新,并不满足第 5 个额外条件,所以会进入复用逻辑但不会跳过后续对比,在复用逻辑中会复制上一次更新时它的(Father)子 Fiber 节点(FatherIoc)作为本次更新的子 Fiber 节点,这个复制的过程会连带旧节点中的 props 一起赋值给新节点。

等对比进行到 FatherIoc 子组件时,此时它的 Fiber 节点是由父组件在上一次更新中直接复制的 Fiber 节点,所以 props 会完全相同,但是自身的 state 发生了变化,不会进入复用逻辑,而是重新调用生成新的 Fiber 节点。而 children 来自于父组件,并不会重新创建

等对比进行到 children 属性对应的节点也就是 <br /> 标签和 Children 节点时,发现其完全满足上述判断的条件包括第 5 个额外条件,就会跳过其所有的子节点对比直接复用。

而在没有 IOC 组件的代码中父组件(Father)自身的状态发生变化,React 会重新调用它生成新的 Fiber 节点,子组件(Children)因为被重新创建,它的 props 也会发生变化不再相等,继而触发重新渲染。在这里你可能会疑惑,为什么子组件的 props 会发生变化?明明从代码层面来看子组件的 props 都是空的,确实,子组件的 props 是空的,但是在 JSX Element 转换成 React.createElement() 形式的时候,空的 props 会作为一个空对象传给 React.createElemen()。而对象是一个引用类型,即使两者都是空对象,它们也是不相等的。

如果你对我写的内容感兴趣,也可以去我的博客看看~

参考文档