React18 源码解析之虚拟 DOM 转为 fiber 树
侧边栏壁纸
  • 累计撰写 1,061 篇文章
  • 累计收到 0 条评论

React18 源码解析之虚拟 DOM 转为 fiber 树

加速器之家
2024-10-20 / 0 评论 / 3 阅读 / 正在检测是否收录...

我们解析的源码是 React18.1.0 版本,请注意版本号。React 源码学习的 GitHub 仓库地址:https://github.com/wenzi0github/react

我们在文章 React18 源码解析之 fiber 等数据结构 中讲解了 jsx, element 和 fiber 的基本结构。这里我们主要讲下如何将 jsx 转为 fiber 节点组成的 fiber 树。

我们为便于理解整个转换的过程,会做一些流程上的精简:

  1. 大部分只考虑初始渲染阶段,因此诸如副作用的收集等暂时就不考虑了,不过偶尔也会涉及到一点两棵 fiber 树的对比;
  2. 忽略各种任务的优先级的调度;
  3. React 中各个节点的类型很多,如函数组件、类组件、html 节点、Suspense 类型的组件、使用 lazy()方法的动态组件等等,不过我们这里主要讲解下函数组件、类组件、html 节点这 3 个;

render()方法是我们整个应用的入口,我们就从这里开始。我们在之前的文章React18 源码解析之 render()入口方法中,只是讲解了 render()方法的挂载方式。这里我们会深入了解到从 jsx 转为 fiber 的整个过程。

1. 起始 #

在开始讲解前,我们先定义下要渲染的 React 组件,方便我们后续的理解:

const FuncComponent = () => {
  return (
    <p>
<span>this is function componentspan>

p> ); }; class ClassComponent extends React.Component { render() { return <p>this is class componentp>; } } function App() { return ( <div className="App">
<FuncComponent />
<ClassComponent />
<div>
<span>123span>

div>
div> ); } const root = ReactDOM.createRoot(document.getElementById('root')); root.render(<App />);

我们编写这个结构主要是为了看下,嵌套的、并列的标签,流程是如何流转的,中间经历了多少个复杂的过程,才将 element 转为 fiber 节点。

2. 初始的 element 如何处理? #

我是强烈建议使用debug-react项目来进行调试的,因为里面的分支和各种变量很多,若只是单纯地硬看,很容易懵,不知道这些变量具体是什么值!那么运行起一个 React 项目后,可以在某些关键节点、变量,打断点输出,就方便很多。

render()传入的 element 和后续的 element 的操作是不一样的。其他的 element 都可以通过执行函数组件或者类组件的实例来得到,而初始的 element 是直接提供的。

我们直接输出下

console.log(<App />);

App组件的element结构

可以看到,element 结构并不是把 React 所有的 jsx 都组织起来,形成巨大的嵌套结构,他只是当前某个节点里的 jsx 结构。若当前是函数组件、类组件等,内部的 jsx 可以通过执行属性 type 对应的函数或类的实例来得到,然后继续递归下去,最终把所有的 jsx 都全部解析出来。

入口函数 render() 传入的参数,就是上面的 element 结构。内部通过属性_internalRoot得到整个应用的根节点 root,然后又调用了 updateContainer():

// children就是我们传入的,即通过jsx编译后的element结构
ReactDOMRoot.prototype.render = function(children: ReactNodeList) {
  const root = this._internalRoot; // FiberRootNode
  updateContainer(children, root, null, null);
};

我们去掉 dev 代码和任务优先级的调度,看下 updateContainer() 主要的流程:

/**
* 将element结构转为fiber树
* @param {ReactNodeList} element 虚拟DOM树
* @param {OpaqueRoot} container FiberRootNode 节点
* @param {?React$Component} parentComponent 在React18传到这里的是null
* @param {?Function} callback render()里的callback,不过从React18开始就没了,传入的是null
* @returns {Lane}
*/
export function updateContainer(element: ReactNodeList,
container: OpaqueRoot,
parentComponent: ?React$Component,
callback: ?Function,
): Lane { /**
* current:
* const uninitializedFiber = createHostRootFiber(tag, isStrictMode, concurrentUpdatesByDefaultOverride,);
*/
// FiberRootNode.current 现在指向到当前的fiber树, // 若是初次执行时,current树只有hostFiber节点,没有其他的 const current = container.current; const eventTime = requestEventTime(); const lane = requestUpdateLane(current); // 结合 lane(优先级)信息,创建 update 对象,一个 update 对象意味着一个更新 /**
* const update: Update<*> = {
* eventTime,
* lane,
* tag: UpdateState,
* payload: null,
* callback: null,
* next: null,
* };
* @type {Update<*>}
*/
const update = createUpdate(eventTime, lane); update.payload = { element }; // 处理 callback,这个 callback 其实就是我们调用 ReactDOM.render 时传入的 callback // 不过从React18开始,render不再传入callback了,即这里的if就不会再执行了 callback = callback === undefined ? null : callback; if (callback !== null) { update.callback = callback; } /**
* 将update添加到current的更新链表中
* 执行后,得到的是 current.updateQueue.shared.pending = sharedQueue
* sharedQueue是React中经典的循环链表,
* 将下面的update节点插入这个shareQueue的循环链表中,pending指针指向到最后插入的那个节点上
*/
enqueueUpdate(current, update, lane); /**
* 这里调用的链路很深,做了很多事情,如:
* 流程图: https://docs.qq.com/flowchart/DS0pVdnB0bmlVRkly?u=7314a95fb28d4269b44c0026faa673b7
* scheduleUpdateOnFiber() -> ensureRootIsScheduled(root) -> performSyncWorkOnRoot(root)
* -> renderRootSync(root) -> workLoopSync()
*/
/**
* 这里传入的current是HostRootFiber的fiber节点了,虽然他的下面没有其他fiber子节点,
* 但它的updateQueue上有element结构,可以用来构建fiber节点
* 即 current.updateQueue.shared.pending = sharedQueue,element结构在sharedQueue其中的一个update节点,
* 其实这里只有一个update节点
*/
const root = scheduleUpdateOnFiber(current, lane, eventTime); if (root !== null) { entangleTransitions(root, current, lane); } return lane; }

我们再梳理下函数 updateContainer()的流程:

  1. updateContainer(element, container)传入了两个参数,element 就是 jsx 编译后的 element 结构,而 container 表示的是 FiberRootNode,整个应用的根节点,并不是 DOM 元素;
  2. container.current 指向的就是目前唯一的一棵 fiber 树的根节点,并 current 变量存储该节点;
  3. 将 element 结构放到 current 节点的属性中,方便后续的构建:current.updateQueue.shared.pending = [{payload:{element}}];pending 是一个环形链表,element 就放在这个环形链表的节点中,在初始更新阶段,只有这一个 update 节点;
  4. 调用 scheduleUpdateOnFiber(current);该方法内部将 element 取出,构建出下一个 fiber 节点;

3. scheduleUpdateOnFiber #

接下来我们看下 scheduleUpdateOnFiber 函数。

markUpdateLaneFromFiberToRoot(fiber, lane)方法会将当前 fiber 节点往上知道 FiberRootNode 所有节点赋值 lane 这个优先级,同时返回整个应用的根节点 FiberRootNode。

我们忽略中间任务调度的步骤,直接进入到 ensureRootIsScheduled(root) 函数中,这里的 root 参数就是整个应用的根节点 FiberRootNode。这个函数里也是一堆的任务调度,我们快进到 performConcurrentWorkOnRoot.bind(null, root),这里面又快进到了 renderRootSync(root)。

4. renderRootSync #

这里有一个很重要的准备操作,这里的 root 是整个应用的根节点,即 FiberRootNode,会传入到函数 prepareFreshStack() 中,主要是为了接下来的递归,初始化一些数据和属性:

/**
* 整个应用目前只有 FiberRootNode和current两个节点,current树只有一个根节点,就是current自己;
* 另一棵树还没有创建,结构是这样: https://mat1.gtimg.com/qqcdn/tupload/1659715740891.png
* prepareFreshStack() 函数的作用,就是通过current树的根节点创建出另一棵树的根节点,
* 并将这两棵树通过 alternate 属性,实现互相的指引
* workInProgressRoot: 是将要构建的树的根节点,初始时为null,经过下面 prepareFreshStack() 后,
* root.current给到workInProgressRoot,
* 即使第二次调用了,这里的if逻辑也是不会走的
* workInProgress初始指向到workInProgressRoot,随着构建的深入,workInProgress一步步往下走
*/
if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) { /**
* 将整个应用的根节点和将要更新的fiber树的根节点赋值到全局变量中
* root是当前整个应用的根节点
*/
prepareFreshStack(root, lanes); }

我们看下函数 prepareFreshStack() 的实现:

/**
* 准备新堆栈,返回「更新树」的根节点
* @param root
* @param lanes
* @returns {Fiber}
*/
function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { root.finishedWork = null; root.finishedLanes = NoLanes; workInProgressRoot = root; // 整个React应用的根节点,即 FiberRootNode /**
* prepareFreshStack()个人认为是只在初始化时执行一次,root是整个应用的根节点,而root.current就是默认展示的那棵树,
* 在初始化时,current 树其实也没内容,只有这棵树的一个根节点;
* 然后利用current的根节点通过 createWorkInProgress()方法 创建另一棵树的根节点rootWorkInProgress
* createWorkInProgress()方法内则判断了 current.alternate 是否为空,来决定是否可以复用这个节点,
* 在render()第一次调用时,root.current.alternate 肯定为空,这里面则会调用createFiber进行创建
*/
const rootWorkInProgress = createWorkInProgress(root.current, null); // 初始执行时,workInProgress指向到更新树的根节点, // 在mount阶段,workInProgress是新创建出来的,与current树的根节点workInProgressRoot,肯定是不相等的 workInProgress = rootWorkInProgress; workInProgressRootRenderLanes = subtreeRenderLanes = workInProgressRootIncludedLanes = lanes; workInProgressRootExitStatus = RootInProgress; workInProgressRootFatalError = null; workInProgressRootSkippedLanes = NoLanes; workInProgressRootInterleavedUpdatedLanes = NoLanes; workInProgressRootRenderPhaseUpdatedLanes = NoLanes; workInProgressRootPingedLanes = NoLanes; workInProgressRootConcurrentErrors = null; workInProgressRootRecoverableErrors = null; return rootWorkInProgress; }

总结下 prepareFreshStack() 的作用:

  1. 将整棵树的根节点 root 给到 workInProgressRoot;
  2. createWorkInProgress() 利用 current 节点创建出「更新树」的根节点;整个函数在这里我们就不展开讲了,大致内容就是判断 current.alternate (即 current 互相对应的那个节点)是否为空,若为空则创建出一个新节点;若不为空,则直接复用之前的节点,然后将新特性给到这个节点(不过我们这里传入的是 null);
  3. workInProgress 指针初始指向到「更新树」的根节点,在接下来的递归操作中,该指针一直在变动;

准备好之后,我们再回到函数 renderRootSync(),就可以顺着 workInProgress 指针往下进行了,这里我们就进入到了函数 workLoopSync() 中。

5. workLoopSync #

该函数很简单,就是一个 while 循环,每次循环时,都会执行函数 performUnitOfWork() ,然后操作 workInProgress 指向的那个 fiber 节点,直到 workInProgress 为 null。

我们刚才在上面的 prepareFreshStack() 中,workInProgress 指针指向到了「更新树」的根节点 rootWorkInProgress(即跟 current 树根节点长得一样的那个节点),这个 fiber 节点里的 updateQueue.shared.pending 中的一个 update 里,存放着 element 结构。

function workLoopSync() {
  // Already timed out, so perform work without checking if we need to yield.
  // 已经超时了,所以即使需要让出时,也不再做检查,直到把workInProgress执行完
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

6. performUnitOfWork #

处理 workInProgress 指向的那个 fiber 节点。

function performUnitOfWork(unitOfWork: Fiber): void {
  /**
* 初始mount节点时,unitOfWork 是上面workLoopConcurrent()中传入的 workInProgress,
* unitOfWork.alternate 指向的是 current
*/
const current = unitOfWork.alternate; let next; /**
* current为当前树的那个fiber节点
* unitOfWork为 更新树 的那个fiber节点
* 在初始mount节点,current和unitOfWork都是fiberRoot节点
* 在第一次调用beginWork()时,element结构通过其一系列的流程,创建出了第一个fiber节点,即对应的fiber节点(我们假设 是最外层的元素)
* next就是第一个fiber节点,然后next给到workInProgress,接着下一个循环
*/
next = beginWork(current, unitOfWork, subtreeRenderLanes); unitOfWork.memoizedProps = unitOfWork.pendingProps; if (next === null) { // unitOfWork已经是最内层的节点了,没有子节点了 // If this doesn't spawn new work, complete the current work. completeUnitOfWork(unitOfWork); } else { workInProgress = next; } }

将当前 current 节点和更新树的节点,都传给 beginWork(current, unitOfWork) 函数。简单来说,beginWork 会根据当前 fiber 节点中的 element 结构,创建出新的 fiber 节点,workInProgress 再指向到这个新 fiber 节点继续操作,直到所有的数据都操作完。具体的操作流程,我们单独开一篇文章进行讲解。

这里我们只需要知道,诸如函数组件、类组件也是 fiber 节点,也是整棵 fiber 树的一部分。其内部的 jsx(element)再继续转为 fiber 节点。

若 beginWork()返回的 next 是 null,说明当前节点 workInProgress 已经是最内层的节点了,就会进入到函数 completeUnitOfWork() 中。可以看到执行的流程是深度优先,即若当前 fiber 还能构造出子节点,即一直向下构造。直到没有子节点后,才会流转到兄弟节点和父级节点。

以我们上面的写的 React 组件为例,workInProgress 指向是的树的根节点,这个根节点没有具体的 jsx 结构。

const FuncComponent = () => {
  return (
    <p>
<span>this is function componentspan>

p> ); }; class ClassComponent extends React.Component { render() { return <p>this is class componentp>; } } function App() { return ( <div className="App">
<FuncComponent />
<ClassComponent />
<div>
<span>123span>

div>
div> ); } const root = ReactDOM.createRoot(document.getElementById('root')); root.render(<App />);
  1. 第一次执行后函数 beginWork() 后,得到 workInProgress 子元素里 element 结构转换出来的 fiber 节点,就是 对应的 fiber 节点,然后给到变量 next;workInProgress 再移动这个 fiber 节点上;
  2. 继续执行函数 beginWork(),得到子元素的 fiber 节点,即 div 标签对应的 fiber 节点;
  3. 继续执行函数 beginWork(),得到 div 标签子元素的 fiber 节点,若子元素是一个数组,beginWork() 会把所有的子元素都转为 fiber 节点,并形成单向链表,然后返回这个链表的头指针。这里,div 标签里有 3 个并列的元素,即, 和 div 标签,beginWork()会节点的不同类型,创建出不同的 fiber 节点,然后形成链表,再回到这个链表的第 1 个节点;即 workInProgress 指向了生成的 fiber 节点;
  4. 继续执行函数 beginWork(),就会到里的 jsx 对应的 fiber 节点,即 p 标签(beginWork()只会一层一层的构建),workInProgress 再指向到 p 标签对应的 fiber 节点,继续构建 span 和文本对应的 fiber 节点;
  5. 文本再往内,就没有节点了,即 next 得到的是 null,这时就会进入到 completeUnitOfWork() 函数,通过该函数的调度,workInProgress 又回到了的兄弟节点
  6. 继续执行函数 beginWork(),得到 里的 jsx 对应的 fiber 节点,即 p 标签;

我们接下来再看下函数 completeUnitOfWork() 是如何流转 workInProgress 指针的。

7. completeUnitOfWork #

当前节点和当前所有的子节点都执行完了,就会调用该方法。现在我们只关心整个流程的流转问题。

/**
* 当前 unitOfWork 已没有子节点了
* 1. 若还有兄弟节点,将 workInProgress 指向到其兄弟节点,继续beginWork()的执行;
* 2. 若所有的兄弟节点都处理完了(或者没有兄弟节点),就指向到其父级fiber节点;回到1;
* 3. 直到整个应用根节点的父级(根应用没有父级节点,所以为null),才结束;
*/
function completeUnitOfWork(unitOfWork: Fiber): void { let completedWork = unitOfWork; do { const current = completedWork.alternate; const returnFiber = completedWork.return; // 每个节点都有一个return属性,指向到其父级节点 // 若有兄弟节点,则继续执行兄弟节点 const siblingFiber = completedWork.sibling; // 该节点下一个兄弟节点 if (siblingFiber !== null) { // If there is more work to do in this returnFiber, do that next. workInProgress = siblingFiber; return; } // 当前节点和兄弟节点全部遍历完毕,则返回到其父节点 // Otherwise, return to the parent completedWork = returnFiber; // Update the next thing we're working on in case something throws. workInProgress = completedWork; } while (completedWork !== null); // while的作用就是若该节点没有兄弟节点,能够一直往上找父级节点, }

通过这张图,我们可以更加直观地理解整个流转流程

fiber构建的流程

图中的数字,就是 workInProgress 指针行进的顺序。

  1. 当有子节点时,就一直往下构建其子节点;若子节点有多个,则一并都构建出来;
  2. 若没有子节点,则优先查询是否有兄弟节点,若有,则流转到兄弟节点(如图中的 5 和 8),回到 1;
  3. 若没有兄弟节点,则回到其父级节点(红色箭头),然后查询父级节点是否有兄弟节点,若有则回到 2,若没有,则继续回到父级节点;

所有的节点都遍历执行完了,workLoopSync()中的 while()循环也就停止了,workInProgress 也指向到了 null,然后就可以进入到 DOM 渲染的 commit 阶段了。

8. 总结 #

到这里,我们把 element 转为 fiber 节点的大致流程过了一遍。主要了解到以下几个知识点:

  1. element 结构从一开始并不是一个巨大的嵌套结构,而是执行组件后,才能得到这个组件里的 element 结构;
  2. 转成 fiber 节点的过程,深度优先的原则,优先执行其第 1 个节点,然后再执行兄弟节点,再回到父级节点;
  3. 每次将 element 结构转为 fiber 节点时,只转当前 fiber 节点里的 element 最直接的子节点,若还有更深的子节点,则等着一会儿 workInProgress 流转到这里的时候,再执行;
  4. 若当前层级的 element 结构是一个数组,即有多个元素时,则会一并全部进行转换;

接下来的文章,我们会再详细介绍下函数 beginWork(),了解不同的 element 结构如何转成 fiber 节点的。

0

评论

博主关闭了当前页面的评论