阅读 3703

React源码揭秘1 架构设计与首屏渲染

欢迎订阅 React技术揭秘  代码参照React 16.13.1 。

和其他React教程有何不同?

假设React是你日常开发的框架,在日复一日的开发中,你萌生了学习React源码的念头,在网上一顿搜索后,你发现这些教程可以分为2类:

  1. 《xx行代码带你实现迷你React》,《xx行代码实现React hook》这样短小精干的文章。如果你只是想花一点点时间了解下React的工作原理,我向你推荐 这篇文章,非常精彩。

  2. 《React Fiber原理》,《React expirationTime原理》这样摘录React源码讲解的文章。如果你想学习React源码,当你都不知道Fiber是什么,不知道expirationTime对于React的意义时,这样的文章会给人“你讲解的代码我看懂了,但这些代码的作用是什么”的感觉。

我要写的这个系列文章和对应仓库的存在就是为了解决这个问题。

简单来说,这个系列文章会讲解React为什么要这么做,以及大体怎么做,但不会有大段的代码告诉你怎么做。

当你看完文章知道我们要做什么后,再来看仓库中具体的代码实现。

同时为了防止堆砌很多功能后,代码量太大影响你理解某个功能的实现,我为仓库每个功能的实现打了一个git tag

配套的仓库如何使用?

如果React是一个毛线团的话,那么他的线头一定是
RectDOM.render(<App/>, document.getElementById('app'));复制代码
通过这个线头,我梳理出React首屏渲染会做的工作,将他们从React代码中抽离出来,加了很多注释,这就是v1版本的React

没有state、没有Hooks、没有函数组件和类组件,只能渲染首屏元素,但是所有目录架构、文件名、方法都和React一样,代码片段完全一样(因为就是一边debug一边抄的)。

如果你想读React源码,但又被React庞大的代码量劝退,我相信这个项目适合你起步。

这个系列的每篇文章,都是对应仓库的学习笔记。如果你想跟着我一起学习,可以找到对应版本的git tag ,clone到本地,安装依赖后
npm start复制代码
会打开当前版本的示例,配合文章 + debug 服用。同时通过create-react-app创建一个React应用,跑同样的示例代码作为对照。你会发现,我们项目的渲染流程和React是一致的。

这是这个系列第一篇文章,对应 git tag v1,正餐开始~

schedule + render + commit = React

我们知道,React是一个声明式的UI库,我们通过组件的形式声明UI,React会为我们输出DOM并渲染到页面上。


在React中,对UI的声明是通过一种称为JSX的语法糖来实现。JSX在编译时会被Babel转换为React.createElement方法。

在运行时我们获取到的其实是

React.createElement方法的调用结果,即
一个描述组件结构的对象。

// 输入JSX
const a = <div>Hello</div>

// 在编译时,被babel编译为React.createElement函数
const a = React.createElement('div', null, 'Hello');

// 在运行时,执行函数,返回描述组件结构的对象
const a = {
  ?typeof: Symbol(react.element),
  "type": "div",
  "key": null,
  "props": {
    "children": "Hello"
  }
}复制代码

我们可以看到,在运行时描述组件结构的对象离渲染到页面上的DOM还相去甚远,为了能渲染DOM到页面上,React内部肯定有2个模块:

  1. 负责解析JSX对象,决定哪些JSX对象是需要最终渲染成DOM节点的。
  2. 把需要渲染的DOM元素渲染到页面上。

在React中,我们把模块1做的工作叫render,把模块2做的工作叫commit

为什么叫这个名字呢,想想你写的ClassComponent的render方法,在render阶段要做的一件事就是执行render方法。

至于commit,可能你会想到 git commit 。事实上,React的工作流程和Git多分支开发非常相似。

所以,更新下我们的架构:


schedule阶段简析

到目前为止,我们简单介绍了render和commit,有了这2个阶段,我们已经可以实现除了异步模式(Concurrent)外React的大部分功能。

但是,设想以下场景:

有一个地址搜索框,在输入字符时会实时请求当前已输入内容的地址匹配结果。


这里包括2个状态变化:
  1. 用户在输入框内输入的字符变化
  2. 显示实时匹配结果的下拉框内容变化
当同时触发这两个状态变化时我们一般期望输入框输入内容不能有卡顿,实时结果显示的下拉框更新有延迟是可以接受的。也就是说,1的优先级如果能高于2那用户体验想必是更好的。

甚至极端的考虑,我们已经触发了2,在计算2需要改变的DOM节点的过程中用户又触发了1,这时候如果能搁置2转而优先处理1,这种体验是符合预期的。

所以我们需要一种机制来处理更新的优先级,决定哪个状态变化带来的更新应该被优先执行。

为了达到这个目的,我们知道需要为现有架构增加一个schedule阶段:

  1. schedule阶段,当触发状态改变后,schedule阶段判断触发的更新的优先级,通知render阶段接下来应该处理哪个更新。
  2. render阶段,收到schedule阶段的通知,处理更新对应的JSX,决定哪些JSX对象是需要最终被渲染的。
  3. commit阶段,将render阶段整理出的需要被渲染的内容渲染到页面上。


commit阶段简析

基于我们现在的设计,commit阶段负责把需要渲染的DOM元素渲染到页面上。

但是React的野心从来不仅限于web端,理论上当render阶段决定了哪些JSX需要被渲染后,我们对应不同的commit,就能实现在不同平台的渲染。


render的最小单元——Fiber

要实现我们的三个阶段,还有三个小问题:
  1. 由于render阶段产生的结果能对应多个平台的commit,那render阶段产生的结果就不能是平台相关的。如果render阶段产生的节点都是DOM节点,显然这些节点是没法在Native环境被commit的。所以我们需要一种平台无关的节点结构。
  2. 我们输入的JSX是一种描述组件结构的对象,但他没法描述哪个节点更新,哪个节点删除这样的节点行为,所以我们需要一种能够描述节点行为的结构。
  3. 在讲到schedule阶段时,我们希望低优先级的schedule是可以被终止以重新开始一个更高优先级的schedule的。那么schedule的节点粒度一定要够细,这样我们才能完全操控节点终止schedule的位置并清除节点schedule产生的结果再重新开始。
为了解决这三个问题,React提出了一种名叫Fiber的结构,如下图:

当我们尝试渲染 <App/> 时,在render阶段会生成右侧的Fiber结构。Fiber的完整结构看这里

  • Fiber中可以保存节点的类型,例子中App节点是一个函数组件节点,div节点是一个原生DOM节点,I am节点是一个文本节点。
  • 可以保存节点的信息(比如state,props)。
  • 可以保存节点对应的值(比如App节点对应App函数,div节点对应div DOMElement)。这样的结构也解释了为什么函数组件通过Hooks可以保存state。因为state并不是保存在函数上,而是保存在函数组件对应的Fiber节点上。
  • 可以保存节点的行为(更新/删除/插入),后面会介绍

在React中,我们的组件会形成一棵组件树,同样的,有了Fiber的结构后,我们需要将他们链接在一起组成Fiber树。我们为Fiber增加如下字段:

  • child:指向第一个子Fiber
  • sibling:指向右边的兄弟节点
同时由于Fiber是一层层向下遍历,当遍历到图中的div Fiber节点,我们已经知道他的父节点是App Fiber节点,这时候可以赋值 div Fiber.return = App Fiber; 即用return指向自己的父节点。


小朋友,此时你是否有很多???

为啥这个字段叫return,不叫parent,React核心团队的Andrew Clark解释说:可以理解为return指向当前Fiber处理完后返回的那个Fiber,当子Fiber被处理完后会返回他的父Fiber。好吧

所以我们的完整Fiber结构是这样的:

你可以在这篇文章看到React团队当初设计Fiber架构时的心路历程。

render和commit的整体流程

现在我们有了描述组件的节点类型(Fiber),可以愉快的开始首屏渲染了。

需要注意的是,由于执行ReactDOM.render产生的首屏渲染并不涉及到其他更高优先级的更新,所以对于首屏渲染,我们掠过schedule阶段。

比如刚才介绍schedule阶段举的地址输入框的例子,首屏渲染了输入框,更高优先级的更新是后续在输入框中输入文字产生的。

这里我们以项目V1版本的Demo为例:

当我们首次进入render阶段时,我们传入JSX:

整个render阶段需要做2件事:

  1. 向下遍历JSX,为每个JSX节点的子JSX节点生成对应的Fiber,并赋值
effectTag字段表示当前Fiber需要执行的副作用,最常见的副作用是:
  • Placement 插入DOM节点
  • Update 更新DOM节点
  • Deletion 删除DOM节点
当然,首屏渲染只会涉及到Placement。(所有effectTag见这里

PS:这里同学可能会奇怪,这一步为什么是“为每个节点的子节点生成对应的Fiber”而不是“为当前节点生成对应的Fiber”?还记得下面这行代码么:

执行这行初始化的代码首先会创建一个根Fiber节点,所以当从根Fiber向下创建Fiber时,我们始终是为子节点创建Fiber。这是要做的第一件事。

2. 为每个Fiber生成对应的DOM节点,保存在Fiber.stateNode

做完这2件事后我们进入commit阶段,此时我们知道

  1. 哪些Fiber需要执行哪些操作(由Fiber.effectTag得知)
  2. 执行这些操作的Fiber他们对应的DOM节点(由Fiber.stateNode得知)

有了这些信息,Commit阶段只需要遍历所有有Placement副作用的Fiber,依次执行DOM插入操作就完成了首屏的渲染。

这就是首屏渲染render+commit的整个过程。机智如你,是不是理解起来完全没压力呢。

深入render阶段

我们刚才讲了render阶段会做2件事(会调用的2个函数),现在我们给他们起个名字吧:

beginWork

向下遍历JSX,为每个JSX节点的子JSX节点生成对应的Fiber,并设置effectTag

我们叫他beginWork,这是每个节点render阶段开始工作的起点。

completeWork

为每个Fiber生成对应的DOM节点

我们叫他completeWork,这是每个节点render阶段完成工作的终点。

我们通过workInProgress这个全局变量表示当前render阶段正在处理的Fiber,当首屏渲染初始化时, workInProgress === 根Fiber。

调用workLoopSync方法,他内部会循环调用performUnitOfWork方法。

performUnitOfWork每次接收一个Fiber,调用beginWorkCompleteWork,处理完该Fiber后返回下一个需要处理的Fiber。


performUnitOfWork返回null时,就代表所有节点的render阶段结束了。

整个流程虽然看起来繁琐,但就做了2件事:

  1. 采用深度优先遍历,从上往下生成子Fiber,生成后继续向子Fiber遍历(代码
  2. 当遍历到底没有子Fiber时,开始从底往上遍历,为每个步骤1中已经创建的Fiber创建对应的DOM节点(代码

在这个过程中如果遇到兄弟节点,又重复步骤1,直到最终又回到根Fiber,完成整棵树的创建与遍历。

优化渲染阶段

到目前为止我们的已经很接近React了,只需再优化两点简直就是React本act了。

effectList

在我们的设计中,commit阶段会遍历找到所有含有effectTag的Fiber节点。如果Fiber树很庞大的话,这个遍历会很耗时。

但其实在render阶段我们已经知道哪些Fiber会被设置Fiber.effectTag, 所以我们可以在render阶段就提前标记好他们,将他们组织成链表的形式。

假设图中标红的Fiber代表本次调度该Fiber有effectTag,我们用链表的指针将他们链接起来形成一条单向链表,这条链表就是 effectList

用Redux作者Dan Abramov的话来说,effectList相对于Fiber树,就像圣诞树上的彩蛋



有了effectListcommit阶段只需要遍历这条链表就能知道所有有effectTag的Fiber了。这部分代码在completeUnitOfWork函数中

首屏渲染的特别之处

按照我们的架构,我们会给需要插入到DOM的Fiber赋值

fiber.effectTag = Placement;复制代码

这对于某次增量更新来说没有问题,但对于首屏渲染却太低效了,毕竟对首屏渲染来说,所有Fiber节点对应的DOM节点都是需要渲染到页面上的。

难道我们要给所有Fiber赋值effectTag = Placement;再在commit阶段一次次的执行DOM插入操作来生成一整棵DOM树?对于首屏渲染,我们需要稍微变通下。

当我们在render阶段执行completeWork创建Fiber对应的DOM节点时,我们遍历一下这个Fiber节点的所有子节点,将子节点的DOM节点插入到创建的DOM节点下。

(子Fiber的completeWork会先于父Fiber执行,所以当执行到父Fiber时,子Fiber一定存在对应的DOM节点)。代码见这里

这样当遍历到根Fiber节点时,我们已经有一棵构建好的离屏DOM树,这时候我们只需要赋值根节点的effectTag就能在commit阶段一次性将整课DOM树挂载。

// 仅赋值根fiber一个节点effectTag
RootFiber.effectTag = Placement; 复制代码


render阶段之前发生了什么

到这里我们已经接近实现React的首屏渲染了,还差最后一步,那就是从
到赋值

// 赋值根fiber
workInProgress = Rootfiber;复制代码

这中间发生了什么?

复习小课堂:workInProgress指当前render阶段正在处理的Fiber,ReactDOM.render会创建一个RootFiber,他会赋值给workInProgress

为了理解这个问题,我们需要知道,排除SSR相关,都有哪些方法能触发React组件的渲染?
  1. ReactDOM.render
  2. this.setState
  3. tihs.forceUpdate
  4. useReducer hook
  5. useState hook (PS:useState其实就是一种特别的useReducer)
既然有这么多方法触发渲染,那么我们需要一种统一的机制来表示组件需要更新。在React中,这种机制叫update,代码见这里。现在我们可以只关注update的如下参数
{
  // UpdateState | ReplaceState | ForceUpdate | CaptureUpdate
  tag: UpdateState,
  // 更新的state
  payload: null,
  // 指向当前Fiber的下一个update
  next: null
}复制代码
可以这么理解:

调用React ClassComponent的this.setState,会产生一个update,update.payload为需要更新的state,在该ClassComponent对应的Fiber执行beginWork时会处理state的更新带来的组件状态改变,当然,在V1版本我们还没有实现。

对于调用ReactDOM.render使根Fiber初始化时,会产生一个update,update.payload为对应需要渲染的JSX(代码见这里),在根Fiber的beginWork中会触发这篇文章讲到的render流程。

最后的最后

至此我们跑通了React的首屏渲染流程。如果你看到了这里,为自己鼓鼓掌吧。

篇幅有限,我们讲的很多都是宏观的东西,要了解细节还需要多多debug代码,把我们的Demo单步调试几遍。

这里再给你推荐一篇极好的React原理文章,配合本文食用效果极佳