为什么要写这个系列?
2020年初给自己定下目标,今年要读懂React源码,最好能成为React Contributor(没想到很快就实现了,虽然提交的commit很微小)。
为什么要读React源码呢,因为如果单纯开发日常业务的话,前端的边界其实很窄。回想一下,你今年做的业务,换作是去年的你,前年的你,换作是应届生甲乙丙,他们能替换你的位置么?我这么一想,就有迫切的愿望拓展自己的边界。
前端的边界很多——可视化、框架、工具链等,这些都能成为一个前端区别其他前端的地方,而我选择从日常工作最熟悉的伙伴——React下手。即使不考虑这些功利的因素,全世界最优秀的一批前端(Facebook)耗费多年开发的框架,去学习下他们的代码,不香么?
既然定下了宏大的目标(笑😊),如何下手呢?网上有些类似《从0实现迷你React》的文章,他们提炼了React的一些关键思路,用很少的代码实现了React的某项功能,阅读他们对了解React的思路很有帮助,尤其推荐这篇。但这不是我想要的,我想要的是真正的React,辣个React。
RectDOM.render(<App/>, document.getElementById('app'));复制代码
如果你想读React源码,但又被React庞大的代码量劝退,我相信这个项目适合你起步。
npm start复制代码
这是这个系列第一篇文章,对应 git tag v1,正餐开始~
调度器 + 渲染器 = React
-
输入JSX后,我们如何解析JSX,并决定哪些是需要最终渲染成DOM节点?
-
我们怎么把需要渲染的DOM元素渲染到页面上?
为什么需要调度器?
-
我们希望用户输入的字符能实时显示在输入框内,不能有卡顿。
-
下拉框内容有个加载的过程一般是可以接受的。
这就是我们叫他调度器的原因——决定要处理什么,以及调度他们的优先级。
为什么需要渲染器?
调度的最小单元——Fiber
-
由于调度器能对应多个平台的渲染器,那调度器调度的节点就不能是平台相关的。如果调度器调度出的节点都是DOM节点,显然这些节点是没法在Native环境被渲染器渲染的。所以我们需要一种平台无关的节点结构。
-
刚才讲到调度器的功能时,我们希望低优先级的调度是可以被终止以重新开始一个更高优先级的调度的。那么被调度的节点粒度一定要够细,这样我们才能完全操控节点终止调度的位置并清除之前调度产生的结果再重新开始。
当我们尝试渲染 <App/> 时,会生成右侧的Fiber结构。Fiber的完整结构看这里。
我们可以在Fiber节点中保存节点的类型(比如App节点是一个函数组件节点,div节点是一个原生DOM节点,I am节点是一个文本节点),可以保存节点对应的state,props,可以保存节点对应的值(比如App节点对应左侧的函数,div节点对应div DOMElement)。
对于Fiber的结构其实我们可以更进一步。我们为Fiber增加如下字段:
- child:指向第一个子Fiber
- sibling:指向右边的兄弟节点
这样我们的父Fiber节点不需要用数组的形式保存多个子节点。所以我们可以这么改进下:
小朋友,此时你是否有很多❓❓❓,为啥这个字段叫return,不叫parent,React核心团队的Andrew Clark解释说:可以理解为return指向当前Fiber处理完后返回的那个Fiber,当子Fiber被处理完后会返回他的父Fiber。好吧🤷♂️
所以我们的完整Fiber结构是这样的:
调度和渲染的整体流程
-
向下遍历JSX,为每个节点的子节点生成对应的Fiber,并赋值
-
Placement 插入DOM节点
-
Update 更新DOM节点
-
Deletion 删除DOM节点
PS:这里同学可能会奇怪,这一步为什么是“为每个节点的子节点生成对应的Fiber”而不是“为当前节点生成对应的Fiber”?还记得下面这行代码么:
2. 为每个Fiber生成对应的DOM节点,保存在Fiber.stateNode。
-
哪些Fiber需要执行哪些操作(由Fiber.effectTag得知)
-
执行这些操作的Fiber他们对应的DOM节点(由Fiber.stateNode得知)
👨🏫术语小课堂: 我们一直讲调度和渲染,在React中,他们分别叫做render阶段和commit阶段,所以以后我们在讲render阶段时就是在说调度阶段,讲commit阶段就是在说渲染阶段。
调度阶段要做的2件事
beginWork
向下遍历JSX,为每个节点的子节点生成对应的Fiber,并设置effectTag
completeWork
为每个Fiber生成对应的DOM节点
我们通过workInProgress这个全局变量表示当前render阶段正在处理的Fiber,当首屏渲染初始化时, workInProgress === 根Fiber,接着我们调用workLoopSync方法,他内部会循环调用performUnitOfWork方法,这个方法接收当前workInProgress传入,处理他,返回下一个需要处理的Fiber。
对于图中Demo来说,就是遍历到 "I am"文本节点或"KaKaSong"文本节点。此时会执行completeUnitOfWork方法,这个方法内部会调用我们刚才讲的completeWork,并尝试返回其兄弟Fiber节点。
整个流程虽然看起来繁琐,但就做了2件事:
优化渲染阶段
effectList
在我们的设计中,渲染阶段会遍历找到所有含有effectTag的Fiber节点。如果Fiber树很庞大的话,这个遍历会很耗时。
但其实在调度阶段我们已经知道哪些Fiber会被设置Fiber.effectTag, 所以我们可以在调度阶段就提前标记好他们,将他们组织成链表的形式。
假设图中标红的Fiber代表本次调度该Fiber有effectTag,我们用链表的指针将他们链接起来形成一条单向链表,这条链表就是 effectList。
用Redux作者Dan Abramov的话来说,effectList相对于Fiber树,就像圣诞树上的彩蛋
那么渲染阶段只需要遍历这条链表就能知道所有有effectTag的Fiber了。这部分代码在completeUnitOfWork函数中。
首屏渲染的特别之处
按照我们的架构,我们会给需要插入到DOM的Fiber设置effectTag = Placement;这对于某次增量更新来说没有问题,但对于首屏渲染却太低效了,毕竟对首屏渲染来说,所有Fiber节点对应的DOM节点都是需要渲染到页面上的。
难道我们要给所有Fiber赋值effectTag = Placement;再在渲染阶段一次次的执行DOM插入操作来生成一整棵DOM树?对于首屏渲染,我们需要稍微变通下。
当我们在调度阶段执行completeWork创建Fiber对应的DOM节点时,我们遍历一下这个Fiber节点的所有子节点,将子节点的DOM节点插入到创建的DOM节点下。(子Fiber的completeWork会先于父Fiber执行,所以当执行到父Fiber时,子Fiber一定存在对应的DOM节点)。代码见这里
这样当遍历到根Fiber节点时,我们已经有一棵构建好的离屏DOM树,这时候我们只需要设置根节点一个节点effectTag = Placement; 就能在渲染阶段一次性将整课DOM树挂载。
调度阶段之前发生了什么
复习小课堂👩🎓:workInProgress指当前调度阶段正在处理的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版本我们还没有实现。
对于根Fiber初始化时,会产生一个update,update.payload为对应需要渲染的JSX(代码见这里),在根Fiber的beginWork中会触发这篇文章讲到的render流程。
最后的最后
篇幅有限,我们讲的很多都是宏观的东西,要了解细节还需要多多debug代码,把我们的Demo单步调试几遍。
这里再给你推荐一篇极好的React原理文章,配合本文食用效果极佳😊
来源:oschina
链接:https://my.oschina.net/u/4373790/blog/3275784