Fiber
可以从三个角度理解 Fiber
- Fiber 架构:在 v16 之前使用的是 stack reconciler,因此之前被称之为 stack 架构。从 react v16 开始重构了整个架构,引入了 Fiber,因此新的架构也称之为 Fiber 架构,stack reconciler 也变成了 Fiber Reconciler。各个 FiberNode 之间通过链表的形式串联起来。
- Fiber 对象:Fiber 本质上也是一个对象,是在之前 React 元素基础上的一种升级版本。每个 FiberNode 对象里面会包含 React 元素的类型,周围链接的 FiberNode 以及 DOM 的相关信息
- Fiber 工作单元:在每个 FiberNode 中保存了本次更新中,该 React 元素变化的数据,还有就是要执行的工作(增,删,更新)以及副作用的信息。
Fiber 双缓冲
在内存中构建并直接替换的技术叫做双缓存,React使用"双缓存"来完成Fiber树的构建与替换------对应着DOM树的创建与更新。
如果只有一个fiber 树,当对其进行操作时,可能会影响其正在进行的工作,双缓冲就是在内存中直接构建另一颗 fiber 树,对其进行操作,完成后直接替换之前的 fiber 树。
mount阶段
最顶层有一个 FiberNode,称之为 FiberRootNode,该 FiberNode 的任务
- currentFiberNode 与 wip 之间的切换
- 应用中的过期时间
- 应用的任务调度信息
javascript
<body>
<div id='root'></div>
</body>
function App() {
const [num, add] = useState(0)
return <p onClick={() => {add(num + 1)}}>add{num + 1}</p>
}
const rootElement = document.createElementById('root')
ReactDOM.createRoot(rootElement).render(<App />)
当执行 ReactDOM.render的时候会创建如下结构
生成的 wip FiberTree 里面的每一个 FiberNode 会和 current FiberTree 里面的 FiberNode 进行关联。关联的方式就是通过 alternate。但是目前 currentFiberTree 里面就只有一个 HostRootFiber
当 wipFiberTree 生成完成后,也就意味着 render 阶段完成了。此时 FiberRootNode 就会被传递给 Renderer 渲染器,进行渲染工作。渲染工作完毕后浏览器中就显示了对应的 UI。此时 FiberRootNode.current就会指向 wip FiberTree。曾经的 wip FiberTree 就会变成 current FiberTree,完成了双缓冲。
update 阶段
点击 p 元素会触发更新,这一操作会开启 update 流程。此时就会生成一颗新的 wip FiberTree。流程和之前是一样的。
新的 wip FiberTree 中的每一个 FiberNode 和 currentFiberTree 中的每一个元素通过 alternate 进行关联。当 wip FiberTree 生成完毕后就会经历和之前一样的流程,FiberRootNode 会被传递给 Renderer 进行渲染。此时宿主环境所渲染的真实 UI 就是左边 wip FiberTree 所对应的 dom 结构,FiberRootNode.current 就会指向左边的树,右边的树就再一次成为了 wip FiberTree
React的渲染流程
react的整体渲染流程可分为两个大的阶段
- render阶段,计算出最终要渲染的虚拟DOM
- commit阶段,根据上一步计算出的虚拟DOM,渲染出真实的UI
总共有三个大的组件模块
render阶段:
- Scheduler调度器,为任务排序优先级,让优先级高的先进入到 Reconciler
- Reconciler协调器,生成 Fiber 对象,收集副作用,找出哪些节点发生了变化,打上不同的 flags
commit阶段
- renderer 渲染器:根据协调器计算出来的虚拟 DOM 同步的渲染到节点视图上
协调器(reconciler)
我们先来看reconciler,根据 Scheduler 调度结果不同, 协调器起点可能是不同的
- performSyncWorkOnRoot 同步更新流程
- performConcurrentWorkOnRoot 并发更新流程
scss
// performSyncWorkOnRoot 会执行该方法
function workLoopSync() {
while(workInProgress !== null) {
performUnitOfWork(workInProgress)
}
}
// performConcurrentWorkOnRoot 会执行该方法
function workLoopConcurrent() {
while(workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress)
}
}
但是可以看出,最终都会调用到performUnitOfWork。
fiber是用来描述DOM结构的,最终会形成fiber树,以链表的形式链接在一起,workInProgress就表示当前的fiberNode。
performUnitOfWork 方法会创建下一个 FiberNode,并且还会将已创建的 FiberNode 链接起来(child,return,sibling),从而形成一个链表结构的 FiberTree
如果 workInProgress 为 null,说明已经没有下一个 FiberNode,也就是整个 Fiber tree 已经构建完毕。
上面两个方法唯一的区别就是是否调用的 shouldYield 方法,该方法表明是否可以中断,这里会在Scheduler中再进行详细介绍。
performUnitOfWork 在创建下一个 FiberNode 的时候整体工作流程可以分为两个阶段
- 递阶段
- 归阶段
递阶段
递阶段会从 HostRootFiber 开始向下以深度优先的原则进行遍历,遍历到的每一个 FiberNode 执行 beginWork 方法,该方法会根据传入的 FiberNode 创建下一级的 FiberNode。这时可能存在两种情况
- 下一级只有一个元素,beginWork 方法会创建对应的 FiberNode,并与 workInProgress 连接
less
<ul>
<li></li>
</ul>
// LiFiberNode.return = UlFiberNode
- 下一级有多个元素,这时 beginWork 方法会依次创建所有的子 Fiber 并通过 sibling 链接到一起,每个子 FiberNode 也会和 workInProgress 链接
ini
<ul>
<li></li>
<li></li>
<li></li>
</ul>
/*
此时会创建 3 个li 对应的 FiberNode
Li0Fiber.sibling = Li1Fiber
Li1Fiber.sibling = Li2Fiber
Li0Fiber.return = UlFiber
Li1Fiber.return = UlFiber
Li2Fiber.return = UlFiber
*/
采用深度优先遍历的方式,当无法再继续往下走的时候,会进入归阶段
归阶段
归阶段会调用 completeWork 方法处理 FiberNode,做一些副作用的收集。
当某个 FiberNode 执行完 completeWork 方法后,如果存在兄弟元素,就进入兄弟元素的递阶段,如果不存在兄弟元素,就会进入父FiberNode 的归阶段。
整体流程如下
css
<div>
<p>hello</p>
<ul>
<li>apple</li>
<li>pear</li>
<li>banana</li>
</ul>
</div>
beginWork 工作流程
bieginwork 方法主要是根据传入的 FiberNode 创建下一级的 FiberNode,整个流程如下图
首先在 beginWork 中会判断当前流程是 mount(初次渲染)还是 update(更新),判断的依据就是 currentFiberNode 是否存在
csharp
if(current !== null) {
// update
} else {
//mount
}
如果是 update,接下来会判断 wipFiberNode 是否能够复用,如果不能复用,那么 update 和 mount 流程大体一致
- 根据 wip.tag进行不同分支的处理
- 根据 reconcile 算法生成下一级的 FiberNode
无法复用的 update 流程和 mount 流程大体一致,主要区别在于是否会生成带副作用标记 flags 的 FiberNode
beginWork 方法代码结构如下
javascript
/*
current: 当前 Fiber 节点
workInProgress: 正在处理的 Fiber 节点, wip
*/
function beginWork(current, workInProgress, renderLanes){
// ...
if (current !== null) {
// update
// ...
} else {
// mount
// ...
}
// 根据 workInProgress.tag 的不同,执行不同的操作
switch (workInProgress.tag) {
case IndeterminateComponent:
// ...
break
case FunctionComponent:
// ...
break
case ClassComponent:
// ...
break
}
}
在 beginWork 中,如果标记了副作用的 flags,那么主要与元素的位置相关,包括
- 标记 ChildDeletion 表示删除操作
- 标记 Placement 表示插入或移动操作
对于我们常见的组件类型,如(FunctionComponent/ClassComponent/HostComponent),最终会进入reconcileChildren方法。
reconcileChildren
- 对于mount的组件,他会创建新的子Fiber节点
- 对于update的组件,他会将当前组件与该组件在上次更新时对应的Fiber节点比较(Diff算法),将比较的结果生成新Fiber节点
scss
export function reconcileChildren(current, workInProgress,nextChildren,renderLanes) {
if (current === null) {
// 对于mount的组件
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes
);
} else {
// 对于update的组件
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes
);
}
}
最终他会生成新的子Fiber节点并赋值给workInProgress.child,作为本次beginWork返回值,并作为下次performUnitOfWork执行时workInProgress的传参。
completeWork 工作流程
completeWork 也会根据 wip.tag 区分对待,流程上面主要包括两个步骤:
- 创建元素或者标记元素的更新
- flags 冒泡
整体流程图如下
mount 阶段
在 mount 流程中,首先会通过 createInstance 创建 FiberNode 所对应的 DOM 元素,接下来会执行 appendAllChildren,该方法的作用是将下一层 DOM 元素插入到通过 createInstance 所创建的 DOM 元素中
ini
const appendAllChildren = function(parent, workInProgress, ...other) {
let node = workInProgress.child;
while(node !== null) {
// 步骤 1,向下遍历,对第一层 DOM 元素执行 appendChild
if (node.tag === HostComponent || node.tag === HostText) {
appendInitalChild(parent, node.stateNode);
} else if (node.child !== null) {
node.child.return = node;
node = node.child;
continue;
}
// 终止情况 1,遍历到parent 对应的 FiberNode
if (node === workInProgress) {
return;
}
// 如果没有兄弟 FiberNode,则向父 FiberNode 遍历
while(node.sibling === null) {
// 终止情况 2,回到最初执行步骤 1 所在层
if (node.return === null || node.return === workInProgress) {
return;
}
node = node.return;
}
// 对兄弟 FiberNode 执行步骤 1
node.sibling.return = node.return;
node = node.sibling;
}
}
接下来 complete 会执行 finalizeInitialChildren 方法,完成属性的初始化,主要包含:
- styles,对应 setValueForStyles 方法
- innerHTML,对应 setInnerHTML 方法
- 文本类型 children,对应 setTextContent 方法
- 不会再再 DOM 中冒泡的事件,包括 cancel,close,invalid,load,scroll,toggle,对应对的是 listenToNonDelegatedEvent 方法
- 其他属性,对应 setValueForProperty 方法
update 阶段
update 流程完成的就是属性更新的标记
updateHostComponent 的主要逻辑是在 diffProperties 中,该方法会包含两次遍历
- 第一次遍历主要是标记更新前有,更新后没有,也就是标记删除了的属性
- 第二次遍历主要是标记更新前后有变化的属性,实际上也就是标记更新的属性
所有更新了的属性的 key 和 value 会保存在当前 FiberNode.updateQueue里面,数据是以 key,value 作为数组相邻两项的形式进行保存的
flags 冒泡
completeWork 是属于归的阶段,整体流程是自下往上的,就非常适合用来收集副作用。
ini
function bubbleProperties(completedWork) {
let subtreeFlags = NoFlags;
let child = completedWork.child;
while (child !== null) {
subtreeFlags |= child.subtreeFlags;
subtreeFlags |= child.flags;
// 继续合并子树的所有 childLanes
completedWork.childLanes = mergeLanes(
completedWork.childLanes,
child.childLanes
);
child = child.sibling;
}
completedWork.subtreeFlags |= subtreeFlags;
}
这样的收集方式有一个好处,在渲染阶段通过任意一级的 FiberNode.subTreeFlags 都可以快速确定该 FiberNode 以及子树是否存在副作用,从而判断是否需要执行和副作用相关的操作。
在 completeWork 完成后,React 会通过如下方式构建副作用链表(firstEffect/lastEffect):
ini
function commitRootImpl(root, renderPriorityLevel) {
// 获取副作用链表
const finishedWork = root.finishedWork;
let firstEffect = finishedWork.firstEffect;
// 遍历处理副作用
while (firstEffect !== null) {
const effect = firstEffect;
const next = effect.nextEffect;
commitMutationEffectsOnFiber(effect, root);
firstEffect = next;
}
}
commit 阶段
render 阶段的行为是在内存中运行的,这意味着可能被打断,也可以被打断。而 commit 阶段则是一旦开始,就会同步执行,直到完成。commit 阶段整体可以分为三个子阶段
- BeforeMutation 阶段
- Mutation 阶段
- Layout 阶段
BeforeMutation阶段
- ClassComponent:执行getSnapshotBeforeUpdate方法
- HostRoot:清空 HostRoot 挂载的内容,方便 Mutation 阶段渲染
ini
function commitBeforeMutationEffectsOnFiber(finishedWork) {
const current = finishedWork.alternate;
const flags = finishedWork.flags;
// ...
// Snapshot 表示 ClassComponent 存在更新,且定义了 getSnapshotBeforeUpdate 生命周期函数
if (flags & SnapShot != NoFlags) {
switch (finishedWork.tag) {
case ClassComponent: {
if (current !== null) {
const prevProps = current.memoizedProps;
const prevState = current.memoizedState;
const instance = finishedWork.stateNode;
// 调用 getSnapshotBeforeUpdate 生命周期函数
const snapshot = instance.getSnapshotBeforeUpdate(
finishedWork.elementType === finishedWork.type ? prevProps : resolveDefaultProps(finishedWork.type, prevProps),
prevState
);
}
break;
}
case HostRoot: {
// 清空 HostRoot 挂载的内容,方便 Mutation 阶段渲染
if (supportsMutation) {
const root = finishedWork.stateNode;
clearContainer(root.containerInfo);
}
break
}
}
}
}
Mutation 阶段
对于 HostComponent,Mutation 阶段的主要工作就是对 DOM 元素进行增删改
删除 DOM 元素
删除 DOM 元素的操作发生在commitMutationEffects_begin方法中,首先会拿到 deletions 数组,之后遍历该数组进行删除操作,对应方法为commitDeletion。
ini
function commitMutationEffects_begin(root) {
while (nextEffect !== null) {
// 删除DOM 元素
const deletions = fiber.deletions;
if (deletions !== null) {
for (let i = 0; i < deletions.length; i++) {
const childToDelete = deletions[i];
try {
commitDeletion(root, childToDelete, fiber);
} catch(error) {
// ...省略错误处理
}
}
}
const child = fiber.child;
if ((fiber.subtreeFlags & MutationMask) !== NoFlags && child !== null) {
nextEffect = child;
} else {
commitMutationEffects_complete(root);
}
}
}
commitDeletion内部的完整逻辑实际上是比较复杂的,原因是在删除 DOM 元素的时候,还需要考虑以下的一些因素
- 子树中所有组件的 unmount 逻辑
- 子树中所有 ref 的卸载操作
- 子树中所有 Effect 相关 hook 的 destory 回调
整个删除操作是以 DFS 的顺序遍历子树的每个 FiberNode,执行对应操作
插入,移动 DOM 元素
插入和移动 DOM 元素则是在commitMutationEffects_complete 方法中的commitMutationEffectsOnFiber 中执行的,Placement 对应的操作方法为commitPlacement
javascript
function commitMutationEffectsOnFiber(finishedWork, root) {
const flags = finishedWork.flags;
//...
const primaryFlags = flags & (Placement | Updata | Hydrating)
outer: switch (primaryFlags) {
case Placement: {
// 执行 placement 对应操作
commitPlacement(finishedWork, root);
// 清除 Placement 标记
finishedWork.flags &= ~Placement;
break;
}
case PlacementAndUpdata: {
// 执行 placement 对应操作
commitPlacement(finishedWork, root);
// 清除 Placement 标记
finishedWork.flags &= ~Placement;
// 执行 update 对应操作
const current = finishedWork.alternate;
commitWork(current, finishedWork);
break;
}
// ...
}
}
scss
function commitPlacement(finishedWork) {
// 获取 Host 类型的祖先 FiberNode
const parentFiber = getHostParentFiber(finishedWork);
// 省略 parentFiber 获取对应 DOM 元素的逻辑
let parent;
// 目标DOM 会插入至 before 左边
let before;
// 省略分支逻辑
// 执行插入或移动操作
insertOrAppendPlacementNode(finishedWork, before, parent);
}
整个 commitPlacement 的流程可以分为三个步骤
- 获取父元素parentNode,用于执行插入动作
- 获取 before 对应的元素(相邻参考点)
- 执行parentNode.insertBefore(有 before)或parentNode.appendChild(没有 before)
对于还没有插入的 DOM 元素(对应的就是 mount 场景),insertBefore 就会将目标元素插入到 before 之前,appendChild 会将目标 DOM 元素作为父 DOM 的最后一个子元素插入
对于 UI 中已经存在的 DOM 元素(对应 update 场景),insertBefore 会将目标 DOM 元素移动到 before 之前,appendChild 会将目标 DOM 元素移动到同级最后。
这也就解释了为什么插入和移动都使用的是Placement进行标记,因为不管是插入还是移动,本质都是 DOM 位置的变化,类似快递员不需要区分是"新快递"还是"调整派送顺序",他只需要知道物品要放在哪个位置。
更新 DOM 元素
更新 DOM 元素一个最主要的工作就是更新对应的属性,执行的方法为 commitwork
ini
function commitWork(current, finishedWork) {
switch(finishedWork.tag) {
// 省略其他类型处理逻辑
case HostComponent: {
const instance = finishedWork.stateNode;
if (instance !== null) {
const newProps = finishedWork.memoizedProps;
const oldProps = current !== null ? current.memoizedProps : newProps;
const type = finishedWork.type;
const updatePayload = finishedWork.updateQueue;
finishedWork.updateQueue = null;
if (updatePayload !== null) {
// 存在变化的属性
commitUpdate(instance, updatePayload, type, oldProps, newProps);
}
}
return
}
}
}
变化的属性会以 key, value 相邻的形式保存在 fiberNode.updateQueue(completeWork 阶段),最终在 FiberNode.updateQueue里面所保存的要变化的属性就会在一个名为 updateDOMProperties方法被遍历然后进行处理。
scss
function updateDOMProperties(domElement, updatePaylod, wasCustomComponentTag, isCustomComponentTag) {
for (let i = 0; i < updatePaylod.length; i += 2) {
const propKey = updatePaylod[i];
const propValue = updatePaylod[i + 1];
if (propKey === STYLE) {
// 处理 style
setValueForStyles(domElement.style, propValue);
} else if (propKey === DANGEROUSLY_SET_INNER_HTML) {
// 处理 dangerouslySetInnerHTML
setInnerHTML(domElement, propValue);
} else if (propKey === CHILDREN) {
// 处理直接的文本节点
setTextContent(domElement, propValue);
} else {
// 处理其他属性
setValueForProperty(domElement, propKey, propValue, isCustomComponentTag);
}
}
}
Mutation 阶段的主要工作完成后,在进入 layout 之前会执行如下的代码完成 FiberTree 的切换
ini
root.current = finishedWork
Layout阶段
有关 DOM 元素的操作,在 mutation 阶段就已经结束了
在 Layout 阶段,主要的工作集中在 commitLayoutEffectOnFiber 方法中,在该方法内部会针对不同类型的 FiberNode 执行不同操作
- 对于 ClassComponent:会执行 ComponentDidMount/Update 阶段
- 对于 FunctionComponent:会执行 useLayoutEffect 回调函数
Layout 执行完成后会异步执行 effect 的调度,而useLayoutEffect会在 Layout 阶段同步执行。
调度器(Scheduler)
为什么需要有调度器
执行 JS 与渲染流水线是在同一个线程执行,如果 JS 执行的时间过长,就不能及时渲染下一帧,页面就会卡顿。
在 React v16 之前, React 中需要去计算整棵虚拟 DOM 树,虽然相比于直接操作 DOM 节省了很多时间,但是每次重新计算整棵虚拟 DOM 树,会造成每一帧的 JS 代码的执行时间过长,从而导致动画还有一些实时更新得不到及时的响应,造成卡顿的视觉效果。在 v16 版本以前,遍历 DOM 树只能使用递归,并且这种递归是不能打断的,从而造成 JS 执行时间过长。这样的架构模式称为 stack,因为会不停的开启函数栈。
对于 React 来讲,所有的操作都是来自于状态的变化导致的重新渲染,我们只需要针对不同的操作,赋予不同的优先级即可。主要包含以下三点:
- 为不同的操作造成的状态变化赋予不同的优先级
- 所有优先级统一调度,优先处理最高优先级的更新
- 如果更新正在进行(进入虚拟 DOM 相关工作),此时有更高优先级的更新产生的话,中断当前的更新,优先处理更高优先级更新
要实现这三点,就要 React 底层能实现:
- 用于调度优先级的调度器
- 调度器对应的调度算法
- 支持可中断的虚拟 DOM 的实现
MessageChannel
message channel 本身是用于做消息通信的,允许我们创建一个消息通道,通过它的两个 MessagePort 来进行信息的发送和接收。
javascript
<div>
<div id='btn1'>给 port1 发消息</div>
<div id='btn2'>给 port2 发消息</div>
</div>
const btn1 = document.getElementById('btn1')
const btn2 = document.getElementById('btn2')
const channel = new MessageChannel()
const port1 = channel.port1
const port2 - channel.port2
btn1.onClick = function() {
port2.postMessage('消息')
}
port1.onmessage = function(event) {
console.log('port1 收到了来自 port2 的消息:', event.data)
}
scheduler是用来调度任务的,需要满足两个条件:
- JS 暂停,将主线程还给浏览器,让浏览器能够有序的重新渲染页面
- 暂停了的 JS,需要在下一次接着来执行
我们可以将没有执行完的 JS 放入到任务队列,下一次事件循环的时候再取出来执行。这里就需要产生一个任务(宏任务),这里就可以使用 MessageChannel,因为 MessageChannel 能够产生宏任务。
为什么不选择 setTimeout
因为 setTimeout 在嵌套层级超过 5 层,延时如果小于 4ms,就会被设置为 4ms(HTML 规范, 参考 html.spec.whatwg.org/multipage/t...)
vbnet
If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.
为什么不选择 requestAnimationFrame
因为这个只能在重新渲染之前,才能够执行一次,而如果我们包装成一个任务,放到任务队列中,那么只要没到重新渲染的时间,就可以一直从任务队列里面取任务来执行。而且requestAnimationFrame这个 API 存在一定的兼容性问题。chrome和 firefox 是将requestAnimationFrame 放在渲染之前执行的,safari 和 edge 是放在渲染之后执行。根据标准是应该放在渲染之前执行的。
为什么不选择requestIdleCallback
- 仅以"空闲"作为唯一触发条件,无法满足高优先级任务插队机制和时间切片
- 存在兼容性问题,Safari不支持
为什么没有选择包装成一个微任务
因为微任务的执行机制。微任务队列会在清空整个队列之后才会结束。微任务会在页面更新之前一直执行,直到队列被清空,达不到将主线程还给浏览器的目的。
最小堆
最小堆(Min Heap)是一种常见的数据结构,属于二叉堆的一种,具有以下核心特点和性质:
- 任意父节点的值 ≤ 其子节点的值
- 满足从左到右完全填充的树形结构(所有层除最后一层外都是满的)
- 可用一维数组高效实现
在 scheduler 中使用最小堆的数据结构对任务进行排序
- push(timerQueue, newTask)
- pop(timerQueue)
- timer = peek(timerQueue)
Scheduler 调度普通任务
shceduleCallback
该函数的主要目的就是用来调度任务
- 任务队列有两个,一个 taskQueue 存放普通任务,一个 timerQueue 存放延时任务
- 任务队列内部用到了小顶堆的算法,保证始终放进去(push)的任务能够正常排序,始终取出的是时间优先级最高的那个任务
- 根据传入的不同的 priorityLevel 优先级会进行不同的 timeout 设置,任务的 timeout 时间也就不一样了,有的比当前时间还要小,代表立即需要执行的,绝大部分时间比当前时间大
- 不同的任务,最终调用的函数不一样,普通任务会调用 requestHostCalback(flushwork)
- 延时任务 requestTimeout(handleTimeout, startTime - currentTime)
ini
let getCurrentTime = () => performance.now();
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
var IDEL_PRIORITY_TIMEOUT = maxSigned31BitInt;
// 有两个队列,分别存储普通任务和延时任务,里面采用了小顶堆算法,保证每次从队列取出的都是优先级最高
var taskQueue = [];
var timerQueue = [];
function unstable_scheduleCallback(priorityLevel, callback, options) {
// 获取当前时间
let currentTime = getCurrentTime();
// 计算开始时间, 如果有delay, 则开始时间为当前时间加上delay
var startTime = currentTime;
if (typeof options === 'object' && options !== null) {
var delay = options.delay;
if (typeof delay === 'number' && delay > 0) {
startTime += delay;
} else {
startTime = currentTime;
}
} else {
startTime = currentTime;
}
// 根据优先级等级计算超时时间
var timeout;
switch (priorityLevel) {
case ImmediatePriority:
timeout = IMMEDIATE_PRIORITY_TIMEOUT;
break;
case UserBlockingPriority:
timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
break;
case NormalPriority:
timeout = NORMAL_PRIORITY_TIMEOUT;
break;
case LowPriority:
timeout = LOW_PRIORITY_TIMEOUT;
break;
case IdlePriority:
timeout = IDEL_PRIORITY_TIMEOUT;
break;
default:
timeout = NORMAL_PRIORITY_TIMEOUT;
break;
}
// 计算过期时间, 有些会比当前时间早,绝大部分会比当前时间晚一些
var expirationTime = startTime + timeout;
// 创建一个新的任务
var newTask = {
id: taskIdCounter++, // 任务id
callback, // 该任务具体要做的事情
priorityLevel, // 任务优先级
startTime, // 任务开始时间
expirationTime, // 任务过期时间
sortIndex: -1 // 任务排序索引, 用于后面在小顶堆排序的索引
};
if (startTime > currentTime) {
// 如果任务开始时间大于当前时间, 说明任务是延迟执行的, 将任务添加到延迟队列中
newTask.sortIndex = startTime;
push(timerQueue, newTask);
// 进入此 if 说明 taskQueue 中任务已经执行完毕了,并且从 timerQueue 中取出的任务就是当前任务
if (peek(taskQueue) === null && peek(timerQueue) === newTask) {
// 下面的 if else 就是一个开关
if (isHostTimeoutScheduled) {
cancelHostTimeout();
} else {
isHostTimeoutScheduled = true;
}
// 如果是延时任务,调用requestHostTimeout进行任务的调度
requestHostTimeout(handleTimeout, startTime - currentTime);
}
} else{
// 说明不是延时任务
newTask.sortIndex = expirationTime;
// 将任务添加到普通任务队列中
push(taskQueue, newTask);
// 最终会调用 requestHostCallback 进行任务的调度
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback(flushWork);
}
}
return newTask;
}
requestHostCallback 和 schedulePerformWorkUntilDeadline
- requestHostCallback 主要就是调用的schedulePerformWorkUntilDeadline
- schedulePerformWorkUntilDeadline一开始是 undefined,根据不同的环境选择不同的生成宏任务的方式
ini
// 这个函数没什么用,主要就是调用 schedulePerformWorkUntilDeadline
function requestHostCallback(callback) {
scheduledHostCallback = callback;// 就是 flushWork
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
schedulePerformWorkUntilDeadline(); // 实例化 MessageChannel 进行后面的调度
}
}
let schedulePerformWorkUntilDeadline;
if (typeof setImmediate === 'function') {
// nodejs and old IE
schedulePerformWorkUntilDeadline = () => {
localSetImmediate(performWorkUntilDeadline);
};
} else if (typeof MessageChannel !== 'undefined') {
// 大多数情况下使用的是 MessageChannel
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
} else {
// 浏览器不支持 MessageChannel, 使用 setTimeout进行兜底
schedulePerformWorkUntilDeadline = () => {
localTimeout(performWorkUntilDeadline, 0);
};
}
performWorkUntileDeadline
- 该方法实际上主要就是在调用 scheduledHostCallback(flushWork),调用之后,返回一个布尔值,根据布尔值来判断是否还有剩余的任务,如果还有,就是用 MessageChanle 进行宏任务的包装,放入到任务队列
ini
let startTime = -1;
const performWorkUntilDeadline = () => {
// scheduledHostCallback 就是 flushWork
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime();
// 这里的 startTime 并非unstable_scheduleCallback,而是一个全局变量,默认值为-1
// 用来测量任务的执行时间,从而能够知道主线程被阻塞了多久
startTime = currentTime;
const hasTimeRemaining = true; // 默认还有剩余时间
let hasMoreWork = true; // 默认还有任务要执行
try {
// scheduledHostCallback ---> flushWork(true, 开始时间):boolean
// 如果是true,代表工作没做完
// 如果是 false,代表没有任务了
hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
} finally {
if (hasMoreWork) {
// 那么就使用 message channel进行一个 message 事件的调度,就将任务放到任务队列里面
schedulePerformWorkUntilDeadline();
} else {
isMessageLoopRunning = false;
scheduledHostCallback = null; // 之前为 flushWork,设置为空
}
}
} else {
isMessageLoopRunning = false;
}
needsPaint = false;
}
flushWork和 workLoop
- flushWork 主要就是在 workLoop,返回 workLoop 的返回值
- workLoop 首先有一个 while 循环,该 while 循环保证了能够从任务队列中不停地取任务出来
-
- while (currentTask !== null){// ....}
- 每次取出任务后还要进行一系列的判断
-
- if (currentTask.expirationTime > currentTime &&(!hasTimeRemaining || shouldYieldToHost())) {break}
- shouldYieldToHost任务是否应该暂停,归还主线程
- 如果进入 if,说明因为某些原因不能再执行任务,需要立即归还 主线程,那么就跳出 while
scss
/**
*
*
* @param {*} hasTimeRemaining 是否有剩余时间,一开始是 true
* @param {*} initialTime
*/
function flushWork(hasTimeRemaining, initialTime) {
// ...
try {
if (enableProfiling) {
try {
return workLoop(hasTimeRemaining, initialTime)
} catch(error) {
if (currentTask !== null) {
const currentTIme = getCurrentTime()
markTaskErrored(currentTask, currentTime)
currentTask.isQueued = false
}
}
} else {
return workLoop(hasTimeRemaining, initialTime)
}
} finally {
currentTask = null
currentPriorityLevel = previousPriorityLevel;
isPerformWork = false
if (enableProfiling) {
const currentTime = getCurrentTime()
markSchedulerSuspended(currentTime)
}
}
}
function workLoop(hasTimeRemaining, initalTIme) {
let currentTime = initalTIme
// 遍历 timerQueue,判断是否有已经到期了的任务,如果有,将任务放入到 taskQueue
advanceTimers(currentTime)
currentTask = peek(taskQueue)
while (currentTask !== null) {
if (currentTask.expirationTime > currentTime &&
(!hasTimeRemaining || shouldYieldToHost())
) {
// currentTask.expirationTime > currentTime表示任务还没有过期
// !hasTimeRemaining 表示没有剩余时间
// shouldYieldToHost() 任务是否应该暂停,归还主线程
// 那么我们就跳出 while
break
}
// 没有进入到上面的 if 说明这个任务到过期时间了,并且有剩余时间去执行,没有到达需要浏览器渲染的时间
// 那我们就执行该任务即可
const callback = currentTask.callback
if (typeof callback === 'function') {
// 说明当前的任务是一个函数,执行该任务
currentTask.callback = null
currentPriorityLevel = currentTask.priorityLevel
// ...
// 任务的执行实际就是这句,其他地方在做一些收集的工作
const continuationCallback = callback(didUserCallbackTimeout)
currentTime = getCurrentTime()
advanceTimers(currentTime)
return true
//...
} else {
pop(taskQueue)
}
currentTask = peek(taskQueue)
if (currentTask !== null) {
// 如果不为空,代表还有更多任务,那么外部的 hasMoreWork 也就是 true
return true
} else {
// taskQueue空了,就去 timerQueue 中去看延时任务
const firstTimer = peek(timerQueue)
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime)
}
// 说明延时队列也完成了,返回 false
return false
}
}
}
shouldYieldToHost
- 首先计算 timeElapsed 判断是否超时
- frameInterval 默认设置的是 5ms
csharp
function shouldYieldToHost() {
// 当前时间减去任务开始时间
// startTime一开始是-1,任务开始时,将任务开始时间赋值给了它
const timeElapsed = getCurrentTime() - startTime
// 主线程线程只被阻塞了一点点时间,没有达到需要归还的时候
// frameInterval默认设置的是 5ms
if (timeElapsed < frameInterval) {
return false
}
// ...
// 如果没有进入上面的 if,说明主线程已经被阻塞了一段时间了,需要归还主线程
return true
}
advanceTimers
- 该方法就是遍历 timerQueue,查看是否有已经过期的方法,如果有,不是直接执行,而是将其添加到 taskQueue
scss
function advanceTimer(currentTime) {
// 从 timerQueue 中获取一个任务
let timer = peek(timerQueue)
// 遍历 timerQueue
while (timer !== null) {
if (timer.callback = null) {
pop(timerQueue)
} else if (timer.startTime < current) {
// 说明当前任务已经不再是延时任务了,我们需要将其转移到 taskQueue
pop(timerQueue);
timer.sortIndex = timer.expirationTime
push(taskQueue, timer)
// ...
} else {
return
}
timer = peek(timerQueue)
}
}
Schedluer 调度延时任务
requestHostTimeout
- 调度一个延时任务的时候主要是执行一个 requestHostTimeout
- 实际上就是调用 setTimeout 然后在 setTimeout 中调用传入的 handleTimeout
javascript
const localSetTimeout = typeof setTimeout === 'function' ? setTimeout : null
/**
*
*
* @param {*} callback 传入的任务
* @param {*} ms 延时时间
*/
function requestHostTimeout(callback, ms) {
taskTimeoutID = localSetTimeout(() => {
callback(getCurrentTime())
}, ms)
handleTimeout
- 主要就是调用 advanceTimer 方法,将已经到时间的延时任务放入 taskQueue,然后使用 requestHostCallback 进行调度
- 如果taskQueue 中没有任务了,就再次从 timerQueue 中取出一个延时任务然后通 requestTimeout 进行调度
scss
/**
*
*
* @param {*} currentTime 当前时间
*/
function handleTimeout(currentTime) {
isHostTimeoutScheduled = false
// 遍历 timerQueue,将已经到期的延时任务放入到 taskQueue
advanceTimers(currentTime)
if (!isHostCallbackScheduled) {
// 从普通任务队列中取出一个任务,采用调度普通任务的方式进行调度
if (peek(taskQueue) !== null) {
isHostCallbackScheduled = true
requestHostCallback(flushWork)
} else {
// taskQueue 为空,就再从 timerQueue 中取出一个任务,取出的延时任务仍然使用requestHostTimeout进行调度
const firstTimer = peek(timerQueue)
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime)
}
}
}
}
scheduler 总结
核心流程:
- 不同优先级的任务对应不同的超时时间,任务根据优先级计算超时时间timeout,timeout可能是负数,根据配置存在延迟时间delay, 通过timeout + delay 计算出执行时间startTime, 与当前时间currentTime比较,如果startTime小于currentTime,说明是立即执行的任务,加入任务队列,否则是延迟任务,加入延迟队列
- 通过performWorkUntilDeadline开启workLoop,workLoop中,根据剩余任务和剩余时间,判断是否需要跳出,跳出时返回是否还需要继续执行
- 如果还需要继续执行,就利用事件循环机制,在浏览器空余后继续执行,机制主要是用MessageChannel,如果不支持也有其他的兜底策略,如node中使用setImmediate,兜底使用计时器
- 任务队列执行完毕后后通过advanceTimer将延时队列中需要执行的任务放入任务队列中进行执行
任务执行的核心流程
- 通过 performWorkUntilDeadline 开启任务
- performWorkUntilDeadline 中 通过flushWork 开启workLoop, flushWork返回是否还有任务需要执行
- schedulePerformWorkUntilDeadline, 如果还有任务需要执行,schedulePerformWorkUntilDeadline 异步下一次 performWorkUntilDeadline
- workLoop中 通过 shouldYieldToHost 判断是否要跳出循环, 并返回是否还有任务要执行
流程图
DIFF 算法
render 阶段会生成 Fiber Tree,diff 实际就发生在这个阶段。这里的 diff 指的是 curren FiberNode 和 JSX 对象之间进行对比,然后生成新的 wipFiberNode。
除了 react,其他使用到了虚拟 DOM 的前端框架也会有类似的流程,比如 vue 里面将这个流程称之为 patch。
diff 算法本身是有性能上的消耗的,想完整的比较两棵树,算法的复杂度会达到O(n^3)
为了降低算法的复杂度,react 为 diff 算法设置了三个限制
- 只对同级元素进行 diff 。如果一个 DOM 元素在前后两次更新中跨越了层级,那么 react 不会尝试复用它
- 两个不同类型的元素会产生不同的树,比如元素从 div 变成了 p,那么 react 会直接销毁 div 以及子孙元素,新建 p 以及 p 对应的子孙元素
- 可以通过 key 暗示哪些子元素能够保持稳定
javascript
// 更新前
<div>
<p key="one">one</p>
<h3 key="two">two</h3>
</div>
// 更新后
<div>
<h3 key="two">two</h3>
<p key="one">one</p>
</div>
如果没有 key,react 就会认为 div 的第一个子元素从p 变成了 h3,第二个从 h3 变成了 p,两个元素都会被直接销毁然后重新创建这两个元素。
如果使用了 key,那么此时的 DOM 元素是可以复用的,只不过前后交换了位置而已。
针对同级元素进行 diff,整个 diff 流程可以分为两大类
- 更新后只有一个元素,此时就会根据 newChild 创建对应的 wipFiberNode,对应的流程就是单节点 diff
- 更新后有多个元素,此时就会遍历 newChild 创建对应的 wipFiberNode 以及它的兄弟元素,此时对应的流程就是多节点 diff
单节点 diff
单节点指的是新节点为单一节点,但是旧节点的数量是不一定的
单节点 diff 是否能够复用,遵循以下流程
- 判断 key 是否相同
-
- 如果更新前后没有设置 key,key 就是 null,也是属于相同的情况
- 如果 key 相同,就会进入到步骤二
- 如果 key 不同,就不需要进入步骤2,无需判断 type,直接不能复用(如果有兄弟节点,还会去遍历兄弟节点)
- 如果 key 相同,在判断 type 是否相同
-
- 如果type 相同,那么就复用
- 如果 type 不同,无法复用(并且兄弟节点也一并标记为删除)
less
// 更新前
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
//更新后
<ul>
<p>1</p>
</ul>
上面的例子中,由于没有设置 key,会被认为 key 是相同的。接下来就会进入 type 判断,此时发现 type 不同,因此不能够复用,会直接标记兄弟 fiberNode 为删除状态。
如果上面的例子中,key 不同只能代表当前的 FiberNode 无法复用,因此还需要去遍历兄弟 FiberNode
多节点 diff
指的是新节点有多个。React 团队发现,在日常开发中,对节点的更新操作,往往多于对节点的新增,移动和删除。因此在进行多节点 diff 的时候,React 会进行两轮遍历
- 第一轮遍历会尝试逐个的复用节点
- 第二轮遍历处理上一轮遍历中没有处理完的节点
第一轮遍历
第一轮遍历会从前往后依次遍历,存在三种情况
- 如果新旧子节点的 key 和 type 都相同,说明可以复用
- 如果新旧子节点的 key 相同,但是 type 不同,这时候就会根据 ReactElement 来生成一个全新的 Fiber,旧的 fiber 被放入到 deletions 数组中,回头统一删除,但是此时遍历并不会终止
- 如果新旧子节点的 key 和 type 都不相同,结束遍历
less
// 更新前
<div>
<div key='a'>a</div>
<div key='b'>b</div>
<div key='c'>c</div>
<div key='d'>d</div>
</div>
// 更新后
<div>
<div key='a'>a</div>
<div key='b'>b</div>
<div key='e'>e</div>
<div key='d'>d</div>
</div>
首先 遍历到div.key.a发现该 fibernode 可以复用,继续发现 div.key.b也可以复用,到 div.key.e发现 key 不同,第一轮遍历就结束了
示例二
less
// 更新前
<div>
<div key='a'>a</div>
<div key='b'>b</div>
<div key='c'>c</div>
<div key='d'>d</div>
</div>
// 更新后
<div>
<div key='a'>a</div>
<div key='b'>b</div>
<p key='c'>c</div>
<div key='d'>d</div>
</div>
首先和上面一样,a,b 节点复用,但是第三个节点 key 相同,type 不同,此时就会将对应旧的 FiberNode 放入 deletions 数组中,回头进行统一删除,根据新的 React 元素创建一个新的 FiberNode,但是此时的遍历是不会结束的,继续向后遍历
第二轮遍历
如果第一轮遍历被提前终止了,那么意味着有新的 React 元素,或者旧的 FiberNode 没有遍历完,此时就会采用第二轮遍历,第二轮遍历会处理三种情况
- 只剩下旧子节点(删除的情况):将旧的子节点添加掉 deletions 数组里面,直接删除掉
- 只剩下新的 jsx 元素(新增的情况):根据 ReactElement 元素来创建 FiberNode 节点
- 新旧节点都有剩余(移动的情况):会将剩余的 FiberNode 放入到一个 map 里面,遍历剩余新的 JSX 元素,然后去 map 中寻找能够复用的 FiberNode 节点,如果能够找到,拿来复用。如果不能找到,就新增。如果剩余的所有 JSX 元素都遍历完了,map 中还有剩余的 Fiber 节点,就将这些Fiber 节点添加到 deletions 数组中,之后做统一删除。
双端对比算法
双端对比,指的是在新旧子节点的数组中,各用两个指针指向头尾,在遍历过程中头尾两个指针同时向中间靠拢。
因此在新子节点数组中会有两个指针 newStartIndex,newEndIndex 分别指向新子节点的头和尾,在旧子节点数组中也会有两个指针 oldStartIndex,oldEndIndex 分别指向旧子节点数组的头和尾。
每遍历到一个节点,就会尝试进行双端比较:【新前:旧前】,【新后:旧后】,【新前:旧后】,【新后:旧前】,如果匹配成功,更新双端指针。比如【新前:旧后】匹配成功,newStartIndex += 1,oldEndIndex -= 1.
如果匹配成功,还需要将节点调整位置。
React 为什么不采用 vue 的双端对比算法?
双端 diff 需要向前查找,react 中的 FiberNode 节点没有反向指针,即前一个 FiberNode 通过 sibling 属性指向后一个 FiberNode,但是不能反过来。