前言
本次React源码参考版本为React 17版本。这是React源码系列第二篇render阶段,感觉写的篇幅会比较长,可能初次写不会那么完善,在后面应该会逐渐完善,建议初看源码的同学从第一篇开始看起。关于React 17版本其实还是同步更新,17版本为过渡版本,Concurrent模式即可中断更新会在React 18版本实现。
在阅读源码的过程中给我最大的感受就是不能钻牛角尖,这样很容易被套的很深,我们初次阅读需要紧扣重点,例如在render阶段主要就是调用beginWork和completeWork,源码阅读是一个持续学习的过程,当我们有些地方没搞懂可以先跳过去,等下次你回过头来在重复看可能就明白了。话不多说开始我们的学习。
准备工作
为了方便讲解,假设我们有下面这样一段代码:
function App(){
const [count, setCount] = useState(0)
useEffect(() => {
setCount(1)
}, [])
const handleClick = () => setCount(count => count++)
return (
<div>
<span onClick={handleClick}>{count}</span>
</div>
)
}
ReactDom.render(<App />, document.querySelector('#root'))
在React项目中,这种jsx语法首先会被编译成:
React.createElement("App", null)
jsx语法会通过babel转换后,会通过creatElement或jsx的api转换为React element作为ReactDom.render()的第一个参数进行渲染。
在上一篇文章Fiber中,我们提到过一个React项目会有一个fiberRoot和一个或多个rootFiber。fiberRoot是一个项目的根节点。而rootFiber是比如每个函数组件都有一个rootFiber,我们在开始真正的渲染前会先基于rootDOM创建fiberRoot,且fiberRoot.current = rootFiber,这里的rootFiber就是currentfiber树的根节点。
if (!root) {
// Initial mount
root = container._reactRootContainer = legacyCreateRootFromDOMContainer(container, forceHydrate);
fiberRoot = root._internalRoot;
}
在创建好fiberRoot和rootFiber后,我们还不知道接下来要做什么,因为它们和我们的<App />函数组件没有一点关联。这时React开始创建update,并将ReactDom.render()的第一个参数,也就是基于<App />创建的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;
上一篇文章也讲过,React最多会同时拥有两个fiber树,一个是currentfiber树,即对应当前页面映射的Fiber树,另一个是workInProgressfiber树,即要更新对应的Fiber树。currentfiber树的根节点在上面已经创建,下面会通过拷贝fiberRoot.current的形式创建workInProgressfiber树的根节点。
到这里,前面的准备工作就做完了, 接下来进入正菜,开始进行循环遍历,生成fiber树和dom树,并最终渲染到页面中。
render阶段
这个阶段并不是指把代码渲染到页面上,而是基于我们的代码画出对应的fiber树和dom树。
// performSyncWorkOnRoot会调用该方法 即同步更新的方式,legency模式
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
// performConcurrentWorkOnRoot会调用该方法, 异步更新即调用这个方法,Concurrent模式
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
可以看到,他们唯一的区别是是否调用shouldYield。如果当前浏览器帧没有剩余时间,shouldYield会中止循环,直到浏览器有空闲时间后再继续遍历。
workInProgress代表当前已创建的workInProgress fiber。
performUnitOfWork方法会创建下一个Fiber节点并赋值给workInProgress,并将workInProgress与已创建的Fiber节点连接起来构成Fiber树。
你可以从这里看到
workLoopConcurrent的源码
我们知道Fiber Reconciler是从Stack Reconciler重构而来,通过遍历的方式实现可中断的递归,所以performUnitOfWork的工作可以分为两部分:“递”和“归”。
“递”阶段
首先从rootFiber开始向下深度优先遍历。为遍历到的每个Fiber节点调用begainWork
该方法会根据传入的Fiber节点创建子Fiber节点,并将这两个Fiber节点连接起来。
当遍历到叶子节点(即没有子组件的组件)时就会进入“归”阶段。
“归”阶段
在“归”阶段会调用completeWork。
当某个Fiber节点执行完completeWork,如果其存在兄弟Fiber节点(即fiber.sibling !== null),会进入其兄弟Fiber的“递”阶段。
如果不存在兄弟Fiber,会进入父级Fiber的“归”阶段。
“递”和“归”阶段会交错执行直到“归”到rootFiber。至此,render阶段的工作就结束了。
beginWork
简单描述下beginWork的工作,就是生成fiber树。
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
// ...省略函数体
}
- current:当前组件对应的
Fiber节点在上一次更新时的Fiber节点,即workInProgress.alternate - workInProgress:当前组件对应的
Fiber节点 - renderLanes:优先级相关,在讲解
Scheduler时再讲解beginWork我们可以从两方面来讲解,mount和update,在这两个阶段都会调用beginwork
mount时
current === null, 会根据fiber.tag不同创建不同的fiber节点
unction beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes
): Fiber | null {
// update时:如果current存在可能存在优化路径,可以复用current(即上一次更新的Fiber节点)
if (current !== null) {
// ...省略
// 复用current
return bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderLanes,
);
} else {
didReceiveUpdate = false;
}
// mount时:根据tag不同,创建不同的子Fiber节点
switch (workInProgress.tag) {
case IndeterminateComponent:
// ...省略
case LazyComponent:
// ...省略
case FunctionComponent:
// ...省略
case ClassComponent:
// ...省略
case HostRoot:
// ...省略
case HostComponent:
// ...省略
case HostText:
// ...省略
// ...省略其他类型
}
}
update时
我们可以看到,满足如下情况时didReceiveUpdate === false(即可以直接复用前一次更新的子Fiber,不需要新建子Fiber)
oldProps === newProps && workInProgress.type === current.type,即props与fiber.type不变!includesSomeLane(renderLanes, updateLanes),即当前Fiber节点优先级不够,会在讲解Scheduler时介绍
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if (
oldProps !== newProps ||
hasLegacyContextChanged() ||
(__DEV__ ? workInProgress.type !== current.type : false)
) {
didReceiveUpdate = true;
} else if (!includesSomeLane(renderLanes, updateLanes)) {
didReceiveUpdate = false;
switch (workInProgress.tag) {
// 省略处理
}
return bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderLanes,
);
} else {
didReceiveUpdate = false;
}
} else {
didReceiveUpdate = false;
}
Fiber.tag不同,会进入不同Fiber类型创建
// mount时:根据tag不同,创建不同的Fiber节点
switch (workInProgress.tag) {
case IndeterminateComponent:
// ...省略
case LazyComponent:
// ...省略
case FunctionComponent:
// ...省略
case ClassComponent:
// ...省略
case HostRoot:
// ...省略
case HostComponent:
// ...省略
case HostText:
// ...省略
// ...省略其他类型
}
对于我们常见的组件类型,如(FunctionComponent/ClassComponent/HostComponent),最终会进入reconcileChildren方法
reconcileChildren
从该函数名就能看出这是Reconciler模块的核心部分。那么他究竟做了什么呢?
- 对于
mount的组件,他会创建新的子Fiber节点 - 对于
update的组件,他会将当前组件与该组件在上次更新时对应的Fiber节点比较(也就是俗称的Diff算法),将比较的结果生成新Fiber节点
export function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes
) {
if (current === null) {
// 对于mount的组件
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes,
);
} else {
// 对于update的组件
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes,
);
}
reconcileChildren最终会生成新的子Fiber节点并赋值给workInProgress.child,作为本次beginWork返回值 ,并作为下次performUnitOfWork执行时workInProgress的传参。
effectTag
我们知道,render阶段的工作是在内存中进行,当工作结束后会通知Renderer需要执行的DOM操作。要执行DOM操作的具体类型就保存在fiber.effectTag中。
比如
// DOM需要插入到页面中
export const Placement = /* */ 0b00000000000010;
// DOM需要更新
export const Update = /* */ 0b00000000000100;
// DOM需要插入到页面中并更新
export const PlacementAndUpdate = /* */ 0b00000000000110;
// DOM需要删除
export const Deletion = /* */ 0b00000000001000;
completeWork
我们再次简述一下completeWork
基于fiber节点生成对应的dom节点,并且将这个dom节点作为父节点,将之前生成的dom节点插入到当前创建的dom节点。并会基于在beginWork生成的不完全的workInProgressfiber树向上查找,直到fiberRoot。在这个向上的过程中,会去判断是否有sibling,如果有会再次走beginWork,没有就继续向上。这样到了根节点,一个完整的dom树就生成了。
类似beginWork,completeWork也是针对不同fiber.tag调用不同的处理逻辑。
function completeWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
const newProps = workInProgress.pendingProps;
switch (workInProgress.tag) {
case IndeterminateComponent:
case LazyComponent:
case SimpleMemoComponent:
case FunctionComponent:
case ForwardRef:
case Fragment:
case Mode:
case Profiler:
case ContextConsumer:
case MemoComponent:
return null;
case ClassComponent: {
// ...省略
return null;
}
case HostRoot: {
// ...省略
updateHostContainer(workInProgress);
return null;
}
case HostComponent: {
// ...省略
return null;
}
// ...省略
处理HostComponent
和beginWork一样,我们根据current === null ?判断是mount还是update。
同时针对HostComponent,判断update时我们还需要考虑workInProgress.stateNode != null ?(即该Fiber节点是否存在对应的DOM节点)
case HostComponent: {
popHostContext(workInProgress);
const rootContainerInstance = getRootHostContainer();
const type = workInProgress.type;
if (current !== null && workInProgress.stateNode != null) {
// update的情况
// ...省略
} else {
// mount的情况
// ...省略
}
return null;
}
update时
当update时,Fiber节点已经存在对应DOM节点,所以不需要生成DOM节点。需要做的主要是处理props,比如:
onClick、onChange等回调函数的注册- 处理
style prop - 处理
DANGEROUSLY_SET_INNER_HTML prop - 处理
children prop
我们去掉一些当前不需要关注的功能(比如ref)。可以看到最主要的逻辑是调用updateHostComponent方法。
if (current !== null && workInProgress.stateNode != null) {
// update的情况
updateHostComponent(
current,
workInProgress,
type,
newProps,
rootContainerInstance,
);
}
在updateHostComponent内部,被处理完的props会被赋值给workInProgress.updateQueue,并最终会在commit阶段被渲染在页面上.
mount时
同样,我们省略了不相关的逻辑。可以看到,mount时的主要逻辑包括三个:
- 为
Fiber节点生成对应的DOM节点 - 将子孙
DOM节点插入刚生成的DOM节点中 - 与
update逻辑中的updateHostComponent类似的处理props的过程
// mount的情况
// ...省略服务端渲染相关逻辑
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;
// 与update逻辑中的updateHostComponent类似的处理props的过程
if (
finalizeInitialChildren(
instance,
type,
newProps,
rootContainerInstance,
currentHostContext,
)
) {
markUpdate(workInProgress);
}
mount时只会在rootFiber存在Placement effectTag。那么commit阶段是如何通过一次插入DOM操作(对应一个Placement effectTag)将整棵DOM树插入页面的呢?
原因就在于completeWork中的appendAllChildren方法。
由于completeWork属于“归”阶段调用的函数,每次调用appendAllChildren时都会将已生成的子孙DOM节点插入当前生成的DOM节点下。那么当“归”到rootFiber时,我们已经有一个构建好的离屏DOM树。
effectList
至此render阶段的绝大部分工作就完成了。
还有一个问题:作为DOM操作的依据,commit阶段需要找到所有有effectTag的Fiber节点并依次执行effectTag对应操作。难道需要在commit阶段再遍历一次Fiber树寻找effectTag !== null的Fiber节点么?
这显然是很低效的。
为了解决这个问题,在completeWork的上层函数completeUnitOfWork中,每个执行完completeWork且存在effectTag的Fiber节点会被保存在一条被称为effectList的单向链表中。
effectList中第一个Fiber节点保存在fiber.firstEffect,最后一个元素保存在fiber.lastEffect。
类似appendAllChildren,在“归”阶段,所有有effectTag的Fiber节点都会被追加在effectList中,最终形成一条以rootFiber.firstEffect为起点的单向链表。
这样,在commit阶段只需要遍历effectList就能执行所有effect了。
总结
render阶段的流程大概就是如上所示,在理解render阶段的过程中,我们一定要紧扣beginWork和completeWork,beginWork就是生成Fiber树,completeWork就是生成dom树,然后在这两个函数中我们分别需要重点关注mount和update,我们一定要抓住render的主线去学习,在completeWork的update情况下调用时,有一个diff算法还没有去仔细讲解,应该会在后面系列的文章中着重讲解,并且会和vue的diff算法去比较。然后render阶段的过程就大概是这样了,下一章会继续学习commit阶段。