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

2024-04-15
26509 分钟
...

React更新原理分析

要想知道这个问题的原因,我们得知道一些react的原理,在react中在每次更新产生后都会进行一个叫做reconciliation(协调)的过程,在其中react中会找到此次更新中fiber树发生的变化,并将他们打上一些标记,在下一个阶段中在根据这些标记对该fiber节点进行一些操作,比如当一个fiber节点被打上Placement标记就说明待会该fiber节点对应的dom节点需要被插入dom树,理所当然的如果我们想要每次更新都能迅速的完成那么我们肯定就不能傻乎乎的去比较整颗fiber树,这是一个非常耗时的操作,所以react实际在进行reconciliation的过程中使用了一种启发式的diff算法,这种算法能根据现有信息提前将一些压根没有更新的fiber子树的reconciliation过程省去,以免白白的花时间做无用工,接下来我们会简单的介绍以下这个算法,根据代码我们可以构建出这样的一颗fiber树(省略了一些底层节点)

setState后,react会将该更新从产生更新的节点上向上冒泡并一路为他的父节点打上需要更新的标记,注意这种标记还需要区分是该节点有更新还是他下面的子树中有更新,这一点非常重要。下面我们就用在这棵树中把标记更新这个过程展现出来

在图中我们把有更新的节点标记为红色,子树中存在更新的标记为绿色,由于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会触发更新但是他冒泡的更新标记并不会影响到并不在他冒泡路径上和他同级的$

KaTeX parse error: Expected 'EOF', got '#' at position 3: ('#̲container')就表示哪…

('#static')节点所以他自己和他子树中都是没有更新标记的,因为也知道他的父级节点$('#container') div也没有更新,所以在创建$

KaTeX parse error: Expected 'EOF', got '#' at position 3: ('#̲container') div…

('#static') div对于的fiber节点时复用前一次的props,当接下来进行$('#static') diff时,由于前后props没变且它自身和子树中不包含更新,也就是上面说的第一种情况他的diff过程就会终止,即使此次更新中$

KaTeX parse error: Expected 'EOF', got '#' at position 3: ('#̲static')

('#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原理,可以了解我的项目

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
react2025-12-02
useEffect与useLayoutEffect对比
在 React Hooks 中, 和 都用于处理副作用逻辑,例如订阅、事件监听、DOM 操作等。但它们有着本质性的执行时机差异,这影响到页面渲染、布局抖动、性能表现等关键点。 1\. 执行时机的核心区别 在 React 的渲染流程中: 1. 渲染(Render phase) React 根据 state/props 计算 UI,生成虚拟 DOM,不访问真实 DOM。 2. 提交(Commit phase) React 将虚拟 DOM 变...
学习
react2025-08-03
在 React 中实现倒计时功能会有什么坑
倒计时 倒计时是一个非常常见的业务场景,但是在 React 中实现起来,却不算简单。 首先我们来看这段倒计时代码,它能否正常执行? 来看看实际表现效果 可以看到计时器在不断执行,但是 的值却没有变。 这是一个很经典的问题:React 闭包陷阱 。 我们来详细分析下: 的依赖数组 是空的,这意味着 effect 只会在组件挂载时执行一次,而不会在 状态更新时重新执行。所以 回调函数中捕获的 值始终是初始值 。 这个问题解决起来也很简单,有...
学习面试
react2025-07-30
React性能优化三剑客:memo、useMemo和useCallback详解
前言 在React开发中,性能优化是一个永恒的话题。今天我们就来深入探讨React提供的三个重要性能优化工具: 、 和 ,它们如何帮助我们构建更高效的React应用。 1\. 为什么需要性能优化? 🤔 React的核心机制是当组件的state或props发生变化时,组件会重新渲染。但有时这种重新渲染是不必要的: 父组件更新导致所有子组件重新渲染 ,即使子组件的props没有变化 复杂计算在每次渲染时重复执行 ,消耗大量资源 函数引用在...
学习面试
react2025-06-05
React 中 useDeferredValue 和 startTransition 的核心区别与使用场景
React 中 和 ,两者都用于优化性能,但适用场景和实现方式不同。 核心概念解析 1\. useDeferredValue:延迟值更新 作用 :告诉 React 延迟更新某个值 ,直到所有高优先级渲染任务完成。 适用场景 :当某个值的更新会触发计算密集型渲染(如复杂列表、图表),但需要优先保证其他高优先级 UI(如输入框)的响应。 机制 : 保持旧值显示,后台计算新值。 计算完成后,用新值更新 UI,期间允许用户继续交互。 示例 :...
学习
react2024-12-18
Antd 样式覆盖
&nbsp; 目前作者所在的业务正在升级 Antd5.0,不得不说 Antd5.0 真的太香了,但是由于团队的 UE 规范进行了大改版,Antd5.0 所有的 必须对齐最新的规范,因此就需要对 Antd5.0 组件做主题定制。 现状 目前针对项目中使用的 antd 高频组件进行了梳理,并且根据规范基于 design token 对这些高频组件进行样式定制,对于主题色、圆角、边框、字体等用户 高的主题都已经满足团队规范。 虽然 Desig...
学习
react2024-12-06
React 19 终于发布,一大波新功能正式升级
这篇文章主要介绍了 React 19 的新功能,包括 Actions 自动处理数据突变相关状态,新增的 useActionState、useFormStatus、useOptimistic 等 hook,新的 use API 读取渲染资源,新的 React DOM 静态 API 生成静态站点,以及 React 服务器组件中的 RSC 和 RSA 等内容。同时提到后续会分享针对旧版功能的优化和改良。 关联问题: React 19 性能如何...
学习面试

评论

请登录后发表评论

去登录
加载评论中...

目录