准备工作
为了方便讲解,假设我们有下面这样一段代码:
function App(){
const [count, setCount] = useState(0)
useEffect(() => {
setCount(1)
}, [])
const handleClick = () => setCount(count => count++)
return (
勇敢牛牛, 不怕困难
{count}
)
}
ReactDom.render( , document.querySelector('#root'))
在React项目中,这种jsx语法首先会被编译成:
React.createElement("App", null)
or
jsx("App", null)
这里不详说编译方法,感兴趣的可以参考:
babel在线编译
新的jsx转换
jsx语法转换后,会通过creatElement
或jsx
的api转换为React element
作为ReactDom.render()
的第一个参数进行渲染。
在上一篇文章Fiber
中,我们提到过一个React项目会有一个fiberRoot
和一个或多个rootFiber
。fiberRoot
是一个项目的根节点。我们在开始真正的渲染前会先基于root
DOM创建fiberRoot
,且fiberRoot.current = rootFiber
,这里的rootFiber
就是current
fiber树的根节点。
if (!root) {
// Initial mount
root = container._reactRootContainer = legacyCreateRootFromDOMContainer(container, forceHydrate);
fiberRoot = root._internalRoot;
}
在创建好fiberRoot
和rootFiber
后,我们还不知道接下来要做什么,因为它们和我们的
函数组件没有一点关联。这时React开始创建update
,并将ReactDom.render()
的第一个参数,也就是基于
创建的React element
赋给update
。
var update = {
eventTime: eventTime,
lane: lane,
tag: UpdateState,
payload: null,
callback: element,
next: null
};
有了这个update
,还需要将它加入到更新队列中,等待后续进行更新。在这里有必要讲下这个队列的创建流程,这个创建操作在React有多次应用。
var sharedQueue = updateQueue.shared;
var pending = sharedQueue.pending;
if (pending === null) {
// mount时只有一个update,直接闭环
update.next = update;
} else {
// update时,将最新的update的next指向上一次的update, 上一次的update的next又指向最新的update形成闭环
update.next = pending.next;
pending.next = update;
}
// pending指向最新的update, 这样我们遍历update链表时, pending.next会指向第一个插入的update。
sharedQueue.pending = update;
我将上面的代码进行了一下抽象,更新队列是一个环形链表结构,每次向链表结尾添加一个update
时,指针都会指向这个update
,并且这个update.next
会指向第一个更新:
上一篇文章也讲过,React最多会同时拥有两个fiber
树,一个是current
fiber树,另一个是workInProgress
fiber树。current
fiber树的根节点在上面已经创建,下面会通过拷贝fiberRoot.current
的形式创建workInProgress
fiber树的根节点。
到这里,前面的准备工作就做完了, 接下来进入正菜,开始进行循环遍历,生成fiber
树和dom
树,并最终渲染到页面中。
render阶段
这个阶段并不是指把代码渲染到页面上,而是基于我们的代码画出对应的fiber
树和dom
树。
workloopSync
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
在这个循环里,会不断根据workInProgress找到对应的child作为下次循环的workInProgress,直到遍历到叶子节点,即深度优先遍历。在performUnitOfWork
会执行下面的beginWork
。
beginWork
简单描述下beginWork
的工作,就是生成fiber
树。
基于workInProgress
的根节点生成
的fiber
节点并将这个节点作为根节点的child
,然后基于
的fiber
节点生成的
fiber
节点并作为
的fiber
节点的child
,如此循环直到最下面的牛牛
文本。
注意, 在上面流程图中,updateFunctionComponent
会执行一个renderWithHooks
函数,这个函数里面会执行App()
这个函数组件,在这里会初始化函数组件里所有的hooks
,也就是上面实例代码的useState()
。
当遍历到牛牛
文本时,它的下面已经没有了child
,这时beginWork
的工作就暂时告一段落,为什么说是暂时,是因为在completeWork
时,如果遍历的fiber
节点有sibling
会再次走到beginWork
。
completeWork
当遍历到牛牛
文本后,会进入这个completeWork
。
在这里,我们再简单描述下completeWork
的工作, 就是生成dom
树。
基于fiber
节点生成对应的dom
节点,并且将这个dom
节点作为父节点,将之前生成的dom
节点插入到当前创建的dom
节点。并会基于在beginWork
生成的不完全的workInProgress
fiber树向上查找,直到fiberRoot
。在这个向上的过程中,会去判断是否有sibling
,如果有会再次走beginWork
,没有就继续向上。这样到了根节点,一个完整的dom
树就生成了。
额外提一下,在completeWork
中有这样一段代码
if (flags > PerformedWork) {
if (returnFiber.lastEffect !== null) {
returnFiber.lastEffect.nextEffect = completedWork;
} else {
returnFiber.firstEffect = completedWork;
}
returnFiber.lastEffect = completedWork;
}
解释一下, flags > PerformedWork
代表当前这个fiber
节点是有副作用的,需要将这个fiber
节点加入到父级fiber
的effectList
链表中。
commit阶段
这个阶段的主要工作是处理副作用。所谓副作用就是不确定操作,比如:插入,替换,删除DOM,还有useEffect()
hook的回调函数都会被作为副作用。
commitWork
准备工作
在commitWork
前,会将在workloopSync
中生成的workInProgress
fiber树赋值给fiberRoot
的finishedWork
属性。相关参考视频讲解:进入学习
var finishedWork = root.current.alternate; // workInProgress fiber树
root.finishedWork = finishedWork; // 这里的root是fiberRoot
root.finishedLanes = lanes;
commitRoot(root);
在上面我们提到,如果一个fiber
节点有副作用会被记录到父级fiber
的lastEffect
的nextEffect
。
在下面代码中,如果fiber
树有副作用,会将rootFiber.firstEffect
节点作为第一个副作用firstEffect
,并且将effectList
形成闭环。
var firstEffect;
// 判断当前rootFiber树是否有副作用
if (finishedWork.flags > PerformedWork) {
// 下面代码的目的还是为了将这个effectList链表形成闭环
if (finishedWork.lastEffect !== null) {
finishedWork.lastEffect.nextEffect = finishedWork;
firstEffect = finishedWork.firstEffect;
} else {
firstEffect = finishedWork;
}
} else {
// 这个rootFiber树没有副作用
firstEffect = finishedWork.firstEffect;
}
mutation之前
简单描述mutation之前阶段的工作:
- 处理DOM节点渲染/删除后的 autoFocus、blur 逻辑;
- 调用getSnapshotBeforeUpdate,fiberRoot和ClassComponent会走这里;
- 调度useEffect(异步);
在mutation之前的阶段,遍历effectList
链表,执行commitBeforeMutationEffects
方法。
do { // mutation之前
invokeGuardedCallback(null, commitBeforeMutationEffects, null);
} while (nextEffect !== null);
我们进到commitBeforeMutationEffects
方法,我将代码简化一下:
function commitBeforeMutationEffects() {
while (nextEffect !== null) {
var current = nextEffect.alternate;
// 处理DOM节点渲染/删除后的 autoFocus、blur 逻辑;
if (!shouldFireAfterActiveInstanceBlur && focusedInstanceHandle !== null){...}
var flags = nextEffect.flags;
// 调用getSnapshotBeforeUpdate,fiberRoot和ClassComponent会走这里
if ((flags & Snapshot) !== NoFlags) {...}
// 调度useEffect(异步)
if ((flags & Passive) !== NoFlags) {
// rootDoesHavePassiveEffects变量表示当前是否有副作用
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
// 创建任务并加入任务队列,会在layout阶段之后触发
scheduleCallback(NormalPriority$1, function () {
flushPassiveEffects();
return null;
});
}
}
// 继续遍历下一个effect
nextEffect = nextEffect.nextEffect;
}
}
按照我们示例代码,我们重点关注第三件事,调度useEffect(注意,这里是调度,并不会马上执行)。
scheduleCallback
主要工作是创建一个task
:
var newTask = {
id: taskIdCounter++,
callback: callback, //上面代码传入的回调函数
priorityLevel: priorityLevel,
startTime: startTime,
expirationTime: expirationTime,
sortIndex: -1
};
它里面有个逻辑会判断startTime
和currentTime
, 如果startTime > currentTime
,会把这个任务加入到定时任务队列timerQueue
,反之会加入任务队列taskQueue
,并task.sortIndex = expirationTime
。
mutation
简单描述mutation阶段的工作就是负责dom渲染。
区分fiber.flags
,进行不同的操作,比如:重置文本,重置ref,插入,替换,删除dom节点。
和mutation之前阶段一样,也是遍历effectList
链表,执行commitMutationEffects
方法。
do { // mutation dom渲染
invokeGuardedCallback(null, commitMutationEffects, null, root, renderPriorityLevel);
} while (nextEffect !== null);
看下commitMutationEffects
的主要工作:
function commitMutationEffects(root, renderPriorityLevel) {
// TODO: Should probably move the bulk of this function to commitWork.
while (nextEffect !== null) { // 遍历EffectList
setCurrentFiber(nextEffect);
// 根据flags分别处理
var flags = nextEffect.flags;
// 根据 ContentReset flags重置文字节点
if (flags & ContentReset) {...}
// 更新ref
if (flags & Ref) {...}
var primaryFlags = flags & (Placement | Update | Deletion | Hydrating);
switch (primaryFlags) {
case Placement: // 插入dom
{...}
case PlacementAndUpdate: //插入dom并更新dom
{
// Placement
commitPlacement(nextEffect);
nextEffect.flags &= ~Placement; // Update
var _current = nextEffect.alternate;
commitWork(_current, nextEffect);
break;
}
case Hydrating: //SSR
{...}
case HydratingAndUpdate: // SSR
{...}
case Update: // 更新dom
{...}
case Deletion: // 删除dom
{...}
}
resetCurrentFiber();
nextEffect = nextEffect.nextEffect;
}
}
按照我们的示例代码,这里会走PlacementAndUpdate
,首先是commitPlacement(nextEffect)
方法,在一串判断后,最后会把我们生成的dom
树插入到root
DOM节点中。
function appendChildToContainer(container, child) {
var parentNode;
if (container.nodeType === COMMENT_NODE) {
parentNode = container.parentNode;
parentNode.insertBefore(child, container);
} else {
parentNode = container;
parentNode.appendChild(child); // 直接将整个dom作为子节点插入到root中
}
}
到这里,代码终于真正的渲染到了页面上。下面的commitWork
方法是执行和useLayoutEffect()
有关的东西,这里不做重点,后面文章安排,我们只要知道这里是执行上一次更新的effect unmount
。
fiber树切换
在讲layout
阶段之前,先来看下这行代码
root.current = finishedWork // 将`workInProgress`fiber树变成`current`树
这行代码在mutation和layout阶段之间。在mutation阶段, 此时的current
fiber树还是指向更新前的fiber
树, 这样在生命周期钩子内获取的DOM就是更新前的, 类似于componentDidMount
和compentDidUpdate
的钩子是在layout
阶段执行的,这样就能获取到更新后的DOM进行操作。
layout
简单描述layout阶段的工作:
- 调用生命周期或hooks相关操作
- 赋值ref
和mutation之前阶段一样,也是遍历effectList
链表,执行commitLayoutEffects
方法。
do { // 调用生命周期和hook相关操作, 赋值ref
invokeGuardedCallback(null, commitLayoutEffects, null, root, lanes);
} while (nextEffect !== null);
来看下commitLayoutEffects
方法:
function commitLayoutEffects(root, committedLanes) {
while (nextEffect !== null) {
setCurrentFiber(nextEffect);
var flags = nextEffect.flags;
// 调用生命周期或钩子函数
if (flags & (Update | Callback)) {
var current = nextEffect.alternate;
commitLifeCycles(root, current, nextEffect);
}
{
// 获取dom实例,更新ref
if (flags & Ref) {
commitAttachRef(nextEffect);
}
}
resetCurrentFiber();
nextEffect = nextEffect.nextEffect;
}
}
提一下,useLayoutEffect()
的回调会在commitLifeCycles
方法中执行,而useEffect()
的回调会在commitLifeCycles
中的schedulePassiveEffects
方法进行调度。从这里就可以看出useLayoutEffect()
和useEffect()
的区别:
useLayoutEffect
的上次更新销毁函数在mutation
阶段销毁,本次更新回调函数是在dom渲染后的layout
阶段同步执行;useEffect
在mutation之前
阶段会创建调度任务,在layout
阶段会将销毁函数和回调函数加入到pendingPassiveHookEffectsUnmount
和pendingPassiveHookEffectsMount
队列中,最终它的上次更新销毁函数和本次更新回调函数都是在layout
阶段后异步执行; 可以明确一点,他们的更新都不会阻塞dom渲染。
layout之后
还记得在mutation之前
阶段的这几行代码吗?
// 创建任务并加入任务队列,会在layout阶段之后触发
scheduleCallback(NormalPriority$1, function () {
flushPassiveEffects();
return null;
});
这里就是在调度useEffect()
,在layout
阶段之后会执行这个回调函数,此时会处理useEffect
的上次更新销毁函数和本次更新回调函数。
总结
看完这篇文章, 我们可以弄明白下面这几个问题:
- React的渲染流程是怎样的?
- React的beginWork都做了什么?
- React的completeWork都做了什么?
- React的commitWork都做了什么?
- useEffect和useLayoutEffect的区别是什么?
- useEffect和useLayoutEffect的销毁函数和更新回调的调用时机?