前言
在看React源码之前,有必要对React的整体架构进行一个初步的认识,对整体架构有了一定的印象之后,后续看源码才不会云里雾里、不知所云。
性能瓶颈
我们在 legacy模式下 先看个Demo
import * as React from "react";
class ConcurrentDemo extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
this.buttonRef = React.createRef(null);
}
componentDidMount() {
const button = this.buttonRef.current;
setTimeout(() => this.setState({ count: 1 }), 500);
setTimeout(() => button.click(), 500);
}
handleButtonClick = () => {
this.setState((prevState) => ({ count: prevState.count + 2 }));
};
render() {
return (
<div >
<button ref={this.buttonRef} onClick={this.handleButtonClick}>
增加2
</button>
<div >
{Array.from(new Array(200)).map((v, index) => (
<span key={index}>{this.state.count}</span>
))}
</div>
</div>
);
}
}
export default ConcurrentDemo;
Demo中,渲染200个节点,我们在didMount中添加2个setTimeout,展示同步状态下,500ms同时触发2个任务,React如何处理?
打开Performance,可以看到,两个任务Task,会按照顺序一个一个执行。前一个任务执行完,再执行下一个任务。
我们把 200个节点改成8000个会看到 十分卡顿,原因是什么呢?
这就要从浏览器的原理说起了
众所周知,JS 是单线程的,浏览器是多线程的,除了 JS 线程以外,还包括 UI 渲染线程、事件线程、定时器触发线程、HTTP 请求线程等等。
JS 线程与 UI 渲染线程是互斥的,如果执行的JS计算比较繁琐,长时间的 JS 持续执行,就会造成 UI 渲染线程长时间地挂起,触发的事件也得不到响应,用户层面就会感知到页面卡顿甚至卡死了,Sync 模式下的问题就由此引起。
那么 JS 执行时间多久会是合理的呢?这里就需要提到帧率了,大多数设备的帧率为 60 次/秒,也就是每帧消耗 16.67 ms 能让用户感觉到相当流畅。浏览器的一帧中包含如下图过程:
一帧的时间内16.67ms,浏览器要处理:
- 处理用户的交互
- JS 解析执行
- 帧开始。窗口尺寸变更,页面滚去等的处理
- requestAnimationFrame(rAF)
- 布局
- 绘制
扩展:
- requestAnimationFrame: 告诉浏览器在下次重绘之前执行传入的回调函数(通常是操纵dom,更新动画的函数);由于是每帧执行一次,那结果就是每秒的执行次数与浏览器屏幕刷新次数一样,通常是每秒60次。
- requestIdleCallback: 充分利用浏览器空闲时间,可以根据 callback 传入的 dealine 判断当前是否还有空闲时间(timeRemaining)用于执行。由于浏览器可能始终处于繁忙的状态,导致 callback 一直无法执行,它还能够设置超时时间(timeout),一旦超过时间(didTimeout)能使任务被强制执行。如果有多个回调,会按照先进先出原则执行,但是当传入了timeout,为了避免超时,有可能会打乱这个顺序。
在一帧中,我们需要将 JS 执行时间控制在合理的范围内,不影响后续 Layout 与 Paint 的过程。
解决策略
- 时间切片:React16之前的版本,更新一旦开始,中途就无法中断,当层级很深时,递归更新时间超过了16ms,用户交互就会卡顿。那是不是可以在在浏览器每一帧的时间中,预留一些时间给JS线程,
React
利用这部分时间更新组件呢?(在源码 中,预留的初始时间是5ms)。当预留的时间不够用时,React
将线程控制权交还给浏览器使其有时间渲染UI,则等待下一帧时间到来继续被中断的工作。这种将长任务分拆到每一帧中,像蚂蚁搬家一样一次执行一小段任务的操作,被称为**时间切片**
(time slice) - 任务优先级:将任务分为不同的优先级,当有更高优先级的任务要执行的时候,优先执行高优先级。
- 可中断的异步更新:高优先级任务执行完之后,返回继续执行低优先级。
总结起来就是:
解决瓶颈的关键是实现时间切片
,而时间切片
的关键是:将同步的更新变为可中断的异步更新。
React16之前的整体架构演变:
可以看到主要增加了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模式下
Concurrent模式
[
](https://km.sankuai.com/page/531721719)
从截图中可以看到,Concurrent模式下实现了可中断。那么问题来了
- 任务如何按时间片拆分、时间片间如何中断与恢复?
- 任务是怎样设定优先级的?
- 如何让高优先级任务后生成而先执行,低优先级任务如又何恢复?
这些问题会在后续的文章中根据源码一一揭开。
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>
)
}
时间切片
由上面的性能截图可以很清晰的看到一段一段的时间切片,在源码 中,预留的时间切片是5ms
Fiber 树的更新流程分为 render 阶段与 commit 阶段,在 Sync 模式下,render 阶段一次性执行完成,而在 Concurrent 模式下,render 阶段可以被拆解,每个时间片内分别运行一部分,直至完成,commit 模式由于带有 DOM 更新,不可能 DOM 变更到一半中断,因此必须一次性执行完成。
while (当前还有空闲时间 && 下一个节点不为空) {
下一个节点 = 子节点 = beginWork(当前节点);
if (子节点为空) {
下一个节点 = 兄弟节点 = completeUnitOfWork(当前节点);
}
当前节点 = 下一个节点;
}
Concurrent 模式下,render 阶段遍历 Fiber 树的过程会在上述 while 循环中进行,每结束一次循环就会进行一次时间片的检查,如果时间片到了,while 循环将被 break,相当于 render 过程暂时被中断,检查是否存在事件响应、更高优先级任务或其他代码需要执行,如果有,当前处理到的节点会被保留下来,等待下一个时间分片到来时,继续处理。
简单来说,就是一个长任务如果在一个时间片(5ms)内没有执行完,就会询问下是否有更高优的任务,如果有,就先执行高优的任务,高优任务执行完再回来执行这个长任务
任务调度
任务调度是根据优先级来处理的,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树
的创建与更新。
在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 协调阶段
具体分析详见 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操作,如果有组件被删除,那么还会调用
componentWillUnmouont
或useLayoutEffect
的销毁函数
- 执行dom操作,如果有组件被删除,那么还会调用
layout
(执行dom操作后)- 切换
fiber tree
- 调用
componentDidUpdate|componentDidMount
生命周期或者useEffectLayout
的回调函数。 - layout结束后,执行之前调度的
**useEffect**
的创建和销毁函数。(这里注意下,useEffect在渲染dom之后执行)
- 切换
总结上文,在performUnitOfWork
时,我们称之为协调阶段,主要依靠beginWork
和completeWork
去交替执行每个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