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

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

和其余React教程有何不一样?

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

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

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

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

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

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

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

配套的仓库如何使用?

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

没有state、没有Hooks、没有函数组件和类组件,只能渲染首屏元素,可是全部目录架构、文件名、方法都和React同样,代码片断彻底同样(由于就是一边debug一边抄的)。npm

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

这个系列的每篇文章,都是 对应仓库的学习笔记。若是你想跟着我一块儿学习,能够找到对应版本的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原理文章,配合本文食用效果极佳

相关文章
相关标签/搜索