React父组件刷新导致子组件重新渲染问题

我在研究react时,碰到一个关于父组件重新渲染导致子组件重新渲染的问题:

当前代码结构如下:

import "./styles.css";
import React, { useState } from "react";

export default function App() {

  return (
    <div className="app">
      <Clicker>
        <ComponentToRender />
      </Clicker>
    </div>
  );
}

function Clicker({ children }) {
  const [count, setCount] = useState(0);

  return (
    <div className="clicker">
      <h2>You clicked {count} times!</h2>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      {children}
    </div>
  );
}

function ComponentToRender() {
  const count = React.useRef(0);

  React.useEffect(() => {
    console.log("component rendered", count.current++);
  });

  return (
    <div className="ComponentToRender">
      <p>sub</p>
    </div>
  );
}

当点击按钮时,count增加,<Clicker />组件重新渲染,同时子组件ComponentToRender并不会重新渲染

但是,当我把代码结构改成如下这样:

import "./styles.css";
import React, { useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <h2>You clicked {count} times!</h2>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <ComponentToRender />
    </div>
  );
}

function ComponentToRender() {
  const count = React.useRef(0);

  React.useEffect(() => {
    console.log("component rendered", count.current++);
  });

  return (
    <div className="ComponentToRender">
      <p>sub</p>
    </div>
  );
}

此时,count增加,<App />组件state发生变化导致重新渲染,同时子组件ComponentToRender会重新渲染

既然都是父组件状态发生变化后重新渲染,子组件也都没有使用React.memo,所以子组件本应该重新渲染。但是第一种情况<ComponentToRender />却没有重新渲染呢?

源代码地址

阅读 19.3k
3 个回答

React更新原理分析

要想知道这个问题的原因,我们得知道一些react的原理,在react中在每次更新产生后都会进行一个叫做reconciliation(协调)的过程,在其中react中会找到此次更新中fiber树发生的变化,并将他们打上一些标记,在下一个阶段中在根据这些标记对该fiber节点进行一些操作,比如当一个fiber节点被打上Placement标记就说明待会该fiber节点对应的dom节点需要被插入dom树,理所当然的如果我们想要每次更新都能迅速的完成那么我们肯定就不能傻乎乎的去比较整颗fiber树,这是一个非常耗时的操作,所以react实际在进行reconciliation的过程中使用了一种启发式的diff算法,这种算法能根据现有信息提前将一些压根没有更新的fiber子树的reconciliation过程省去,以免白白的花时间做无用工,接下来我们会简单的介绍以下这个算法,根据代码我们可以构建出这样的一颗fiber树(省略了一些底层节点)
image.png
setState后,react会将该更新从产生更新的节点上向上冒泡并一路为他的父节点打上需要更新的标记,注意这种标记还需要区分是该节点有更新还是他下面的子树中有更新,这一点非常重要。下面我们就用在这棵树中把标记更新这个过程展现出来
image.png
在图中我们把有更新的节点标记为红色,子树中存在更新的标记为绿色,由于Clicker都是HostRoot, App, div[class="app"]的节点他所以他们都需要被标记为绿色
标记完成后,react就会开始从HostRoot开始reconciliation的过程,在其中会更具该节点更新前后的props是否严格相等(Object.is)和是否被标记了更新决定是否继续进行他子树的reconciliation过程,HostRoot是个特例他是React中的一个抽象根节点,他不对应任何组件和dom节点他的props也永远都是为null,所以再进行他的diff时他的前后props永远是相等的,我们可以吧reconciliation的过程中的情况分为三种:

(1)一个节点更新前后props相等,且该节点没有更新,并且其子树中也没有更新,这种情况下以该节点为根的fiber子树的reconciliation过程就会到此为止停止diff
(2)一个节点更新前后props不相等,则继续他子节点的diff过程
(3)一个节点更新前后props相等,且他自身没有更新,但是他的子树中存在更新,这种情况要复杂一点,该节点会复用前一轮更新时他的直接子fiber节点(注意这里的直接子节点就是严格意义上的子节点,比如HostRoot的直接子节点就只有App),这种情况下会发生非常有趣的情况,由于是直接复用的节点而没有重新调用组件生成JSX元素,所以复用的节点的props就是前一轮更新是的props,所以更新前后复用节点的props总是严格相等的,光说可能还有点不太直观让我们考虑以下代码
const TriggerUpdate = () => {
  const [count, setCount] = useState(0);

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

const App = () => {
  return (
    <div id="container">
      <div
        style={{
          background: "red"
        }}
        id="static"
      >
        Static Node
        <div>Static Node</div>
      </div>
      <TriggerUpdate />
    </div>
  );
};

下面会使用jquery选择器的方式指明我们说的时哪个fiber节点比如$('#container')就表示哪个id为container的div所对应的fiber,虽然App中的TriggerUpdate会触发更新但是他冒泡的更新标记并不会影响到并不在他冒泡路径上和他同级的$('#static')节点所以他自己和他子树中都是没有更新标记的,因为也知道他的父级节点$('#container') div也没有更新,所以在创建$('#static') div对于的fiber节点时复用前一次的props,当接下来进行$('#static') diff时,由于前后props没变且它自身和子树中不包含更新,也就是上面说的第一种情况他的diff过程就会终止,即使此次更新中$('#static')对应的jsx对象的style属性是全新的对象,对于一些不会产生更新的节点react在运行时就会将他识别出来,以减少diff的工作量

问题逻辑梳理

知道上面的工作原理后,我们再来看看我们这颗fiber树的diff过程,不难看出直到Clicker进行diff是才会重新调用创建组件创建JSX元素应为在他上层的节点都满足第三种情况,但是我们可以发现ComponentToRender并没有被重新创建,应为他是在App中被创建并以props.children的形式穿给Clicker的,又应为App组件并没有被调用,他复用了他的子节点,所以实际上更新前后Clickerprops的children都是严格相等的所以当进行ComponentToRenderdiff时就会发现它属于第一种更新情况,直接结束他的diff,而你的问题的第二种情况中可就没那么幸运了,由于ComponentToRenderJSX元素App中被重新创建,所以他的props就不相等了,所以属于第二种更新情况继续他子树的diff所以ComponentToRender也会被调用所以会看到useEffect中的打印,在这种情况下,如果ComponentToRender的执行代价很高的话,就可以将这个组件包裹在memo中这时候比较他更新前后的props是否变更,就会转换为对其中的每个属性进行浅比较,而不是直接判断严格相等,这样也会停止ComponentToRenderdiff

备注

下面是对上面用到概念的一些解释

(1)fiber节点:

每一个组件或者dom都对应一个fiber节点,用他们组成的树也就是我们平时说的虚拟dom树,fiber节点由JSX元素中的信息创建而来,比如<div id="3" foo={4} />对于的jsx元素和fiber节点就是下面这样的
jsx表达式

<div id="3" foo={4} />
jsx表达式创建出来的jsx元素
{
  "type": "div",
  "key": null,
  "ref": null,
  "props": {
    "id": "3",
    "foo": 4
  }
} 

由jsx元素的信息创建出来的fiber节点的结构(不完整,只显示了部分属性),其中type中存了'div',节点是否更新和子树中是否有更新则分别存储在了laneschildLanes

type Fiber = {
  /**
   * 该fiber节点处于同级兄弟节点的第几位
   */
  index: number
  /**
   * 此次commit中需要删除的fiber节点
   */
  deletions: Fiber[] | null
  /**
   * 子树带有的更新操作,用于减少查找fiber树上更新的时间复杂度
   */
  subtreeFlags: Flags
  /**
   *一个Bitset代表该fiber节点上带有的更新操作,比如第二位为1就代表该节点需要插入
   */
  flags: Flags
  /**
   * 新创建jsx对象的第二个参数,像HostRoot这种内部自己创建的Fiber节点为null
   */
  pendingProps: any
  /**
   * 上一轮更新完成后的props
   */
  memoizedProps: any
  /**
   *其子节点为单链表结构child指向了他的第一个子节点后续子节点可通过child.sibling获得
   */
  child: Fiber | null

  /**
   * 该fiber节点的兄弟节点,他们都有着同一个父fiber节点
   */
  sibling: Fiber | null
  /**
   * 在我们的实现中只有Function组件对应的fiber节点使用到了该属性
   * function组件会用他来存储hook组成的链表,在react中很多数据结构
   * 都有该属性,注意不要弄混了
   */
  memoizedState: any
  /**
   * 该fiber节点对于的相关节点(类组件为为类实例,dom组件为dom节点)
   */
  stateNode: any

  /**
   * 存放了该fiber节点上的更新信息,其中HostRoot,FunctionComponent, HostComponent
   * 的updateQueue各不相同,函数的组件的updateQueue是一个存储effect的链表
   * 比如一个函数组件内有若干个useEffect,和useLayoutEffect,那每个effect
   * 就会对应这样的一个数据结构
   * {
   *  tag: HookFlags //如果是useEffect就是Passive如果是useLayoutEffect就是Layout
   *  create: () => (() => void) | void //useEffect的第一个参数
   *  destroy: (() => void) | void //useEffect的返回值
   *  deps: unknown[] | null //useEffect的第二个参数
   *  next: Effect
   * }
   * 各个effect会通过next连接起来
   * HostComponent的updateQueue表示了该节点所要进行的更新,
   * 比如他可能长这样
   * ['children', 'new text', 'style', {background: 'red'}]
   * 代表了他对应的dom需要更新textContent和style属性
   */
  updateQueue: unknown

  /**
   * 表示了该节点的类型,比如HostComponent,FunctionComponent,HostRoot
   * 详细信息可以查看react-reconciler\ReactWorkTags.ts
   */
  tag: WorkTag

  /**
   * 该fiber节点父节点(以HostRoot为tag的fiber节点return属性为null)
   */
  return: Fiber | null

  /**
   * 该节点链接了workInPrgress树和current fiber树之间的节点
   */
  alternate: Fiber | null

  /**
   * 用于多节点children进行diff时提高节点复用的正确率
   */
  key: string | null

  /**
   * 如果是自定义组件则该属性就是和该fiber节点关联的function或class
   * 如果是div,span则就是一个字符串
   */
  type: any

  /**
   * 表示了元素的类型,fiber的type属性会在reconcile的过程中改变,但是
   * elementType是一直不变的,比如Memo组件的type在jsx对象中为
   * {
   *  $$typeof: REACT_MEMO_TYPE,
   *  type,
   *  compare: compare === undefined ? null : compare,
   * }
   * 在经过render阶段后会变为他包裹的函数,所以在render前后是不一致的
   * 而我们在diff是需要判断一个元素的type有没有改变,
   * 以判断能不能复用该节点,这时候elementType就派上用场
   * 了,因为他是一直不变的
   */
  elementType: any

  /**
   * 描述fiber节点及其子树属性BitSet
   * 当一个fiber被创建时他的该属性和父节点一致
   * 当以ReactDom.render创建应用时mode为LegacyMode,
   * 当以createRoot创建时mode为ConcurrentMode
   */
  mode: TypeOfMode

  /**
   * 用来判断该Fiber节点是否存在更新,以及改更新的优先级
   */
  lanes: Lanes
  /**
   * 用来判断该节点的子节点是否存在更新
   */
  childLanes: Lanes
}

(1)reconciliation:

你可以把 reconciliation和平时说的diff理解成一个意思

更多

(1) 如果你看到这里还不明白的话可以去看下这个问题,我用几十行代码实现了和react启发式diff思路相同的关键代码
(2) 如果想了解更多启发式算法内容可以查看
(3) 如果想了解更多react原理,可以了解我的项目

第一种写法中
<ComponentToRender />不是<Clicker>的子组件,它是App的子组件,对于<Clicker>来说,<ComponentToRender />是他的一个属性props.children。所以<ComponentToRender />是否re-render取决于App,而App没有更新。
第二种写法,状态维护在App中,<ComponentToRender />是App的子组件,所以会刷新

这个现象的核心是<ComponentToRender />是否重新render,作为子组件被重新渲染,作为子元素没有重新渲染,子组件的owner是当前元素,子元素的owner是其所属元素,触发render在fiber中是由owner决定(新建、更新、比较等)

这个问题React的作者Dan写过一遍文章来讲解详细问题和原因,推荐阅读下

before-you-memo

推荐问题
宣传栏