1874
1874
FE Engineer
桌游布道者
有粤语歌就不会有世界末日

第三章 React架构-render阶段

2022-2-9|最后更新: 2023-4-10|
type
Post
status
Published
date
Feb 9, 2022
slug
react-render
summary
category
学习笔记
tags
自顶向下学习React源码
React
创建时间
Apr 7, 2023 07:15 PM
更新时间
Apr 10, 2023 05:16 AM
password
icon
Task List
Fiber节点是如何被创建并构建 Fiber 树?
render阶段开始于performSyncWorkOnRootperformConcurrentWorkOnRoot方法的调用。这取决于本次更新是同步更新还是异步更新。
/ performSyncWorkOnRoot会调用该方法 function workLoopSync() { while (workInProgress !== null) { performUnitOfWork(workInProgress); } } // performConcurrentWorkOnRoot会调用该方法 function workLoopConcurrent() { while (workInProgress !== null && !shouldYield()) { performUnitOfWork(workInProgress); } }
可以看到,他们唯一的区别是是否调用shouldYield。如果当前浏览器帧没有剩余时间,shouldYield会中止循环,直到浏览器有空闲时间后再继续遍历。 workInProgress代表当前已创建的workInProgress fiberperformUnitOfWork方法会创建下一个Fiber节点并赋值给workInProgress,并将workInProgress与已创建的Fiber节点连接起来构成Fiber树Fiber Reconciler是从Stack Reconciler重构而来,通过遍历的方式实现可中断的递归,所以performUnitOfWork的工作可以分为两部分:“递”和“归”。

“递”阶段

首先从rootFiber开始向下深度优先遍历。为遍历到的每个Fiber节点调用beginWork方法。 该方法会根据传入的Fiber节点创建子Fiber节点,并将这两个Fiber节点连接起来。 当遍历到叶子节点(即没有子组件的组件)时就会进入“归”阶段。

“归”阶段

在“归”阶段会调用completeWork处理Fiber节点。 当某个Fiber节点执行完completeWork,如果其存在兄弟Fiber节点(即fiber.sibling !== null),会进入其兄弟Fiber的“递”阶段。 如果不存在兄弟Fiber,会进入父级Fiber的“归”阶段。 “递”和“归”阶段会交错执行直到“归”到 rootFiber。至此,render 阶段的工作就结束了。

举个 🌰

function App() { return ( <div> i am <span>1874</span> </div> ); } ReactDOM.render(<App />, document.getElementById("root"));
对应的Fiber树结构:
notion image
render阶段
会依次执行:
1. rootFiber beginWork2. App Fiber beginWork3. div Fiber beginWork4. "i am" Fiber beginWork5. "i am" Fiber completeWork6. span Fiber beginWork7. span Fiber completeWork8. div Fiber completeWork9. App Fiber completeWork10. rootFiber completeWork
注意 之所以没有 “1874” Fiber 的 beginWork/completeWork,是因为作为一种性能优化手段,针对只有单一文本子节点的Fiber,React会特殊处理。

beginWork

流程概述

可以从源码这里看到 beginWork 的定义。整个方法大概有 500 行代码。beginWork 的工作是传入当前 Fiber 节点,创建子 Fiber 节点。
// 从传参看方法执行 function beginWork( current: Fiber | null, workInProgress: Fiber, renderLanes: Lanes ): Fiber | null { // ...省略函数体 }
其中传参:
  • current:当前组件对应的Fiber节点在上一次更新时的Fiber节点,即workInProgress.alternate
  • workInProgress:当前组件对应的Fiber节点
  • renderLanes:优先级相关
rootFiber以外, 组件mount时,由于是首次渲染,是不存在当前组件对应的Fiber节点在上一次更新时的Fiber节点,即mountcurrent === null。 组件update时,由于之前已经mount过,所以current !== null。 所以我们可以通过current === null ?来区分组件是处于mount还是update。 基于此原因,beginWork的工作可以分为两部分:
  • update时:如果current存在,在满足一定条件时可以复用current节点,这样就能克隆current.child作为workInProgress.child,而不需要新建workInProgress.child
  • mount时:除fiberRootNode以外,current === null。会根据fiber.tag不同,创建不同类型的子Fiber节点
function 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,即propsfiber.type不变
  • !includesSomeLane(renderLanes, updateLanes),即当前Fiber节点优先级不够。

mount 时

当不满足优化路径时,就会新建子Fiber。根据fiber.tag不同,进入不同类型Fiber的创建逻辑。
可以从这里看到tag对应的组件类型
// 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 ); } }
从代码可以看出,和beginWork一样,他也是通过current === null ?区分mountupdate。 不论走哪个逻辑,最终他会生成新的子Fiber节点并赋值给workInProgress.child,作为本次beginWork返回值,并作为下次performUnitOfWork执行时workInProgress传参
注意 值得一提的是,mountChildFibers与reconcileChildFibers这两个方法的逻辑基本一致。唯一的区别是:reconcileChildFibers会为生成的Fiber节点带上effectTag属性,而mountChildFibers不会。

effectTag

我们知道,render阶段的工作是在内存中进行,当工作结束后会通知Renderer需要执行的DOM操作。要执行DOM操作的具体类型就保存在fiber.effectTag中。
你可以从这里看到effectTag对应的DOM操作
比如:
// 通过二进制表示effectTag,可以方便的使用位操作为fiber.effectTag赋值多个effect。 // DOM需要插入到页面中 export const Placement = /* */ 0b00000000000010; // DOM需要更新 export const Update = /* */ 0b00000000000100; // DOM需要插入到页面中并更新 export const PlacementAndUpdate = /* */ 0b00000000000110; // DOM需要删除 export const Deletion = /* */ 0b00000000001000;
那么,如果要通知RendererFiber节点对应的DOM节点插入页面中,需要满足两个条件:
  1. fiber.stateNode存在,即Fiber节点中保存了对应的DOM节点
  1. (fiber.effectTag & Placement) !== 0,即Fiber节点存在Placement effectTag
我们知道,mount时,fiber.stateNode === null,且在reconcileChildren中调用的mountChildFibers不会为Fiber节点赋值effectTag。那么首屏渲染如何完成呢? 针对第一个问题,fiber.stateNode会在completeWork中创建。第二个问题的答案十分巧妙:假设mountChildFibers也会赋值effectTag,那么可以预见mount时整棵 Fiber 树所有节点都会有Placement effectTag。那么commit阶段在执行DOM操作时每个节点都会执行一次插入操作,这样大量的DOM操作是极低效的。为了解决这个问题,在mount时只有rootFiber会赋值Placement effectTag,在commit阶段只会执行一次插入操作。
notion image

completeWork

类似beginWorkcompleteWork也是针对不同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(即原生DOM组件对应的Fiber节点)。

处理 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,比如:
  • onClickonChange等回调函数的注册
  • 处理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 = function( current: Fiber, workInProgress: Fiber, type: Type, newProps: Props, rootContainerInstance: Container, ) { // If we have an alternate, that means this is an update and we need to // schedule a side-effect to do the updates. const oldProps = current.memoizedProps; if (oldProps === newProps) { // In mutation mode, this is sufficient for a bailout because // we won't touch this node even if children changed. return; } // If we get updated because one of our children updated, we don't // have newProps so we'll have to reuse them. // TODO: Split the update API as separate for the props vs. children. // Even better would be if children weren't special cased at all tho. const instance: Instance = workInProgress.stateNode; const currentHostContext = getHostContext(); // TODO: Experiencing an error where oldProps is null. Suggests a host // component is hitting the resume path. Figure out why. Possibly // related to `hidden`. const updatePayload = prepareUpdate( instance, type, oldProps, newProps, rootContainerInstance, currentHostContext, ); // TODO: Type this specific to this type of component. workInProgress.updateQueue = (updatePayload: any); // If the update payload indicates that there is a change or if there // is a new ref we mark this as an update. All the work is done in commitWork. if (updatePayload) { markUpdate(workInProgress); } };
updateHostComponent内部,被处理完的props会被赋值给workInProgress.updateQueue,并最终会在commit阶段被渲染在页面上。
workInProgress.updateQueue = (updatePayload: any);
其中updatePayload为数组形式,他的偶数索引的值为变化的prop key,奇数索引的值为变化的prop value

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阶段需要找到所有有effectTagFiber节点并依次执行effectTag对应操作。难道需要在commit阶段再遍历一次Fiber树寻找effectTag !== nullFiber节点么? 这显然是很低效的。 为了解决这个问题,在completeWork的上层函数completeUnitOfWork中,每个执行完completeWork且存在effectTagFiber节点会被保存在一条被称为effectList的单向链表中。 effectList中第一个Fiber节点保存在fiber.firstEffect,最后一个元素保存在fiber.lastEffect。 类似appendAllChildren,在“归”阶段,所有有effectTagFiber节点都会被追加在effectList中,最终形成一条以rootFiber.firstEffect为起点的单向链表。
nextEffect nextEffect rootFiber.firstEffect -----------> fiber -----------> fiber
这样,在commit阶段只需要遍历effectList就能执行所有 effect 了。
你可以在这里看到这段代码逻辑。

流程结尾

notion image
至此,render阶段全部工作完成。在performSyncWorkOnRoot函数中fiberRootNode被传递给commitRoot方法,开启commit阶段工作流程。
commitRoot(root);
代码见这里。
yuqe-hexo-with-cdn插件支持多图床yuque-hexo插件语雀图片防盗链的解决方案