前端宝典之五:React源码解析之深度剖析Diff算法

news2024/11/16 6:55:18

本文主要针对React源码进行解析,内容有:
1、Diff算法原理、两次遍历
2、Diff瓶颈及限制
3、Diff更新之单节点和多节点原理

一、Diff源码解析

以下是关于 React Diff 算法的详细解析及实例:

1、React Diff 算法的基本概念和重要性

1.1 概念

React Diff 算法是 React 用于比较虚拟 DOM 树之间差异的一种高效算法。其目的是在更新组件时,尽可能高效地找出真实 DOM 中需要更新的最小部分,从而减少不必要的 DOM 操作,提高渲染性能。
会将当前组件与该组件上次更新时对应的Fiber节点进行比较,将比较的结果生成新的Fiber节点

1.2 重要性

在前端应用中,频繁的 DOM 操作是非常昂贵的,会导致性能下降。通过 Diff 算法,React 可以在每次状态变化时,先在虚拟 DOM 层面进行比较和计算,只对实际发生变化的部分进行真实 DOM 的更新,大大提高了应用的性能和响应速度。

2、Diff 算法的原理

2.1 树的层级比较

(1) 不同类型的元素,React销毁原节点,新建节点

React 首先会对新旧两棵虚拟 DOM 树进行层级比较。如果两棵树的根节点类型不同,React 会直接销毁旧树,重新创建新树并插入到 DOM 中。
例如,旧的根节点是一个 <div>,新的根节点是一个 <span>,那么 React 会将旧的 <div> 及其子树从 DOM 中移除,然后创建新的 <span> 及其子树并插入到 DOM 中。

(2)同一层级节点比较

当根节点类型相同时,React 会进一步比较同一层级的子节点。
1、对于类型为元素的节点(如 <div><p> 等):

  • 首先比较节点的属性(如 classNamestyle 等),如果属性发生了变化,React 会更新相应的 DOM 属性。
  • 然后比较子节点。React 采用了一种高效的策略,通过给每个子节点设置唯一的 key 属性来辅助比较。

2、 key 的作用:

  • key 是用来帮助 React 识别哪些子元素发生了变化。在列表渲染中,如果子元素没有设置 key,React 会进行低效的顺序比较。当设置了 key 后,React 可以更准确、更快速地找到变化的元素。
    例如,在渲染一个列表时:
  {[1, 2, 3].map((item) => (
     <li key={item}>{item}</li>
   ))}
  • 如果列表中的元素顺序发生了变化,React 可以通过 key 快速识别出哪些元素移动了,哪些元素是新增或删除的,而不是对整个列表进行重新渲染。

3、使用index作为key有性能问题

  • 如果列表顺序发生变化,可能无法有效的复用组件
  • 删除或者增加元素时,索引改变,可能会重建元素而不是复用

3、实例分析

假设初始状态有以下组件结构:

<div id="parent">
  <p className="text">Hello</p>
  <ul>
    <li>Item 1</li>
    <li>Item 2</li>
  </ul>
</div>

当状态更新后变为:

<div id="parent">
  <p className="new-text">World</p>
  <ul>
    <li>Item 2</li>
    <li>Item 3</li>
  </ul>
</div>
  1. 树的层级比较
    根节点 <div> 类型没有变化,继续比较子节点。

  2. 子节点比较
    <p> 节点:
    类型相同,但是 className"text" 变为 "new-text",并且文本从 "Hello" 变为 "World"。React 会更新 <p> 元素的 className 和文本内容。
    <ul> 节点:
    类型相同,继续比较子 <li> 节点。
    第一个 <li> 节点:
    旧列表中的 "Item 1" 消失,React 会从 DOM 中移除对应的 <li> 元素。
    第二个 <li> 节点:
    文本从 "Item 2" 没有变化,但是位置发生了变化,React 会根据新的位置进行 DOM 操作,将其移动到新的位置。
    新增了 "Item 3"<li> 节点,React 会创建新的 <li> 元素并插入到 DOM 中。

通过这样的 Diff 算法,React 能够高效地处理组件的更新,只对实际发生变化的部分进行 DOM 操作,保证了应用的性能和用户体验。

4、相关节点

结合rendercommit阶段,一个DOM节点最多有4个节点与之相关:

  1. current Fiber。如果该DOM节点已在页面中,current Fiber代表该DOM节点对应的Fiber节点;
  2. workInProgress Fiber。如果该DOM节点将在本次更新中渲染到页面中,workInProgress Fiber代表该DOM节点对应的Fiber节点;
  3. DOM节点本身;
  4. JSX对象。即ClassComponent的render方法的返回结果,或FunctionComponent的调用结果。JSX对象中包含描述DOM节点的信息;

5、Diff的瓶颈及处理方法

diff操作本身也会带来性能损耗,React文档中提到,即使在最前沿的算法中,将前后两棵树完全比对的算法的复杂程度为 O(n^3),其中n是树中元素的数量;如果在React中使用了该算法,那么展示1000个元素所需要执行的计算量将在十亿的量级范围。这个开销实在是太过高昂;

为了降低算法复杂度,React的diff会预设三个限制:

  1. 只对同级元素进行diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React会忽略;
  2. 两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点;
  3. 开发者可以通过 key prop来暗示哪些子元素在不同的渲染下能保持稳定;

6、Diff是如何实现的

Diff的入口函数是reconcileChildFibers:会根据newChild(即JSX对象)类型调用不同的处理函数

// 根据newChild类型选择不同diff函数处理
function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
): Fiber | null {

  const isObject = typeof newChild === 'object' && newChild !== null;

  if (isObject) {
    // object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        // 调用 reconcileSingleElement 处理
      // // ...省略其他case
    }
  }

  if (typeof newChild === 'string' || typeof newChild === 'number') {
    // 调用 reconcileSingleTextNode 处理
    // ...省略
  }

  if (isArray(newChild)) {
    // 调用 reconcileChildrenArray 处理
    // ...省略
  }

  // 一些其他情况调用处理函数
  // ...省略

  // 以上都没有命中,删除节点
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}

根据同级的节点数量将Diff分为两类:

  1. 当newChild类型为object、number、string,代表同级只有一个节点
  2. 当newChild类型为Array,同级有多个节点

7、单节点diff

可以用文字来描述一个 React Diff 单节点更新的简单逻辑:

7.1 整体流程:

  1. 开始比较:
    首先获取旧的虚拟 DOM 节点和新的虚拟 DOM 节点。
    检查它们的类型是否相同(例如都是<div><p>等)。

  2. 类型相同的处理:
    如果类型相同,进一步检查属性。
    属性比较可以看作是一个属性列表的对比,对于不同的属性(如classNamestyle等)进行标记。
    然后检查子节点。
    对于子节点,如果是文本节点,对比文本内容是否变化;如果是元素节点,递归地进行同样的节点类型、属性和子节点的比较。

  3. 类型不同的处理:
    如果类型不同,直接标记该节点及其子树需要完全更新。
    旧节点将从 DOM 中卸载,新节点将创建并插入到 DOM 中。

大致可以用以下的流程图来表示(简化版):

开始
|
V
节点类型相同?
|---- 否 ----> 卸载旧节点,创建新节点插入DOM
|
V
属性有变化?
|---- 否 ----> 不操作属性
|
V
检查子节点
|
V
子节点变化?
|---- 否 ----> 不操作子节点
|
V
结束

通过这个流程图也可以解释单节点diff的过程:
在这里插入图片描述

以下是 React Diff 单节点更新的原理及实例解析:

7.2 实例

假设初始有以下组件:

function App() {
    return (
        <div id="myDiv" className="oldClass">
            <p>Initial Text</p>
        </div>
    );
}

当状态更新后变为:

function App() {
    return (
        <div id="myDiv" className="newClass">
            <p>Updated Text</p>
        </div>
    );
}
  1. 节点类型检查:
    前后都是<div>,节点类型未变,继续下一步。

  2. 属性比较:
    id属性未变。
    classNameoldClass变为newClass,React 会更新 DOM 中<div>元素的className属性。

  3. 子节点比较:
    <p>标签仍然存在。
    文本从Initial Text变为Updated Text,React 会更新<p>元素中的文本内容。

通过这样的过程,React 在 Diff 算法中对单节点进行了有效的更新,只更新了发生变化的部分,避免了不必要的 DOM 操作,从而提高了性能。

8、多节点diff

8.1 概述

对于多节点的functionComponent,reconcileChildFibers的newChild参数类型为Array,执行reconcileChildrenArray

if (isArray(newChild)) {
    // 调用 reconcileChildrenArray 处理
    // ...省略
}

同级多个节点的diff,归纳为:

  1. 节点更新
// 更新前
<ul>
  <li key="0" className="before">0<li>
  <li key="1">1<li>
</ul>

// 更新后 情况1 —— 节点属性变化
<ul>
  <li key="0" className="after">0<li>
  <li key="1">1<li>
</ul>

// 更新后 情况2 —— 节点类型更新
<ul>
  <div key="0">0</div>
  <li key="1">1<li>
</ul>
  1. 节点新增或减少
// 更新前
<ul>
  <li key="0">0<li>
  <li key="1">1<li>
</ul>

// 更新后 情况1 —— 新增节点
<ul>
  <li key="0">0<li>
  <li key="1">1<li>
  <li key="2">2<li>
</ul>

// 更新后 情况2 —— 删除节点
<ul>
  <li key="1">1<li>
</ul>
  1. 节点位置变化
// 更新前
<ul>
  <li key="0">0<li>
  <li key="1">1<li>
</ul>

// 更新后
<ul>
  <li key="1">1<li>
  <li key="0">0<li>
</ul>

前提:操作优先级一样,但实际开发中,React团队发现,相较于新增和删除,更新组件发生的频率更高。所以Diff会优先判断当前节点是否属于更新。

8.2 思路

react团队提供的思路:2轮遍历

  1. 处理 更新 的节点;
  2. 处理非 更新 的节点;
    在这里插入图片描述

8.3 第一轮遍历

  1. let i = 0,遍历newChildren,将newChildren[i]与oldFiber比较,判断DOM节点是否可复用;
  2. 如果可复用,i++,继续比较newChildren[i]与oldFiber.sibling,可以复用则继续遍历;
  3. 如果不可复用,分两种情况:
  • key不同导致不可复用,立即跳出整个遍历,第一轮遍历结束;
  • key相同type不同导致不可复用,会将oldFiber标记为DELETION,并继续遍历;
  1. 如果newChildren遍历完(即 i === newChildren.length - 1 )或者oldFiber遍历完(即oldFiber.sibling === null),跳出遍历,第一轮遍历结束;

8.4 第二轮遍历

  1. newChildren 和 oldFiber 同时遍历完
    不需要第二轮的遍历,直接进行 update,diff结束;

  2. newChildren没遍历完,oldFiber遍历完
    已有的DOM节点都对比结束,这时还有新加入的节点,意味着本次更新有新节点插入,我们只需要遍历剩下的newChildren为生成的workInProgress fiber依次标记Placement;

  3. newChildren遍历完,oldFiber没遍历完
    本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的oldFiber,依次标记Deletion;

  4. newChildren与oldFiber都没遍历完
    意味着有节点更新了位置

😖如何处理更新后的节点,将oldFiber放入map数据结构中
我们将所有还未处理的oldFiber存入以index为key,oldFiber为value的Map中

let existingChildren= new Map();
existingChildren.set(index, oldFiber);

接下来遍历剩余的newChildren,通过newChildren[i].index就能在existingChildren中找到key相同的oldFiber

😖标记节点是否移动
本次更新中节点是按newChildren的顺序排列。在遍历newChildren过程中,每个遍历到的可复用节点一定是当前遍历到的所有可复用节点中最靠右的那个,即一定在lastPlacedIndex对应的可复用的节点在本次更新中位置的后面;
所以只需要比较遍历到的可复用节点在上次更新时是否也在lastPlacedIndex对应的oldFiber后面,就能知道两次更新中这两个节点的相对位置改变没有;
我们用变量oldIndex表示遍历到的可复用节点在oldFiber中的位置索引。
如果oldIndex < lastPlacedIndex,代表本次更新该节点需要向右移动

8.5 代码示例

// 之前
abcd

// 之后
acdb

===第一轮遍历开始===
a(之后)vs a(之前)  
key不变,可复用
此时 a 对应的oldFiber(之前的a)在之前的数组(abcd)中索引为0
所以 lastPlacedIndex = 0;

继续第一轮遍历...

c(之后)vs b(之前)  
key改变,不能复用,跳出第一轮遍历
此时 lastPlacedIndex === 0;
===第一轮遍历结束===

===第二轮遍历开始===
newChildren === cdb,没用完,不需要执行删除旧节点
oldFiber === bcd,没用完,不需要执行插入新节点

将剩余oldFiber(bcd)保存为map

// 当前oldFiber:bcd
// 当前newChildren:cdb

继续遍历剩余newChildren

key === c 在 oldFiber中存在
const oldIndex = c(之前).index;
此时 oldIndex === 2;  // 之前节点为 abcd,所以c.index === 2
比较 oldIndex 与 lastPlacedIndex;

如果 oldIndex >= lastPlacedIndex 代表该可复用节点不需要移动
并将 lastPlacedIndex = oldIndex;
如果 oldIndex < lastplacedIndex 该可复用节点之前插入的位置索引小于这次更新需要插入的位置索引,代表该节点需要向右移动

在例子中,oldIndex 2 > lastPlacedIndex 0,
则 lastPlacedIndex = 2;
c节点位置不变

继续遍历剩余newChildren

// 当前oldFiber:bd
// 当前newChildren:db

key === d 在 oldFiber中存在
const oldIndex = d(之前).index;
oldIndex 3 > lastPlacedIndex 2 // 之前节点为 abcd,所以d.index === 3
则 lastPlacedIndex = 3;
d节点位置不变

继续遍历剩余newChildren

// 当前oldFiber:b
// 当前newChildren:b

key === b 在 oldFiber中存在
const oldIndex = b(之前).index;
oldIndex 1 < lastPlacedIndex 3 // 之前节点为 abcd,所以b.index === 1
则 b节点需要向右移动
===第二轮遍历结束===

最终acd 3个节点都没有移动,b节点被标记为移动

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2054196.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

非专业人员该学什么程序语言

编程&#xff0c;一度被认为和驾驶一样是一项现代社会的基本技能&#xff0c;非专业人员也该有所掌握&#xff0c;中小学也在教。但实际上&#xff0c;它的普及程度远比驾驶差&#xff0c;掌握这个技能的人很少&#xff0c;在学校学过的知识&#xff0c;因为工作中用不上也都忘…

一文弄懂评分卡是什么

在最开始的信用审批过程中,客户的信用等级主要由专家进行主观评判。随着数据分析工具的发展和数据收集、存储越来越容易,各大机构逐渐使用统计模型将专家的评判标准量化为评分卡模型。从而更有利于客观评价客户风险,和批量高效对客户进行风险分级。随着技术的发展,机器学习…

力扣经典题目~快乐数~零基础也能看懂哦

202. 快乐数https://leetcode.cn/problems/happy-number/ 题目描述&#xff1a; 编写一个算法来判断一个数 n 是不是快乐数。 「快乐数」 定义为&#xff1a; 对于一个正整数&#xff0c;每一次将该数替换为它每个位置上的数字的平方和。然后重复这个过程直到这个数变为 1&…

MyBatisX逆向工程

目录 逆向工程 准备好数据库、表 安装MyBatisX插件 项目连接数据库 引入依赖pom.xml 生成实体类、映射文件、接口 逆向工程 正向工程&#xff1a;先创建Java实体类&#xff0c;由框架负责根据实体类生成数据库表。Hibernate是支持正向工程的。 逆向工程&#xff1a;先创…

晶体管电路设计学习(一)放大电路的工作

我们这里学习晶体管电路设计&#xff0c;会从晶体管到场效应管直到复杂的运放器件&#xff0c;主要是进行体系化的深入学习&#xff0c;只是一个学习和记录的过程。 放大电路的作用是将小信号放大为大信号。例如,将0.1V的信号提高为1V 信号----即是放大。 1.首先,用晶体管组成一…

TinyC编译器4—编译器基本流程

1.什么是编译器&#xff0c;为什么要开发编译器 编译器&#xff1a;将一种程序语言翻译为另一种程序语言的计算机程序。一般来说&#xff0c;源程序为高级语言&#xff0c;而目标语言则是汇编语言或者机器码。 一开始的程序员是用机器码写程序&#xff0c;非常容易出错&#…

UE5中制作箭头滑动转场

通过程序化的方式&#xff0c;可以制作一些特殊的转场效果&#xff0c;如箭头划过的转场&#xff1a; 1.制作思路 我们知道向量点积可以拿来做投影&#xff0c;因此可以把UV空间想象成向量坐标&#xff0c;绘制结果就是在某个向量上的投影&#xff1a; 绘制结果似乎是倾斜方…

【ISAC】Federated Edge Learning With Misaligned Over-The-Air Computation

[1]-Tse, David, and Pramod Viswanath. Fundamentals of wireless communication. Cambridge university press, 2005. 文章目录 1-综述2-系统模型 1-综述 misaligned OAC&#xff1a;预编码矩阵&#xff08;含噪声&#xff09; 没同步好 2-系统模型 θ ∈ R d \theta \in\m…

云计算实训31——playbook(剧本)基本应用、playbook常见语法、playbook和ansible操作的编排

playbook(剧本): 是ansible⽤于配置,部署,和管理被控节点的剧本。⽤ 于ansible操作的编排。 使⽤的格式为yaml格式 一、YMAL格式 以.yaml或.yml结尾 ⽂件的第⼀⾏以 "---"开始&#xff0c;表明YMAL⽂件的开始(可选的) 以#号开头为注释 列表中的所有成员都开始于…

耐氟化氢PFA蒸馏冷凝装置PFA烧瓶应用于氟化工半导体行业领域

氟化氢&#xff0c;化学式为 HF&#xff0c;是一种无色、有刺激性气味的气体&#xff0c;它在空气中会形成白色的雾。氟化氢具有很强的腐蚀性&#xff0c;能够侵蚀许多金属和非金属材料。这种腐蚀性使得氟化氢在工业上被用于蚀刻玻璃、清洗半导体器件以及加工金属等领域。 氟化…

Ubuntu | 更换 Solc 版本

目录 第一步&#xff1a;安装 pip3第二步&#xff1a;安装 solc-select第三步&#xff1a;查看可安装版本第四步&#xff1a;安装指定版本第五步&#xff1a;使用指定版本 前言&#xff1a;部署智能合约时报错&#xff0c;发现是 Solc 版本太高。 参考博客&#xff1a;Solc 安…

Spring Boot整合Quartz框架

说明&#xff1a;Quartz是一个定时器框架&#xff0c;可以实现定时任务&#xff0c;本文介绍如何在Spring Boot项目中整合Quartz框架&#xff0c;Quartz介绍参看下面这篇文章&#xff1a; 【Quartz】Quartz定时任务框架 创建Demo 首先&#xff0c;创建一个Spring Boot项目&a…

Qt Creator安装配置指南

1.官网下载在线安装包 官网地址&#xff1a; https://www.qt.io/download-dev#eval-form-modal 2.双击在线安装包按引导流程安装qt 3.选择自己要配置的qt环境版本 3.1如果要选中低版本的qt环境我这里安装的是qt5.15.2的(其他低版本也一样的)&#xff0c;要勾选上Archive(存…

拓展销售网络:立即领取企元数智小程序合规分销系统!

"拓展销售网络&#xff1a;立即领取企元数智小程序合规分销系统&#xff01;"企业的销售网络是企业成长和发展的关键&#xff0c;而企元数智小程序合规分销系统能帮助您快速拓展销售网络&#xff0c;实现销售业绩的持续增长。 通过领取企元数智小程序合规分销系统&am…

2024软件测试八股文【答案解析+文档】

&#x1f345; 点击文末小卡片&#xff0c;免费获取软件测试全套资料&#xff0c;资料在手&#xff0c;涨薪更快 Part1 1、你的测试职业发展是什么&#xff1f; 测试经验越多&#xff0c;测试能力越高。所以我的职业发展是需要时间积累的&#xff0c;一步步向着高级测试工程师…

Go语言导入gin包

访问https://pkg.go.dev/页面,输入gin 点击README&#xff0c;点击Getting started&#xff0c;点击Getting Gin。 以VSCode通过mod命令导入gin包为例 安装第三方库 go mod init go mod tidy运行成功创建go.mod文件 go get -u github.com/gin-gonic/gin创建Go项目&#xf…

养猫换毛季总结,希喂、小米宠物空气净化器功能测评,真实PK

猫咪作为小家中的一员&#xff0c;陪伴我们度过了非常多时光。而养猫一定会面临换毛季的问题&#xff0c;在换毛季期间&#xff0c;宠物会大量掉毛&#xff0c;不仅破坏家里的整洁&#xff0c;而且还可能被猫咪误吞&#xff0c;导致毛球症。这需要我们铲屎官选找到有效的清理毛…

Spring cloud alibaba(二)RibbonLoadBalance

一、负载均衡 其含义就是将负载&#xff08;工作任务&#xff09;进行平衡、分摊到多个操作单元上进行运行&#xff1b;&#xff08;就是客户端调用服务提供方时的如何调用多个实例的策略&#xff09; 1、主流负载均衡的方案 集中式负载均衡&#xff1a;在消费者和服务提供方中…

使用excalidraw搭建自己的中文手写画板

使用excalidraw搭建自己的中文手写画板 成品预览地址&#xff1a;https://guizimo.github.io/excalidraw/ 原excalidraw提供了英文的手写体&#xff0c;但中文还是正正方方的&#xff0c;感觉不搭。希望中文也可以有那样一种手写风格。 本文使用的是excalidraw&#xff0c;它…

ArchWsl 运行图形界面程序

最新的WSL2已经支持图形界面&#xff08;wslg&#xff09;了&#xff0c;这里教大家运行GUI应用&#xff08;桌面环境同理&#xff0c;但是我建议大家不要安装桌面环境&#xff0c;没有桌面环境也可以单独运行GUI应用&#xff09; 更新WSL 建议更新到最新版本&#xff0c;早期…