前言

在看React源码之前,有必要对React的整体架构进行一个初步的认识,对整体架构有了一定的印象之后,后续看源码才不会云里雾里、不知所云。

性能瓶颈

我们在 legacy模式下 先看个Demo

  1. import * as React from "react";
  2. class ConcurrentDemo extends React.Component {
  3. constructor(props) {
  4. super(props);
  5. this.state = { count: 0 };
  6. this.buttonRef = React.createRef(null);
  7. }
  8. componentDidMount() {
  9. const button = this.buttonRef.current;
  10. setTimeout(() => this.setState({ count: 1 }), 500);
  11. setTimeout(() => button.click(), 500);
  12. }
  13. handleButtonClick = () => {
  14. this.setState((prevState) => ({ count: prevState.count + 2 }));
  15. };
  16. render() {
  17. return (
  18. <div >
  19. <button ref={this.buttonRef} onClick={this.handleButtonClick}>
  20. 增加2
  21. </button>
  22. <div >
  23. {Array.from(new Array(200)).map((v, index) => (
  24. <span key={index}>{this.state.count}</span>
  25. ))}
  26. </div>
  27. </div>
  28. );
  29. }
  30. }
  31. export default ConcurrentDemo;

Demo中,渲染200个节点,我们在didMount中添加2个setTimeout,展示同步状态下,500ms同时触发2个任务,React如何处理?
image.png
打开Performance,可以看到,两个任务Task,会按照顺序一个一个执行。前一个任务执行完,再执行下一个任务。

我们把 200个节点改成8000个会看到 十分卡顿,原因是什么呢?

这就要从浏览器的原理说起了
众所周知,JS 是单线程的,浏览器是多线程的,除了 JS 线程以外,还包括 UI 渲染线程、事件线程、定时器触发线程、HTTP 请求线程等等。

JS 线程与 UI 渲染线程是互斥的,如果执行的JS计算比较繁琐,长时间的 JS 持续执行,就会造成 UI 渲染线程长时间地挂起,触发的事件也得不到响应,用户层面就会感知到页面卡顿甚至卡死了,Sync 模式下的问题就由此引起。

那么 JS 执行时间多久会是合理的呢?这里就需要提到帧率了,大多数设备的帧率为 60 次/秒,也就是每帧消耗 16.67 ms 能让用户感觉到相当流畅。浏览器的一帧中包含如下图过程:
image.png
一帧的时间内16.67ms,浏览器要处理:

  • 处理用户的交互
  • JS 解析执行
  • 帧开始。窗口尺寸变更,页面滚去等的处理
  • requestAnimationFrame(rAF)
  • 布局
  • 绘制

扩展:

  • requestAnimationFrame: 告诉浏览器在下次重绘之前执行传入的回调函数(通常是操纵dom,更新动画的函数);由于是每帧执行一次,那结果就是每秒的执行次数与浏览器屏幕刷新次数一样,通常是每秒60次。
  • requestIdleCallback: 充分利用浏览器空闲时间,可以根据 callback 传入的 dealine 判断当前是否还有空闲时间(timeRemaining)用于执行。由于浏览器可能始终处于繁忙的状态,导致 callback 一直无法执行,它还能够设置超时时间(timeout),一旦超过时间(didTimeout)能使任务被强制执行。如果有多个回调,会按照先进先出原则执行,但是当传入了timeout,为了避免超时,有可能会打乱这个顺序。


在一帧中,我们需要将 JS 执行时间控制在合理的范围内,不影响后续 Layout 与 Paint 的过程。

如果我们要解决Sync下卡顿问题,可以从哪些方面入手呢?

解决策略

  1. 时间切片:React16之前的版本,更新一旦开始,中途就无法中断,当层级很深时,递归更新时间超过了16ms,用户交互就会卡顿。那是不是可以在在浏览器每一帧的时间中,预留一些时间给JS线程,React利用这部分时间更新组件呢?(在源码 中,预留的初始时间是5ms)。当预留的时间不够用时,React将线程控制权交还给浏览器使其有时间渲染UI,则等待下一帧时间到来继续被中断的工作。这种将长任务分拆到每一帧中,像蚂蚁搬家一样一次执行一小段任务的操作,被称为**时间切片**(time slice)
  2. 任务优先级:将任务分为不同的优先级,当有更高优先级的任务要执行的时候,优先执行高优先级。
  3. 可中断的异步更新:高优先级任务执行完之后,返回继续执行低优先级。

总结起来就是:
解决瓶颈的关键是实现时间切片,而时间切片的关键是:将同步的更新变为可中断的异步更新

React16之前的整体架构演变:
image.png
可以看到主要增加了Schedule来调度任务。

我们来看下开启Concurrent之后,性能怎样呢?可以先 Dan-Concurrent Mode Demo 来感受
如何开启Concurrent呢?

npm install react@experimental react-dom@experimental
// index.js 文件
// 同步模式
ReactDOM.render(<App />, container);

// Concurrent 模式
ReactDOM.unstable_createRoot(container).render(
    <App />
)

Sync模式下
image.png

Concurrent模式
image.png
[

](https://km.sankuai.com/page/531721719)
从截图中可以看到,Concurrent模式下实现了可中断。那么问题来了

  1. 任务如何按时间片拆分、时间片间如何中断与恢复?
  2. 任务是怎样设定优先级的?
  3. 如何让高优先级任务后生成而先执行,低优先级任务如又何恢复?

这些问题会在后续的文章中根据源码一一揭开。

fiber

如果想要实现可中断,高优先级任务执行完之后继续执行低优先级任务,需要记录被打断的节点。React16之前JSX节点只有相应的prop等信息,没办法满足需求。

基于以上背景,React设计了fiber节点,链表形式,链表可以记录父子节点、兄弟节点关系。所以我们先从fiber说起。

无论是类组件(ClassComponent)、函数组件(FunctionComponent)还是宿主组件(HostComponent,在 DOM 环境中就是 DOM 节点,例如 div),在底层都会统一抽象为 Fiber 节点 ,拥有父节点(return)、子节点(child)或者兄弟节点(sibling)的引用,方便对于 Fiber 树的遍历,同时组件与 Fiber 节点会建立唯一映射关系

每一个fiber节点都对应一个dom节点。fiber节点没什么神秘的,就是存了一些更新的信息和优先级的信息

function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // 作为静态数据结构的属性 
  this.tag = tag; //Fiber对应组件的类型 Function/Class/Host...函数组件、类组件、宿主组件(即dom)
  this.key = key;  
  this.elementType = null; // 大部分情况同type,某些情况不同,比如FunctionComponent使用React.memo包裹
  this.type = null;  // 对于 FunctionComponent,指函数本身,对于ClassComponent,指class,对于HostComponent,指DOM节点tagName
  this.stateNode = null; // Fiber对应的真实DOM节点   DOM节点 | Class实例 函数组件则为空


  // 用于连接其他Fiber节点形成Fiber树
  this.return = null;
  this.child = null;
  this.sibling = null;
  this.index = 0;

  this.ref = null;

  // 保存本次更新造成的状态改变相关信息
  this.pendingProps = pendingProps; //本次更新的props
  this.memoizedProps = null; //上一次渲染的props
  this.updateQueue = null; // 如果是class,将保存setState产生的update链表; 如果是hooks,这个地方会存放effect链表;如果是dom节点,会存放他所需更新的props
  this.memoizedState = null; // 如果是class组件,会保存上一次渲染的state,如果是hooks组件,会保存所有hooks组成的链表
  this.dependencies = null;
  this.mode = mode;
  // 保存本次更新会造成的DOM操作
  this.effectTag = NoEffect;
  this.nextEffect = null;
  this.firstEffect = null;
  this.lastEffect = null;


  // 调度优先级相关
  this.lanes = NoLanes;
  this.childLanes = NoLanes;

  // 指向该fiber在另一次更新时对应的fiber
  this.alternate = null;
}

每个Fiber节点有个对应的React element,多个Fiber节点是如何连接形成树呢?靠如下三个属性:

// 指向父级Fiber节点
this.return = null;
// 指向子Fiber节点
this.child = null;
// 指向右边第一个兄弟Fiber节点
this.sibling = null;

demo展示下

function App() {
  return (
    <div>
      i am
      <span>JoyGuan</span>
    </div>
  )
}

image.png

时间切片

由上面的性能截图可以很清晰的看到一段一段的时间切片,在源码 中,预留的时间切片是5ms
image.png

Fiber 树的更新流程分为 render 阶段与 commit 阶段,在 Sync 模式下,render 阶段一次性执行完成,而在 Concurrent 模式下,render 阶段可以被拆解,每个时间片内分别运行一部分,直至完成,commit 模式由于带有 DOM 更新,不可能 DOM 变更到一半中断,因此必须一次性执行完成。

while (当前还有空闲时间 && 下一个节点不为空) {
  下一个节点 = 子节点 = beginWork(当前节点);
  if (子节点为空) {
    下一个节点 = 兄弟节点 = completeUnitOfWork(当前节点);
  }
  当前节点 = 下一个节点;
}

Concurrent 模式下,render 阶段遍历 Fiber 树的过程会在上述 while 循环中进行,每结束一次循环就会进行一次时间片的检查,如果时间片到了,while 循环将被 break,相当于 render 过程暂时被中断,检查是否存在事件响应、更高优先级任务或其他代码需要执行,如果有,当前处理到的节点会被保留下来,等待下一个时间分片到来时,继续处理。

简单来说,就是一个长任务如果在一个时间片(5ms)内没有执行完,就会询问下是否有更高优的任务,如果有,就先执行高优的任务,高优任务执行完再回来执行这个长任务

image.png

任务调度

任务调度是根据优先级来处理的,React中有5种优先级

var timeout;
switch (priorityLevel) {
  case ImmediatePriority:
    timeout = IMMEDIATE_PRIORITY_TIMEOUT;
    break;
  case UserBlockingPriority:
    timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
    break;
  case IdlePriority:
    timeout = IDLE_PRIORITY_TIMEOUT;
    break;
  case LowPriority:
    timeout = LOW_PRIORITY_TIMEOUT;
    break;
  case NormalPriority:
  default:
    timeout = NORMAL_PRIORITY_TIMEOUT;
    break;
}

var expirationTime = startTime + timeout;

不同优先级意味着任务过期时间不同

// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
// Never times out
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;

关于任务调度,这里不展开说,移步React任务调度

双缓存

React是如何更新DOM呢?这需要用到被称为“双缓存”的技术。

在内存中构建并直接替换的技术叫做双缓存

React使用“双缓存”来完成Fiber树的构建与替换——对应着DOM树的创建与更新。
image.png

React中最多会同时存在两棵Fiber树
当前屏幕上显示内容对应的**Fiber树**称为**current Fiber树**
current fiber树: 当完成一次渲染之后,会产生一个current树,current会在commit阶段替换成真实的Dom树。

正在内存中构建的**Fiber树**称为**workInProgress Fiber树**。简称 WIP
workInProgress fiber树: 即将调和渲染的 fiber 树。再一次新的组件更新过程中,会从current复制一份作为workInProgress,更新完毕后,将当前的workInProgress树赋值给current树。

二者通过alternate属性连接

在组件commit阶段,将workInProgress树替换成current树,替换真实的DOM元素节点。并在current树保存hooks信息

currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;

React应用的根节点通过current指针在不同Fiber树rootFiber间切换来实现Fiber树的切换。

**workInProgress Fiber树**在内存中构建完成,由于在内存中执行,所以可以中断和恢复,不阻塞浏览器,根据优先级高低执行,

构建完之后,应用的根节点应用根节点的current指针指向workInProgress Fiber树,应用根节点的current指针指向workInProgress Fiber树,此时workInProgress Fiber树就变为current Fiber树,从而渲染到界面上。

整体流程

整体流程可以分为 render阶段和commit阶段

Render 协调阶段

具体分析详见 Render协调过程 可以被打断

Render阶段就是调和的过程,即:

  • 采用深度优先遍历算法,生成**fiber**结构,构建对应的workInProgress树,
  • 找到diff
  • 根据优先级调度任务
  • 根据节点态生成一个Effect List
//Sync
function workLoopSync() {
  // workInProgress 是一个全局变量,保存当前所要执行的fiber节点,它会从root节点开始
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}
// Concurrent
function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

无论是Sync模式还是Concurrent模式,WIP的构建过程都是从performUnitWork开始,performUnitWork会执行当前fiber 然后把这个fiber的child子节点赋值给WIP,当子节点不存在时,就把sibling兄弟节点赋值给WIP

在performUnitWork里,又分为两个阶段,一个是**beginWork**,一个是**completeWork**

首先从rootFiber开始向下深度优先遍历。为遍历到的每个Fiber节点调用beginWork
该方法会根据传入的Fiber节点创建子Fiber节点,并将这两个Fiber节点连接起来。
当遍历到叶子节点(即没有子组件的组件)时就会进入“归”阶段。

当某个Fiber节点执行完completeWork,如果其存在兄弟Fiber节点(即fiber.sibling !== null),会进入其兄弟Fiber的“递”阶段。
如果不存在兄弟Fiber,会进入父级Fiber的“归”阶段。
“递”和“归”阶段会交错执行直到“归”到rootFiber。

  • **beginWork**
    • 执行组件render
      • render组件,会执行实例化,处理state,调用挂载前生命周期钩子等等。最后执行rende,获取返回的jsx
      • function组件,会执行组件的构造函数,里面包括了hooks的一系列调用,最后获取返回的jsx
    • 对返回的jsx执行reconclie,也就是俗称的Diff
      • 根据diff生成当前fiber的子节点,并标记上对应的flag,比如这个节点是更新、删除、移动。
      • 这个生成的子节点,会返回出去,赋值给WIP,然后上层函数workLoopSync进行下一轮遍历,执行这个新生成的fiber节点
  • **completeWork**,当遍历到叶子节点,会执行它,对 fiber tree进行一个回溯,去迭代return,也就是父节点。在发现有sibling兄弟节点时,会退出遍历,并赋值给workInProgress,以便上层workLoopSync函数遍历。
    • 生成dom节点,并把子孙dom节点插入进去。组成一个虚拟dom
    • 处理props
    • 把所有含有副作用的fiber节点用firstEffect和lastEffect链接起来,组成一个链表,以便在commit时去遍历执行。

Commit 提交阶段

commit阶段主要是根据effectList,更新dom,这个阶段是同步的,不可以被打断
具体详见 Commit提交阶段

completeWork执行到root根节点时,证明所有的工作已经完成,就会执行commitRoot,它又分为三个阶段:

  • before mutation (执行dom操作前)
    • 调用挂载前的生命周期钩子,比如getSnapshotBeforeUpdate 调度useEffect
  • mutation(执行dom操作)
    • 执行dom操作,如果有组件被删除,那么还会调用componentWillUnmouontuseLayoutEffect的销毁函数
  • layout(执行dom操作后)
    • 切换 fiber tree
    • 调用componentDidUpdate|componentDidMount生命周期或者useEffectLayout的回调函数。
    • layout结束后,执行之前调度的**useEffect**的创建和销毁函数(这里注意下,useEffect在渲染dom之后执行)

总结上文,在performUnitOfWork时,我们称之为协调阶段,主要依靠beginWorkcompleteWork去交替执行每个fiber,在commitRoot时,我们称之为提交阶段。

用一张表格来概括下render阶段和commit阶段:

阶段 内部函数 生命周期 作用



render
beginWork getDerivedStateFromProps
shouldComponentUpdate
render
计算diff
产生Effect
completeWork 收集Effect
(Placement | Update | Deletion)




commit
commitBeforeMutationEffects getSnapshotBeforeUpdate
commitMutationEffects conponentWillUnmount
useEffectLayout
根据effect去操作dom
commitLayoutEffects componentDidMount
componentDidUpdate
useEffect

References

https://km.sankuai.com/page/531721719
https://juejin.cn/post/6844904018200756238