Samoy的小窝


一只默默工作的程序猿


欢迎光临Samoy的小屋

React深入理解

React自从 React 16 版本引入 Fiber 架构后,渲染过程变得更加精细和可控。整个更新流程主要可以分为三个关键模块:Scheduler(调度器)、Reconciler(协调器) 和 Renderer(渲染器)。

关键模块

Scheduler(调度器)

  • 调度器是React内部的一个核心模块,它负责决定何时以及如何执行更新任务。在React应用中,当状态发生变化时,会触发重新渲染,而调度器的作用就是根据任务优先级将这些更新任务排队。

  • Scheduler 遵循可中断的工作循环模型,允许高优先级的任务中断低优先级的渲染任务,从而确保了用户交互等重要操作不会被阻塞。例如,它可以确保动画帧请求等具有时间敏感性的任务能够得到及时处理。

  • Scheduler 还会考虑浏览器环境的特性,如使用requestIdleCallback API 在浏览器空闲时段进行批处理更新,以避免阻塞主线程并提高性能。

Reconciler(协调器)

  • 协调器在React 16及以后版本中对应Fiber架构,它的核心工作是遍历React组件树,通过算法找出需要更新的组件,并创建或更新对应的Fiber节点。

  • 算法会对比新旧虚拟DOM树,标记出有差异的部分,而不是盲目地整体刷新UI。这个过程中,每个Fiber节点代表一个可中断的单元,使得React能够在任何阶段暂停和恢复渲染过程。

  • 协调器会生成更新计划,记录下所有需要变更的状态和DOM操作,但并不直接修改DOM,而是将这些信息传递给Renderer。

Renderer(渲染器)

  • 渲染器负责实际的DOM操作。它依据协调器提供的更新计划,在适当的时候将React组件树映射到真实的DOM结构上,执行实际的DOM更新操作。

  • 在不同的环境中,比如浏览器环境和服务器环境,甚至原生移动应用环境,Renderer会有不同的实现。例如 ReactDOM 是用于浏览器环境的渲染器,而 React Native 则提供了针对原生移动平台的渲染器。

虚拟DOM

1. 什么是虚拟DOM?

虚拟DOM(Virtual DOM)是一种用JavaScript对象结构来表示网页DOM树结构的技术,它模拟了真实DOM节点的属性和层级关系。

2. 为什么使用虚拟DOM而不使用真实DOM?

  • 真实DOM的操作代价高昂,尤其是当页面结构复杂时,频繁的添加、删除和修改DOM节点会导致重绘与回流,这会严重影响浏览器渲染性能。
  • 虚拟DOM通过在内存中进行计算和比较,找出需要更新的真实DOM部分,然后批量地应用这些更改到真实DOM。这样可以减少不必要的DOM操作,从而提高页面渲染速度。
  • 虚拟DOM其实是一个JavaScript对象,能够应用于不同的平台。只需替换对应的Renderer即可实现“一次编写,多处运行”。

渲染过程

  • Reconciler工作的阶段被称为render阶段。因为在该阶段会调用组件的render方法。
  • Renderer工作的阶段被称为commit阶段。commit阶段会把render阶段提交的信息渲染在页面上。
  • render与commit阶段统称为work,即React在工作中。相对应的,如果任务正在Scheduler内调度,就不属于work。

Render阶段

render阶段开始于performSyncWorkOnRootperformConcurrentWorkOnRoot方法的调用。这取决于本次更新是同步更新还是异步更新。

// performSyncWorkOnRoot会调用该方法
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

// performConcurrentWorkOnRoot会调用该方法
function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

可以看到,他们唯一的区别是是否调用shouldYield。如果当前浏览器帧没有剩余时间,shouldYield会中止循环,直到浏览器有空闲时间后再继续遍历。

workInProgress代表当前已创建的workInProgress fiber

performUnitOfWork方法会创建下一个Fiber节点并赋值给workInProgress,并将workInProgress与已创建的Fiber节点连接起来构成Fiber树。

performUnitOfWork的工作可以分为两部分:“递”和“归”。

“递”阶段

首先从rootFiber开始向下深度优先遍历。为遍历到的每个Fiber节点调用beginWork方法。

该方法会根据传入的Fiber节点创建子Fiber节点,并将这两个Fiber节点连接起来。

当遍历到叶子节点(即没有子组件的组件)时就会进入“归”阶段。

“归”阶段

在“归”阶段会调用completeWork处理Fiber节点。

当某个Fiber节点执行完completeWork,如果其存在兄弟Fiber节点(即fiber.sibling !== null),会进入其兄弟Fiber的“递”阶段。

如果不存在兄弟Fiber,会进入父级Fiber的“归”阶段。

“递”和“归”阶段会交错执行直到“归”到rootFiber。至此,render阶段的工作就结束了。

例子

function App() {
  return (
    <div>
      I am
      <span>Samoy</span>
    </div>
  )
}

ReactDOM.render(<App />, document.getElementById("root"));

对应的Fiber树如下:

stateDiagram
    App --> div:child
    div --> App:return
    div --> I&nbsp;am:child
    I&nbsp;am --> div:return
    I&nbsp;am --> span:sibling
    span-->div:return
    span --> Samoy:child
    Samoy --> span:return

render的阶段会依次执行:

1. rootFiber beginWork
2. App Fiber beginWork
3. div Fiber beginWork
4. "i am" Fiber beginWork
5. "i am" Fiber completeWork
6. span Fiber beginWork
7. span Fiber completeWork
8. div Fiber completeWork
9. App Fiber completeWork
10. rootFiber completeWork

注意:之所以没有 “Samoy” Fiber 的 beginWork/completeWork,是因为作为一种性能优化手段,针对只有单一文本子节点的FiberReact会特殊处理。

beginWork

beginWork方法会创建子Fiber节点并将其连接到Fiber树上。

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  // ...省略函数体
}

其中传参:

  • current:当前组件对应的Fiber节点在上一次更新时的Fiber节点,即workInProgress.alternate
  • workInProgress:当前组件对应的Fiber节点
  • renderLanes:优先级相关 beginWork的工作可以分为两部分:
    1. update时:如果current存在,在满足一定条件时可以复用current节点,这样就能克隆current.child作为workInProgress.child,而不需要新建workInProgress.child.
    2. mount时:当current === null。会根据fiber.tag不同,创建不同类型的子Fiber节点. beginWork流程图如下: beginWork流程图

completeWork

类似beginWorkcompleteWork也是针对不同fiber.tag调用不同的处理逻辑。 当update时,Fiber节点已经存在对应DOM节点,所以不需要生成DOM节点。需要做的主要是处理props,比如:

  • onClick、onChange等回调函数的注册
  • 处理style prop
  • 处理DANGEROUSLY_SET_INNER_HTML prop
  • 处理children prop

mount时的主要逻辑包括三个:

  • 为Fiber节点生成对应的DOM节点
  • 将子孙DOM节点插入刚生成的DOM节点中
  • update逻辑中的updateHostComponent类似的处理props的过程

completeWork流程图如下: completeWor流程图

Commit阶段

commit阶段开始于commitRoot方法的调用。 commit阶段的主要工作分为三部分:

  1. before mutation阶段(执行DOM操作前)
  2. mutation阶段(执行DOM操作)
  3. layout阶段(执行DOM操作后)

before mutation阶段

before mutation阶段,会遍历effectList,依次执行:

  1. 处理DOM节点渲染/删除后的 autoFocusblur逻辑
  2. 调用getSnapshotBeforeUpdate生命周期钩子 (注意:从Reactv17开始,componentWillXXX钩子前增加了UNSAFE_前缀, 究其原因,是因为重构为Fiber架构后,render阶段的任务可能中断/重新开始, 对应的组件在render阶段的生命周期钩子 (即componentWillXXX)可能触发多次。这种行为和Reactv16不一致,所以标记为UNSAFE_。 为此,React提供了替代的生命周期钩子getSnapshotBeforeUpdate。)
  3. 调度useEffect

mutation阶段

类似before mutation阶段,mutation阶段也会遍历effectList,执行函数,这里执行的是commitMutationEffects。 该函数的主要工作是根据effectTag调用不同的处理函数处理Fiber

layout阶段

该阶段之所以称为layout,因为该阶段的代码都是在DOM渲染完成(mutation阶段完成)后执行的。

该阶段触发的生命周期钩子和hook可以直接访问到已经改变后的DOM,即该阶段是可以参与DOM layout的阶段。

与前两个阶段类似,layout阶段也是遍历effectList,执行函数。这里执行的是commitLayoutEffects。该函数的主要工作是 根据effectTag调用不同的处理函数处理Fiber并更新ref

Diff算法

Diff算法是一种高效的比较算法,用于计算虚拟DOM(Virtual DOM)中两个不同树结构的最小差异。 当组件的状态或属性发生变化时,React会生成一个新的虚拟DOM树,并与上一次渲染的结果进行比较。这个过程就是所谓的Diff算法。 由于Diff操作本身也会带来性能损耗,React文档中提到,即使在最前沿的算法中,将前后两棵树完全比对的算法的复杂程度为 O(n3),其中n是树中元素的数量。 如果在React中使用了该算法,那么展示1000个元素所需要执行的计算量将在十亿的量级范围。这个开销实在是太过高昂。

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

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

Diff是如何实现的

单节点Diff

实现流程如下: 单节点Diff

多节点Diff

实现流程如下: 多节点Diff需要两轮遍历:

第一轮遍历: 处理更新的节点。

  • newChildrenoldFiber同时遍历完,那就是最理想的情况:只有组件更新。此时Diff结束。
  • newChildren遍历完,oldFiber还没遍历完:说明有节点被删除,需要第二轮遍历剩下的oldFiber,依次标记Deletion
  • oldFiber遍历完,newChildren还没遍历完:说明有节点被添加,需要第二轮遍历剩下的newChildren,依次标记Placement。、
  • newChildrenoldFiber都没遍历完:说明有节点改变了位置。 第二轮遍历:处理剩下的不属于更新的节点。
  • 对于添加的节点,需要第二轮遍历newChildren,依次标记Placement
  • 对于删除的节点,需要第二轮遍历oldFiber,依次标记Deletion
  • 对于移动的节点,参照以下图示:
// 之前
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节点被标记为移动

参考文献

React技术解密

欢迎在评论区留下您的见解~
最近的文章

使用Typescript实现一个LRU缓存算法

1. 什么是 LRU 缓存算法LRU(Least Recently Used)算法是一种常用的页面替换或缓存淘汰策略。它的核心思想是在资源有限的情况下,当需要添加新的数据项但存储空间已满时,优先淘汰最近最少使用的数据项,以保证最常访问或最近使用过的数据能够保留在缓存中。2. 实现 LRU 算法的核心步骤 数据结构选择 使用哈希表(如 HashMap)来实现 O(1)时间复杂度的查找和更新操作,用于存储键值对及其在缓存中的位置引用。 使用双端队列(Deque,例...…

Others
更早的文章

使用exiv2读取或者修改图片元数据

0. 前言开发环境为Windows,使用vcpkg安装exiv2库。关于vcpkg的安装,请参考vcpkg官方文档。 如果使用其他环境,请自行通过搜索引擎查找相关资料。1. 安装 exiv2vcpkg install exiv22. CMmakeLists.txt 配置include("C:\\vcpkg\\scripts\\buildsystems\\vcpkg.cmake") # 此处修改为你的vcpkg安装路径find_package(exiv2 CONFIG REQUIRED)ta...…

C/C++