欢迎订阅 React技术揭秘 代码参照React 16.13.1 。
和其他React教程有何不同?
假设React是你日常开发的框架,在日复一日的开发中,你萌生了学习React源码的念头,在网上一顿搜索后,你发现这些教程可以分为2类:
《xx行代码带你实现迷你React》,《xx行代码实现React hook》这样短小精干的文章。如果你只是想花一点点时间了解下React的工作原理,我向你推荐 这篇文章,非常精彩。
《React Fiber原理》,《React expirationTime原理》这样摘录React源码讲解的文章。如果你想学习React源码,当你都不知道
Fiber
是什么,不知道expirationTime
对于React的意义时,这样的文章会给人“你讲解的代码我看懂了,但这些代码的作用是什么”的感觉。
我要写的这个系列文章和对应仓库的存在就是为了解决这个问题。
简单来说,这个系列文章会讲解React为什么要这么做,以及大体怎么做,但不会有大段的代码告诉你怎么做。
当你看完文章知道我们要做什么后,再来看仓库中具体的代码实现。
同时为了防止堆砌很多功能后,代码量太大影响你理解某个功能的实现,我为仓库每个功能的实现打了一个git tag
。
配套的仓库如何使用?
RectDOM.render(<App/>, document.getElementById('app'));
没有state、没有Hooks、没有函数组件和类组件,只能渲染首屏元素,但是所有目录架构、文件名、方法都和React一样,代码片段完全一样(因为就是一边debug一边抄的)。
如果你想读React源码,但又被React庞大的代码量劝退,我相信这个项目适合你起步。
npm start
这是这个系列第一篇文章,对应 git tag v1,正餐开始~
schedule + render + commit = React
我们知道,React是一个声明式的UI库,我们通过组件的形式声明UI,React会为我们输出DOM并渲染到页面上。
在React中,对UI的声明是通过一种称为JSX的语法糖来实现。JSX在编译时会被Babel转换为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个模块:
- 负责解析JSX对象,决定哪些JSX对象是需要最终渲染成DOM节点的。
- 把需要渲染的DOM元素渲染到页面上。
在React中,我们把模块1做的工作叫render,把模块2做的工作叫commit。
为什么叫这个名字呢,想想你写的ClassComponent的render方法,在render阶段要做的一件事就是执行render方法。
至于commit,可能你会想到 git commit 。事实上,React的工作流程和Git多分支开发非常相似。
所以,更新下我们的架构:
schedule阶段简析
到目前为止,我们简单介绍了render和commit,有了这2个阶段,我们已经可以实现除了异步模式(Concurrent)外React的大部分功能。
但是,设想以下场景:
- 用户在输入框内输入的字符变化
- 显示实时匹配结果的下拉框内容变化
甚至极端的考虑,我们已经触发了2,在计算2需要改变的DOM节点的过程中用户又触发了1,这时候如果能搁置2转而优先处理1,这种体验是符合预期的。
所以我们需要一种机制来处理更新的优先级,决定哪个状态变化带来的更新应该被优先执行。
为了达到这个目的,我们知道需要为现有架构增加一个schedule阶段:
- schedule阶段,当触发状态改变后,schedule阶段判断触发的更新的优先级,通知render阶段接下来应该处理哪个更新。
- render阶段,收到schedule阶段的通知,处理更新对应的JSX,决定哪些JSX对象是需要最终被渲染的。
- commit阶段,将render阶段整理出的需要被渲染的内容渲染到页面上。
commit阶段简析
基于我们现在的设计,commit阶段负责把需要渲染的DOM元素渲染到页面上。
但是React的野心从来不仅限于web端,理论上当render阶段决定了哪些JSX需要被渲染后,我们对应不同的commit,就能实现在不同平台的渲染。
- ReactDOM 渲染到浏览器端
- ReactNative 渲染App原生组件
- ReactTest 渲染出纯Js对象用于测试
- ReactArt 渲染到Canvas, SVG 或 VML (IE8)
render的最小单元——Fiber
- 由于render阶段产生的结果能对应多个平台的commit,那render阶段产生的结果就不能是平台相关的。如果render阶段产生的节点都是DOM节点,显然这些节点是没法在Native环境被commit的。所以我们需要一种平台无关的节点结构。
- 我们输入的JSX是一种描述组件结构的对象,但他没法描述哪个节点更新,哪个节点删除这样的节点行为,所以我们需要一种能够描述节点行为的结构。
- 在讲到schedule阶段时,我们希望低优先级的schedule是可以被终止以重新开始一个更高优先级的schedule的。那么schedule的节点粒度一定要够细,这样我们才能完全操控节点终止schedule的位置并清除节点schedule产生的结果再重新开始。

当我们尝试渲染 <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:指向右边的兄弟节点

小朋友,此时你是否有很多???
为啥这个字段叫return,不叫parent,React核心团队的Andrew Clark解释说:可以理解为return指向当前Fiber处理完后返回的那个Fiber,当子Fiber被处理完后会返回他的父Fiber。好吧
所以我们的完整Fiber结构是这样的:

render和commit的整体流程
现在我们有了描述组件的节点类型(Fiber),可以愉快的开始首屏渲染了。
需要注意的是,由于执行ReactDOM.render产生的首屏渲染并不涉及到其他更高优先级的更新,所以对于首屏渲染,我们掠过schedule阶段。
比如刚才介绍schedule阶段举的地址输入框的例子,首屏渲染了输入框,更高优先级的更新是后续在输入框中输入文字产生的。

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

整个render阶段需要做2件事:
- 向下遍历JSX,为每个JSX节点的子JSX节点生成对应的Fiber,并赋值

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

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

做完这2件事后我们进入commit阶段,此时我们知道
- 哪些Fiber需要执行哪些操作(由Fiber.effectTag得知)
- 执行这些操作的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,调用beginWork或CompleteWork,处理完该Fiber后返回下一个需要处理的Fiber。

当performUnitOfWork返回null时,就代表所有节点的render阶段结束了。
整个流程虽然看起来繁琐,但就做了2件事:
在这个过程中如果遇到兄弟节点,又重复步骤1,直到最终又回到根Fiber,完成整棵树的创建与遍历。
优化渲染阶段
effectList
在我们的设计中,commit阶段会遍历找到所有含有effectTag的Fiber节点。如果Fiber树很庞大的话,这个遍历会很耗时。
但其实在render阶段我们已经知道哪些Fiber会被设置Fiber.effectTag, 所以我们可以在render阶段就提前标记好他们,将他们组织成链表的形式。

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

用Redux作者Dan Abramov的话来说,effectList相对于Fiber树,就像圣诞树上的彩蛋
有了effectList,commit阶段只需要遍历这条链表就能知道所有有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阶段之前发生了什么

// 赋值根fiber
workInProgress = Rootfiber;
这中间发生了什么?
复习小课堂:workInProgress指当前render阶段正在处理的Fiber,ReactDOM.render会创建一个RootFiber,他会赋值给workInProgress
- ReactDOM.render
- this.setState
- tihs.forceUpdate
- useReducer hook
- useState hook (PS:useState其实就是一种特别的useReducer)
{
// 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流程。
最后的最后
篇幅有限,我们讲的很多都是宏观的东西,要了解细节还需要多多debug代码,把我们的Demo单步调试几遍。
这里再给你推荐一篇极好的React原理文章,配合本文食用效果极佳