本文为 卡颂react源码 学习整理

React 设计理念

React 是用 JavaScript 构建 快速响应 的大型 Web 应用程序的首选方式。

如何实现快速响应,需要解决两个方面的问题:

硬件限制CPU

由于JS是单线程的,脚本执行与页面渲染无法同时进行。当项目庞大,组件繁多时,JS执行就会超过16.6ms(浏览器单帧时长),用户就会感受到卡顿。

为了解决JS执行事件过长的问题,React 采取了时间切片的方式。将长任务拆分成多个片段到每一帧中执行。

网络延迟

网络延迟是前端开发者无法解决的。如何在网络延迟客观存在的情况下,减少用户对网络延迟的感知。

为了解决网络延迟问题 React 实现了Suspense功能及配套的 hook useDeferredValue

解决上述问题的思路都是将同步的更新变为可中断的异步更新


React16架构

React16架构可以分为三层:

  • Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler
  • Reconciler(协调器)—— 负责找出变化的组件 (render阶段)
  • Renderer(渲染器)—— 负责将变化的组件渲染到页面上 (commit阶段)

Scheduler调度器

主要实现时间分片优先级调度

时间分片

每隔一段时间就把主线程还给浏览器,避免长时间占用主线程。当浏览器更新页面之后,继续执行未完成任务

浏览器每一帧JS执行流程:
task(宏任务) – 队列中全部job(微任务) – requestAnimationFrame – 浏览器重排/重绘 – requestIdleCallback

实现时间分片,我们需要知道每一帧中,JS主线程是否有空余的时间,在浏览器中的 requestIdleCallback API实现了在有空余时间时调用,但是由于兼容性等问题。React团队采用了宏任务中的 MessageChannel 来实现任务中断。

不选择 setTimeout(fn, 0) 的原因是递归执行多次之后,setTimeout间隔时间会变成4ms,导致资源浪费

那么如何判断是否应该中断任务呢?
每次进入 render 阶段时,都会调用 Scheduler 提供的 shouldYield 方法。该方法内部根据每个任务分配的时间是否用完来判断是否中断任务。初始分配时间为5ms,执行过程中会根据fps来动态调整时间长度。

优先级调度

Scheduler是独立于React的包,所以他的优先级也是独立于React的优先级的

不同的优先级对应了不同的任务过期时间。
根据任务是否延迟(超过设定的过期时间)将任务分为未就绪任务队列已就绪任务队列。每当有新的未就绪的任务被注册,我们将其插入未就绪任务队列并根据开始时间重新排列任务的顺序。当未就绪任务队列中有任务就绪,即延时,我们将其取出并加入已就绪任务队列。

执行时从已就绪任务队列中取出最早过期任务并执行。

Reconciler协调器(render阶段)

负责找出变化的组件,具体实现就是构建或更新 Fiber 树。

当 Scheduler 将任务分配给 Reconciler 后,Reconciler 会为变化的虚拟DOM节点打上增删改的标记。当所有组件都完成 Reconciler 操作之后在同一交给 Renderer。

render阶段开始于 performSyncWorkOnRootperformConcurrentWorkOnRoot 方法的调用。这取决于本次更新是同步更新还是异步更新。具体过程分为两个阶段。

“递”阶段

从 rootFiber 开始向下深度优先遍历,为遍历的每一个 Fiber 节点调用 beginWork 方法。根据传入的 Fiber 节点,创建其子节点,并将其连接起来。(DIFF)

“归”阶段

相当于回溯,为遍历到底的 Fiber 叶子节点执行 completeWork 方法,再逐层向上执行。生成对应的DOM节点,将之前生成的子节点添加上去,并处理props。

Renderer渲染器(commit)

Renderer 根据 Reconciler 为虚拟DOM打的标记,同步执行对应的DOM操作。

commit 阶段主要工作分为三个阶段

before mutation阶段(执行DOM操作前)

  1. 处理DOM节点渲染/删除后的 autoFocus、blur逻辑

  2. 调用getSnapshotBeforeUpdate生命周期钩子

  3. 调度useEffect

mutation阶段(执行DOM操作)

mutation 阶段会遍历 effectList(有变动的Fiber节点组成的链表,圣诞树的彩灯),依次执行 commitMutationEffects。该方法的主要工作为 根据effectTag(增删改标记)调用不同的处理函数处理Fiber。

layout阶段(执行DOM操作后)

该阶段的代码都是在DOM渲染完成(mutation阶段完成)后执行的。
该阶段触发的生命周期钩子和hook可以直接访问到已经改变后的DOM(componentDidMountcomponentDidUpdate


React 状态更新流程

从触发状态更新到进入render与commit流程之间,还有一些流程。

Update

React中有很多触发更新的方式,render、this.setState、useState等,为了让这些场景能够接入一套更新机制,React在每次状态更新的时候就创建一个Update 对象,用来保存更新相关的内容。

  • Update 对象的payload属性保存更新时挂载的数据(state、jsx)
  • callback 属性保存 this.setState 的第二个参数 回调函数
  • 通过 next 属性与其他 Update 对象组成链表结构,**保存在该 fiber 节点的 updateQueue 属性中。**每个 fiber 节点最多存在两个 updateQueue(current 与 workInProgress)
// ClassComponent与HostRoot使用的UpdateQueue结构const queue: UpdateQueue<State> = {    baseState: fiber.memoizedState,//本次更新前该Fiber节点的state    firstBaseUpdate: null,//本次更新前该Fiber节点已保存的Update链表头    lastBaseUpdate: null,//本次更新前该Fiber节点已保存的Update链表尾    shared: {      pending: null,//总是指向最后一个产生的Update对象    },// 再次触发更新时,产生的Update对象会保存在pending中形成单向环链表    effects: null,//数组,保存update.callback !== null的Update  };

进入render阶段以后,shared.pending 中的环链表会被剪开,并连接在 lastBaseUpdate 后面。之后会遍历baseUpdate链表,并根据baseState计算新state。最终得到的state就是本次更新的state。后续根据diff算法计算effectTag,再走commit流程最终渲染页面

从fiber到root

render阶段是从 rootFiber 节点向下遍历的。需要通过当前更新的 fiber 节点拿到 rootFiber 节点,调用 markUpdateLaneFromFiberToRoot 方法(beginWork)遍历向上寻找 rootFiber 的时候,还会更新遍历到的fiber的优先级。

调度更新

根据 Update 更新的优先级,来决定使用同步更新方法还是异步更新方法。分别对应到render阶段中开头的两个方法。

Fiber 架构——异步可中断更新

参考思想——代数效应: 将 副作用 从函数中分离。使函数逻辑纯粹。

要实现 Scheduler 的线程调度,就需要借助协程的思想(在执行过程中暂停并出让线程)。原生JS中的 generator 函数就是协程的实现。但是 React 团队介于其执行的中间状态是与上下文关联的,没有直接采用。而是自己基于协程实现了一套新的状态更新机制,这就是 React Fiber支持任务不同优先级,可中断与恢复,并且恢复后可以复用之前的中间状态。

每一个分片的状态的数据结构就是一个 Fiber 节点 ,每一个 Fiber 节点 对应一个 React element 。响应的 Fiber 节点 构成的 Fiber 树 (虚拟DOM)就对应 DOM树 。

React使用“双缓存”来完成 Fiber树 的构建与替换——对应着 DOM树的创建与更新。React 中同时存在一个 current Fiber树 和一个 workInProgress Fiber树。每次状态更新都会产生新的workInProgress Fiber树,通过current与workInProgress的替换,完成DOM更新。fiber树的切换是在commit阶段中的mutation阶段结束后,layout阶段开始前

Diff算法

比较 当前更新组件上次更新时 对应的Fiber节点。
由于Diff算法本身的性能开销O(n^3) React做了三点限制:

  1. 只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他。

  2. 两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁原节点及其子孙节点,并新建p及其子孙节点。

  3. 多个子节点可以通过添加 key 值来区分哪些是可以复用的。

React 会根据更新后Fiber树的当前节点的类型来执行不同的处理函数。我们可以简单的划分为单节点和多节点。

单节点

包括 string、number、object ,表示同级只有一个节点。

在判断DOM是否可以复用的阶段。React先判断key是否相同,再判断type是否相同,只有都相同时一个DOM节点才能复用。

遍历到key不同的节点,标记删除当前节点

遍历到key相同但是type不同的fiber节点,直接可以删除其本身与剩余未遍历的兄弟节点。 例如将多个

  • 标签替换为一个 ,当div标签key属性与某个li标签相同,此时由于type不同,节点无法复用,其他的li标签更没机会复用,因此可以一并删除。

    多节点

    主要是多个同级节点组成的数组

    节点涉及的变化无非是新增、删除、更新三种。日常开发中,更新的频率要远高于增删,因此React 团队优化了算法。通过两次遍历来解决多节点diff。

    由于同级Fiber节点是sibling由单链表的结构,因此无法通过双指针来优化遍历

    第一轮遍历:处理更新节点(移动位置除外)

    1. 比较 newChildren (更新后的节点数组) 与 oldFiber (更新前的节点链表) 进行比较 ,判断DOM节点是否可复用。
    2. 如果可以复用(key与type都相同),就继续比较。如果不能复用,有两种情况
      • key不同导致无法复用,直接跳出循环,第一轮结束。
      • key相同,type不同无法复用,标记当前 oldFiber 节点为 DELETION 继续遍历。
    3. newChildrenoldFiber 遍历完成,第一轮结束。

    第二轮遍历:处理非更新节点(新增、删除、位移)

    • newChildren 与 oldFiber 同时遍历完 此时无需二次遍历,diff结束。
    • newChildren 没遍历完,oldFiber 遍历完 表示有新增的节点,遍历newChildren 剩余节点并标记Placement
    • newChildren 遍历完,oldFiber 没遍历完 表示需要删除节点,遍历 oldFiber 剩余节点并标记 Deletion
    • newChildren 与oldFiber 都没遍历完 表示有节点在本次更新中改变了位置。会进行一个排序的操作。
    具体的排序操作
    1. oldFiber 剩余部分保存到 Map 结构中,key为key值,value为节点对象
    2. 遍历 newChildren 中剩余节点,是否在Map中存在。
    3. 设置一个变量 lastPlacedIndex 来记录上一次遍历找到的可复用节点key值。初始值为0
    4. 遍历找到下一个可复用节点,记录key值到 oldIndex 变量。
    5. 比较 lastPlacedIndexoldIndex ,如果 oldIndex >= lastPlacedIndex 代表该可复用节点不需要移动
      并将 lastPlacedIndex = oldIndex 。如果 oldIndex < lastplacedIndex 该可复用节点之前插入的位置索引小于这次更新需要插入的位置索引,代表该节点需要向右移动

    lane——React优先级模型

    在使用 Lane 模型之前,React 内部使用 ExpirationTime 表示任务的优先级。但是在处理高优先级IO任务和低优先级CPU任务的时候,会出现BUG 。

    lane使用31位二进制表示优先级,就像环形跑道一样,位数越小的优先级越低。
    lane可以方便表示批量更新。Lanes 是一个整数,该整数所有二进制位为 1 对应的优先级任务都将被执行。例如 Lanes 为 17 时,表示将批量更新 SyncLane(值为 1)和 DefaultLane(值为 16)的任务。