学习react底层的过程,和学习其他原理一样,抓住一些关健的点,也就是关键的函数(往往代表了一些阶段),可以对源码的把握以及 图像化的流程更加清晰和易懂。
我们知道facebook团队在react16之后就对react底层有一些重大的重构,一句大白话来解释就是,让react可以实现异步可中断的更新。
至于怎么实现的,是react引入了Scheduler调度器,会分配给js线程一个初始的执行时间,源码里面yieldInterval=5ms,如果预留的时间不够浏览器渲染的话,那么react就会将控制权交给浏览器,等到下一帧再进行渲染。具体原理本篇不细讲。
但是在更新过程不是所有阶段都是异步的,这会造成一些预料之外的
回到状态更新上来,可以改变状态大概有这些:
-
ReactDOM.render
-
this.setState
-
this.forceUpdate
-
useState
-
useReducer
很显然,react开发着肯定会想到接入一套状态更新体制当中,怎么实现呢?答案就是,每次创建一个保存状态更新的对象,在render阶段根据该对象来计算新的state。然而在render阶段,是从rootFiber开始遍历,产生update对象的可能是fiber树中的一个节点,所有得有一种方式能够让react从节点回到顶点。这个关键的过程就是markUpdateLaneFromFiberToRoot函数。
function markUpdateLaneFromFiberToRoot(
sourceFiber: Fiber,
lane: Lane,
): FiberRoot | null {
// Update the source fiber's lanes
sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane);
let alternate = sourceFiber.alternate;
if (alternate !== null) {
alternate.lanes = mergeLanes(alternate.lanes, lane);
}
let node = sourceFiber;
let parent = sourceFiber.return;
while (parent !== null) {
parent.childLanes = mergeLanes(parent.childLanes, lane);
alternate = parent.alternate;
if (alternate !== null) {
alternate.childLanes = mergeLanes(alternate.childLanes, lane);
}
}
node = parent;
parent = parent.return;
}
if (node.tag === HostRoot) {
const root: FiberRoot = node.stateNode;
return root;
} else {
return null;
}
}
这就是源码,可以看出它不仅接收一个fiber还接收一个优先级,这也对应着update不仅仅有一维的状态保存,还有优先级。逻辑上来看,就是不断通过return指针来往上一层查找,直到找到最上层。
现在拥有了rootfiber立即更新的话肯定有个问题,那就是更新都是同步的,但是react是异步的,那就得调度更新了。核心的函数就是ensureRootIsScheduled。
核心代码如下:
if (newCallbackPriority === SyncLanePriority) {
//同步高优先级任务
newCallbackNode = scheduleSyncCallback(
//同步执行render阶段
performSyncWorkOnRoot.bind(null, root)
);
} else {
// 异步低优先级任务
var schedulerPriorityLevel = lanePriorityToSchedulerPriority(
newCallbackPriority
);
newCallbackNode = scheduleCallback(
schedulerPriorityLevel,
异步执行render阶段
performConcurrentWorkOnRoot.bind(null, root)
);
}
这样就让render阶段成为一个既可以异步也可以同步也有优先级的了,接下来可以光明正大的更新了。
Render阶段
注意,render阶段的开始于performSyncWorkOnRoot或performConcurrentWorkOnRoot方法的调用。
// performSyncWorkOnRoot会调用该方法--同步更新
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
// performConcurrentWorkOnRoot会调用该方法--异步更新
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
注意到异步更新里面有一个关键的函数shouldYield(),这个函数会监控浏览器当前帧是否有剩余时间,没有的话就是终止更新,等到下一帧继续执行。
workInProgress代表正在构建的fiber树,而performUnitOfWork会创建下一个fiber节点并连接上之前的节点,形成fiber树。
在整个render阶段,大概也可以分为两个重要的时期,对应fiber被遍历两遍的过程。第一个阶段就是beginWork()从rootFiber节点开始,向下进行遍历。当遍历到叶子节点时,就会执行completeWork()函数,从叶子节点一步一步到rootfiber。
下面来详细说一下beginWork,这个函数作用很简单,就是根据传入的fiber节点,创建子fiber节点以及通过对比新旧节点给节点打上一些effectTag标签,给commit阶段处理。总体上时分为两个阶段:
1.mount时,因为此时的current树还是空的,所有值会根据不同的fiber.tag来生成对应的fiber节点。
switch (workInProgress.tag) {
case IndeterminateComponent:
// ...省略
case LazyComponent:
// ...省略
case FunctionComponent:
// ...省略
case ClassComponent:
// ...省略
case HostRoot:
// ...省略
.
.
.
}
2.update时情况就会比较复杂,会在这个过程这个过程中通过调用reconcileChildren方法来进行diff(不深究),尝试最大可能的复用当前节点。
//reconcileChildren部分逻辑
if (current === null) {
// 对于mount的组件
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes,
);
} else {
// 对于update的组件
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes,
);
}
实际上,mount和update时都会调用reconcileChildren。但是为什么要调用两个不同的函数呢?
因为mount阶段fiber节点都是首次创建,如果都打上effectTag标签的话,会影响性能。因此,mount的时候,mountchildFibers只会给rootFiber节点打上一个placement的标记,一次性加入。
接下来就是completeWork(current,workInprogress)阶段,这个阶段的作用总结来说就是,mount时
根据fiber创建DOM节点,并且初始化DOM节点的props。update阶段就是处理diff之后的收尾工作,依据打完effectTag标签的fiber,形成一个需要更新的props组成的一个数组。
mount阶段和update阶段重要的函数:
const currentHostContext = getHostContext();
// 为fiber创建对应DOM节点
const instance = createInstance(
type,
newProps,
rootContainerInstance,
currentHostContext,
workInProgress,
);
// 将子孙DOM节点插入刚生成的DOM节点中
appendAllChildren(instance, workInProgress, false, false);
// DOM节点赋值给fiber.stateNode
workInProgress.stateNode = instance;
mount阶段也会初始化DOM节点上的props。
// update的情况
updateHostComponent(
current,
workInProgress,
type,
newProps,
rootContainerInstance,
);
//在updateHostComponent内部
workInProgress.updateQueue = (updatePayload: any);
其中updatePayload就是需要更新的props数组。
最后在completeWork阶段还有一个小细节,为了让commit阶段更加快速的操作DOM,在completeWork这个回宿阶段会格外的生成一个effectList数组,里面包含的是下一阶段需要更新的DOM。
至此,render阶段才算完成,这个过程可能不是一帆风顺的,会由于高优先级以及浏览器的渲染时间而终止以及恢复。现在currentFiber还没有变化,真正改变DOM的就是下一阶段,也是同步的,commit阶段。
commit的入口函数是commitRoot(root)
这个阶段大致分为三个阶段:
before mutation阶段----mutation阶段----layout阶段
1.befor mutation阶段。
主要就是遍历effectList,以及调用主函数commitBeforeMutationEffects,这个函数里面会调用getSnapshotBeforeUpdate这个钩子开始,这也看出来是在同步阶段执行,并且在componentDidMount之前执行。其次就是调度useEffect,这是个重头戏。
// 调度useEffect
if ((effectTag & Passive) !== NoEffect) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
scheduleCallback(NormalSchedulerPriority, () => {
// 触发useEffect
flushPassiveEffects();
return null;
});
}
}
这里额外聊一下,useEffect的调度过程,useEffect在这个阶段是通过flushPassiveEffects调度useEffect,但是不会立马执行,因为这是一个异步的过程,flushPassiveEffects依赖的参数rootWithPendingPassiveEffects此时为null,只有等到layout阶段,rootWithPendingPassiveEffects才会被赋值为effectList,执行回调,这也是useEffect异步回调的原理。
而且因为这个原因,useEffect的执行要比 componentDidMount、componentDidUpdate 执行要靠后,而且视图已经更新,不会阻塞页面渲染。
2.mutation阶段。
真正执行DOM操作的过程。这个阶段也是遍历effectList,主函数是commitMutationEffects。接收一个fiberRoot和更新级作为参数。
function commitMutationEffects(root: FiberRoot, renderPriorityLevel) {
// 遍历effectList
while (nextEffect !== null) {
const effectTag = nextEffect.effectTag;
.
.
.
}
- 充值文字节点,在react里面文字节点是被单独分开处理的
- 更新ref
- 根据不同的effectTag执行不同的操作,包括Placement,update,deletion,以及Hydrating。
下面来聊聊具体的effectTag,Placement tag调用了commitPlacement函数,获取父fiber节点,再进行insertBefore或着appendChild的DOM操作插入DOM节点。而当fiber中包含Update effectTag会调用commitUpdate,执行函数组件的useLayoutEffect的销毁函数,对于原生DOM组件,会将在completeWork阶段附着在fiber节点上的updateQuene对应的内容渲染到页面上。至于Deletion effectTag,就是删除fiber节点以及解绑ref,执行compoentWillUnmount。并且会执行useEffect的销毁函数。
3.layout阶段。
和之前的阶段一样,这个阶段也是会遍历effectList。这个阶段的DOM结构已经更新渲染完成。这个阶段的主函数就是commitLayoutEffects。主要的逻辑代码如下:
function commitLayoutEffects(root: FiberRoot, committedLanes: Lanes) {
while (nextEffect !== null) {
const effectTag = nextEffect.effectTag;
// 调用生命周期钩子和hook
if (effectTag & (Update | Callback)) {
const current = nextEffect.alternate;
commitLayoutEffectOnFiber(root, current, nextEffect, committedLanes);
}
// 赋值ref
if (effectTag & Ref) {
commitAttachRef(nextEffect);
}
nextEffect = nextEffect.nextEffect;
}
}
1.commitLayoutEffectOnFiber(之前的版本叫commitLifeCycles),对于类组件,会根据current==null来判断执行componentDidmount以及componentDidupdate函数。而且此时setState的第二个参数回调函数会在此执行。对于函数组件,他会调用useLayoutEffect的回调函数以及调度useEffect的销毁和回调函数。注意一点,此时useEffect并没有执行,等到layout阶段之后再执行。
2.commitAttachRef,这个函数做的事情比较简单,获取实例DOM实例,然后再更新ref,如果ref是个回到函数,也是会执行,是对象实例则直接赋值。
至此整个流程差不多结束,但是内存里面的两颗fiber树是啥时候切换的呢?
答案是:在mutation和layout阶段之间。
总结一下从状态更新,到页面显示的全过程:
- 调用不同的方法状态更新
- 创建Update对象
- 调用markUpdateLaneFromFiberToRoot回归到root节点
- 调用ensureRootIsScheduled,进行调度更新
- render阶段
- commit阶段
这只是从底层原理和流程的角度去看整个更新过程,react在这个给过程中也有许多的地方值得深入学习,比如采用位运算以及位标识,用一个数组的奇偶数来保存相反的回调函数...不过应该也算是讲明白了一些流程。学习路上进无止境,一起加油!
如果有不对的地方,烦请读者指出,共同进步!!!