编者按:本文作者苏畅,奇舞团前端开发工程师。
2020年初给自己定下目标,今年要读懂React源码,最好能成为React Contributor(没想到很快就实现了,虽然提交的commit很微小)。
为什么要读React源码呢,因为如果单纯开发日常业务的话,前端的边界其实很窄。回想一下,你今年做的业务,换作是去年的你,前年的你,换作是应届生甲乙丙,他们能替换你的位置么?我这么一想,就有迫切的愿望拓展自己的边界。
前端的边界很多——可视化、框架、工具链等,这些都能成为一个前端区别其他前端的地方,而我选择从日常工作最熟悉的伙伴——React下手。即使不考虑这些功利的因素,全世界最优秀的一批前端(Facebook)耗费多年开发的框架,去学习下他们的代码,不香么?
既然定下了宏大的目标(笑:blush:),如何下手呢?网上有些类似《从0实现迷你React》的文章,他们提炼了React的一些关键思路,用很少的代码实现了React的某项功能,阅读他们对了解React的思路很有帮助,尤其推荐这篇 1 。但这不是我想要的,我想要的是真正的React,辣个React 2 。
于是,开始debug React :dog:。如果React是一个毛线团 的话,那么他的线头一定是
RectDOM.render(<App/>, document.getElementById('app'));
通过这个线头,我梳理出React首屏渲染会做的工作,将他们从React代码中抽离出来,加了很多注释,这就是v1版本的React 3 。没有state、没有Hooks、没有函数组件和类组件,只能渲染首屏元素,但是所有目录架构、文件名、方法都和React一样,代码片段完全一样(因为就是一边debug一边抄的:joy:)。
如果你想读React源码,但又被React庞大的代码量劝退,我相信这个项目适合你起步。
这个系列的每篇文章,都是对应仓库 4 的一个版本的学习笔记。如果你想跟着我一起学习,可以找到对应版本的git tag ,clone到本地,安装依赖后
npm start
会打开当前版本的示例,配合文章 + debug 服用。同时通过create-react-app创建一个React应用,跑同样的示例代码作为对照。你会发现,我们项目的渲染流程和React是一致的。
这是这个系列第一篇文章,对应 git tag v1,正餐开始~
ps:项目代码参照React 16.13.1
让我们站在框架设计者的角度,首先我们已经决定使用JSX 5 来表现我们的UI组件(如果你还不清楚JSX,可以看这里 6 ),有2个首要问题需要解决:
输入JSX后,我们如何解析JSX,并决定哪些是需要最终渲染成DOM节点?
我们怎么把需要渲染的DOM元素渲染到页面上?
React给出的解答是:将整个流程分为调度和渲染2部分。
设想以下场景:有一个地址搜索框,在输入字符时会实时请求当前已输入内容的地址匹配结果。
这里包括2个状态变化:
我们希望用户输入的字符能实时显示在输入框内,不能有卡顿。
下拉框内容有个加载的过程一般是可以接受的。
所以当同时触发这两个状态变化时1的优先级如果能高于2那用户体验想必是更好的。甚至极端的考虑,我们已经触发了2,在计算2需要改变的DOM节点的过程中用户又触发了1,这时候如果能搁置2转而优先处理场景1,这种体验是符合预期的。
这就是我们叫他调度器的原因——决定要处理什么,以及调度他们的优先级。
当调度器已经处理好需要渲染的节点,为什么不直接渲染呢,而需要渲染器?
因为React的野心从来不仅限于web端,理论上当调度器整理出的节点应用于不同渲染器,就能实现在不同平台的渲染。
DOM渲染器 7 渲染到浏览器端
Native渲染器 8 渲染App原生组件
Test渲染器 9 渲染出纯Js对象用于测试
Art渲染器 10 渲染到Canvas, SVG 或 VML (IE8)
要实现React的宏伟愿景,还有2个小问题:
由于调度器能对应多个平台的渲染器,那调度器调度的节点就不能是平台相关的。如果调度器调度出的节点都是DOM节点,显然这些节点是没法在Native环境被渲染器渲染的。所以我们需要一种平台无关的节点结构。
刚才讲到调度器的功能时,我们希望低优先级的调度是可以被终止以重新开始一个更高优先级的调度的。那么被调度的节点粒度一定要够细,这样我们才能完全操控节点终止调度的位置并清除之前调度产生的结果再重新开始。
为了解决这2个问题,React提出了一种名叫Fiber 11 的结构,如下图:
当我们尝试渲染 <App/> 时,会生成右侧的Fiber结构。Fiber的完整结构看这里 12 。
我们可以在Fiber节点中保存节点的类型(比如App节点是一个函数组件节点,div节点是一个原生DOM节点,I am节点是一个文本节点),可以保存节点对应的state,props,可以保存节点对应的值(比如App节点对应左侧的函数,div节点对应div DOMElement)。
这样的结构也解释了为什么函数组件(React Hooks 13 )可以保存state。因为state并不是保存在函数上,而是保存在函数组件对应的Fiber节点上。
对于Fiber的结构其实我们可以更进一步。我们为Fiber增加如下字段:
child:指向第一个子Fiber
sibling:指向右边的兄弟节点
这样我们的父Fiber节点不需要用数组的形式保存多个子节点。所以我们可以这么改进下:
同时由于Fiber是一层层向下遍历,当遍历到图中的div Fiber节点,我们已经知道他的父节点是App Fiber节点,这时候可以赋值 div Fiber.return = App Fiber; 即用return指向自己的父节点。
小朋友,此时你是否有很多:question::question::question:,为啥这个字段叫return,不叫parent,React核心团队的Andrew Clark 14 解释说:可以理解为return指向当前Fiber处理完后返回的那个Fiber,当子Fiber被处理完后会返回他的父Fiber。好吧 ♂️
所以我们的完整Fiber结构是这样的:
你可以在这篇文章 15 看到React团队当初设计Fiber架构时的心路历程。
现在我们有了可供调度的节点类型(Fiber),可以愉快的开始第一次调度辣。:smile::tada::tada::tada:这里我们以项目V1版本的Demo 16 为例:
当我们首次进入调度流程时,我们传入JSX:
整个调度阶段需要做2件事:
向下遍历JSX,为每个节点的子节点生成对应的Fiber,并赋值
effectTag字段表示当前Fiber需要执行的副作用,最常见的副作用是:
Placement 插入DOM节点
Update 更新DOM节点
Deletion 删除DOM节点
当然,首屏渲染只会涉及到Placement。(所有effectTag见这里 17 )
PS:这里同学可能会奇怪,这一步为什么是“为每个节点的子节点生成对应的Fiber”而不是“为当前节点生成对应的Fiber”?还记得下面这行代码么:
执行这行初始化的代码首先会创建一个根Fiber节点,所以当从根Fiber向下创建Fiber时,我们始终是为子节点创建Fiber。这是要做的第一件事。
2. 为每个Fiber生成对应的DOM节点,保存在Fiber.stateNode。
做完这2件事后我们通知渲染器,此时渲染器知道
哪些Fiber需要执行哪些操作(由Fiber.effectTag得知)
执行这些操作的Fiber他们对应的DOM节点(由Fiber.stateNode得知)
有了这些数据,渲染器只需要遍历所有有Placement副作用的Fiber,依次执行DOM插入操作就完成了首屏的渲染。:dancer::dancer::dancer:这就是首屏调度+渲染的整个过程。机智如你,是不是理解起来完全没压力呢。
:man::school:术语小课堂:我们一直讲调度和渲染,在React中,他们分别叫做render阶段和commit阶段,所以以后我们在讲render阶段时就是在说调度阶段,讲commit阶段就是在说渲染阶段。
我们刚才讲了调度阶段会做2件事(会调用的2个函数),现在我们给他们起个名字吧:
向下遍历JSX,为每个节点的子节点生成对应的Fiber,并设置effectTag
我们叫他beginWork 18 ,这是每个节点调度阶段开始工作的起点。
为每个Fiber生成对应的DOM节点
我们叫他completeWork 19 ,这是每个节点调度阶段完成工作的终点。
我们通过workInProgress这个全局变量表示当前render阶段正在处理的Fiber,当首屏渲染初始化时, workInProgress === 根Fiber,接着我们调用workLoopSync方法,他内部会循环调用performUnitOfWork方法,这个方法接收当前workInProgress传入,处理他,返回下一个需要处理的Fiber。
当这个循环结束时,就代表所有节点的调度阶段结束了。
在performUnitOfWork函数内部,会执行刚才我们讲到的beginWork,创建并返回当前Fiber的子Fiber。当没有返回子Fiber,意味着遍历到最内层的节点,如图:
对于图中Demo来说,就是遍历到 "I am"文本节点或"KaKaSong"文本节点。此时会执行completeUnitOfWork方法,这个方法内部会调用我们刚才讲的completeWork,并尝试返回其兄弟Fiber节点。
整个流程虽然看起来繁琐,但就做了2件事:
采用深度优先遍历,从上往下生成子Fiber,向子Fiber遍历(代码 20 )
当遍历到底时,开始从下往上遍历,为每个1中已经创建的Fiber创建对应的DOM节点(代码 21 )
在这个过程中如果遇到还未处理的兄弟节点,又重复1,直到最终又回到根节点,完成整棵树的创建与遍历。
到目前为止我们的已经很接近React了,只需再优化2点简直就是React本act了。
在我们的设计中,渲染阶段会遍历找到所有含有effectTag的Fiber节点。如果Fiber树很庞大的话,这个遍历会很耗时。
但其实在调度阶段我们已经知道哪些Fiber会被设置Fiber.effectTag, 所以我们可以在调度阶段就提前标记好他们,将他们组织成链表的形式。
假设图中标红的Fiber代表本次调度该Fiber有effectTag,我们用链表的指针将他们链接起来形成一条单向链表,这条链表就是 effectList。
用Redux作者Dan Abramov 22 的话来说,effectList相对于Fiber树,就像圣诞树上的彩蛋
那么渲染阶段只需要遍历这条链表就能知道所有有effectTag的Fiber了。这部分代码在completeUnitOfWork函数中 23 。
按照我们的架构,我们会给需要插入到DOM的Fiber设置effectTag = Placement;这对于某次增量更新来说没有问题,但对于首屏渲染却太低效了,毕竟对首屏渲染来说,所有Fiber节点对应的DOM节点都是需要渲染到页面上的。
难道我们要给所有Fiber赋值effectTag = Placement;再在渲染阶段一次次的执行DOM插入操作来生成一整棵DOM树?对于首屏渲染,我们需要稍微变通下。
当我们在调度阶段执行completeWork创建Fiber对应的DOM节点时,我们遍历一下这个Fiber节点的所有子节点,将子节点的DOM节点插入到创建的DOM节点下。(子Fiber的completeWork会先于父Fiber执行,所以当执行到父Fiber时,子Fiber一定存在对应的DOM节点)。代码见这里 24
这样当遍历到根Fiber节点时,我们已经有一棵构建好的离屏DOM树,这时候我们只需要设置根节点一个节点effectTag = Placement; 就能在渲染阶段一次性将整棵DOM树挂载。
到这里我们已经接近实现React的首屏渲染了,还差最后一步,那就是从
到赋值workInProgress这中间发生了什么?
复习小课堂:woman::mortar_board::workInProgress指当前调度阶段正在处理的Fiber,ReactDOM.render会创建一个RootFiber,他会赋值给workInProgress
为了理解这个问题,我们需要知道,排除SSR相关,都有哪些方法能触发React组件的渲染?
ReactDOM.render
this.setState
tihs.forceUpdate
useReducer hook
useState hook (PS:useState其实就是一种特别的useReducer)
既然有这么多方法触发渲染,那么我们需要一种统一的机制来表示组件需要更新。在React中,这种机制叫update,代码见这里 25 。现在我们可以只关注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版本我们还没有实现。
对于根Fiber初始化时,会产生一个update,update.payload为对应需要渲染的JSX(代码见这里 26 ),在根Fiber的beginWork中会触发这篇文章讲到的render流程。
至此我们跑通了React的首屏渲染流程。如果你看到了这里,为自己鼓鼓掌吧。:clap::clap::clap:
篇幅有限,我们讲的很多都是宏观的东西,要了解细节还需要多多debug代码,把我们的Demo单步调试几遍。
这里再给你推荐一篇极好的React原理文章Inside Fiber: in-depth overview of the new reconciliation algorithm in React 27 ,配合本文食用效果极佳:blush:
https://pomb.us/build-your-own-react/
https://github.com/facebook/react
https://github.com/BetaSu/react-on-the-way
https://github.com/BetaSu/react-on-the-way
https://github.com/facebook/jsx
https://react.docschina.org/docs/introducing-jsx.html
https://www.npmjs.com/package/react-dom
https://www.npmjs.com/package/react-native
https://www.npmjs.com/package/react-test-renderer
https://www.npmjs.com/package/react-art
https://github.com/acdlite/react-fiber-architecture
https://github.com/BetaSu/react-on-the-way/blob/v1/packages/react-reconciler/ReactFiber.js#L15
https://zh-hans.reactjs.org/docs/hooks-intro.html
https://github.com/acdlite
https://github.com/acdlite/react-fiber-architecture
https://github.com/BetaSu/react-on-the-way/blob/v1/demo/index.js
https://github.com/BetaSu/react-on-the-way/blob/v1/packages/shared/ReactSideEffectTags.js
https://github.com/BetaSu/react-on-the-way/blob/v1/packages/react-reconciler/ReactFiberBeginWork.js
https://github.com/BetaSu/react-on-the-way/blob/v1/packages/react-reconciler/ReactFiberCompleteWork.js
https://github.com/BetaSu/react-on-the-way/blob/v1/packages/react-reconciler/ReactChildFiber.js#L77
https://github.com/BetaSu/react-on-the-way/blob/v1/packages/react-reconciler/ReactFiberCompleteWork.js#L60
https://github.com/gaearon
https://github.com/BetaSu/react-on-the-way/blob/v1/packages/react-reconciler/ReactFiberWorkLoop.js#L111
https://github.com/BetaSu/react-on-the-way/blob/v1/packages/react-reconciler/ReactFiberCompleteWork.js#L65
https://github.com/BetaSu/react-on-the-way/blob/v1/packages/react-reconciler/ReactUpdateQueue.js
https://github.com/BetaSu/react-on-the-way/blob/v1/packages/react-reconciler/ReactFiberBeginWork.js#L37
https://indepth.dev/inside-fiber-in-depth-overview-of-the-new-reconciliation-algorithm-in-react/
《奇舞周刊》是360公司专业前端团队「 奇舞团 」运营的前端技术社区。关注公众号后,直接发送链接到后台即可给我们投稿。