跟着 https://pomb.us/build-your-own-react/ 尝试实现一个有基本功能的迷你 React。教程内容一流,交互效果更是一流。
作者的仓库:
以及感谢优秀的中译版: https://qcsite.gatsbyjs.io/build-your-own-react/
文章分成8个步骤:
createElement
函数render
函数- 并发模式
- 纤程 (Fiber)
- 渲染和提交阶段
- 协调 (Reconciliation)
- 函数式组件
- 钩子
1. createElement
函数
首先,我们有一个简单的 React app
首先,我们将它变成 JS:
createElement
函数需要做的就是创建一个带有 type 和 props 的对象。
例如:
因此,函数如下:
children
数组中也可能有像 strings、numbers 这样的基本值。为了方便起见,我们对所有不是对象的值创建一个特殊类型 TEXT_ELEMENT
,这和 React 有所差异。
2. render
函数
接下来是模拟 ReactDOM.render
函数,首要解决的问题是:怎么往 DOM 里加东西。
首先,创建对应 DOM 节点,然后赋值属性,最后将节点添加到容器。
递归地处理每一个子节点。
相关链接:
Array.prototype.filter() - MDN
Document.createTextNode() - MDN、Node.nodeValue - MDN
至此,我们得到了一个简单的能将 JSX 渲染成 DOM 的库。
可以使用以下代码尝试效果,也可以在 原作者提供的 codesandbox 上尝试。
3. 并发模式
在 render 函数 一节中,我们需要递归地处理子节点。渲染一旦开始就无法停止,可能会阻塞主线程。
因此,我们将任务分成多个小块,每完成一个小块,将控制权交还浏览器,让浏览器判断是否有更高优先级的任务。
我们使用 requestIdleCallback
循环来代替 react/packages/scheduler,回调函数会在浏览器空闲的时候被调用。
相关链接:
requestIdleCallback - MDN
4. 纤程(Fiber)
为了组织任务单元,我们需要一个数据结构: Fiber 树。每个 Fiber 节点对应一个 React element。
其中,处理顺序是:子节点 -> 兄弟节点 -> 叔节点(父节点的兄弟)
对应的处理函数则是 performUnitOfWork
,它需要完成三件事:
- 把 element 添加到 DOM 上;
- 为当前 element 的子节点新建对应 fiber 节点;
- 挑选下一个任务单元
4.1 改进 render
由于原有的 render
函数仅用于插入,因此需要对其进行改进。
首先注释掉旧的 render
函数,并新建一个 render
函数,将 nextUnitOfWork
置为 Fiber 树的根节点。
4.2 将节点插入 DOM
首先我们需要创建节点,这里可以复用部分旧的 render
代码。
将旧的 render
改名为 createDom
,删除递归执行和插入部分:
创建 Fiber 对应的 DOM 节点,并插入到父节点里。
4.3 为子节点创建 Fiber 节点
然后为子节点创建 Fiber 节点并且插入 Fiber 树。
4.4 选择下一个任务单元
按照 子节点 -> 兄弟节点 -> 叔节点(父节点的兄弟)的顺序选择下一个任务单元
相关链接:
Fiber的结构 - React技术揭秘
5. 渲染和提交阶段
目前,我们的方案会持续生成 DOM 节点并插入到父节点。在完成渲染之前,如果浏览器需要中断这个过程,用户可能会看到渲染不完整的界面。
因此我们在内存中完成修改,所有的任务都完成后再一起添加到 DOM。
我们用一棵叫做 wipRoot
(work in progress root)的树来追踪修改。
首先修改 render
函数。在原来的基础上,将 wipRoot
置为 Fiber 树的根节点。
然后,删除 performUnitOfWork
里插入 DOM 节点的部分。
修改 workLoop
,当所有修改已经完成,由 commitRoot
将变化提交到实际 DOM。
最后,添加 commitRoot
和 commitWork
,递归将节点插入 DOM。
相关链接:
什么是“双缓存” - React技术揭秘
6. 协调 (Reconciliation)
这一步,需要在之前增加节点的基础上,实现更新和删除。
6.1 保存上次提交
- 新增
currentRoot
用来保存上次提交到 DOM 节点的 Fiber 树,并修改 commitRoot
; - Fiber 节点内增加
alternate
,用于记录上一个 commit 阶段使用的 fiber 节点的引用
6.2 同时迭代
将为子节点创建 Fiber 节点的步骤抽取成 reconcileChildren
修改循环条件,迭代 elements
的同时也迭代 oldFiber
6.3 比较区别
通过比较 React element 和上一次提交的 Fiber node 我们可以找到需要在 DOM 上进行哪些改动。
比较的方法:
- 旧的 Fiber 和新的 React element 类型相同,则复用节点,仅修改属性
- 类型不同且有新的 element,需要增加 DOM 节点
- 类型不同且有旧 Fiber,需要删除 DOM 节点
分类设计 newFiber
后面针对各种情况继续修改 reconcileChildren
。
6.3.1 复用
复用旧的 DOM 节点,仅将 React element 的属性置换进去。effectTag
设置为 "UPDATE"
,后面会用到。
6.3.2 新增
effectTag
设置为 "PLACEMENT"
6.3.3 删除
删除时,不需要创建新 Fiber 节点,因此没有 newFiber
,就只修改旧节点的 effectTag
。同时由于我们 commit 的时候,并不会再遍历旧的 Fiber 树,所以需要使用一个数组标记哪些节点被删除。
新建数组
修改 reconcileChildren
同时,由于删除不产生新的 fiber,所以需要修改连接兄弟的条件
修改 render
修改 commitRoot
,提交需要删除的节点
6.4 处理 effectTag
我们需要对 commitWork
进行修改,以处理各种条件下 DOM 的变化。
遇到 PLACEMENT
和 DELETION
的情况比较简单,直接插入或删除即可。
下面考虑复用情况:
新建 updateDom
函数用于属性的新增和更新
修改 commitWork
同时,发现这个函数也可以用在 createDom
6.5 处理事件监听
在 updateDom
中,当我们遇到事件监听时,需要特殊处理。
相关链接:
Reconciliation - React
事件处理 - React
事件列表 - MDN
至此,我们完成了增删改功能。可以使用以下代码或者在原作者提供的 codesandbox尝试。
7. 函数式组件
下面是支持函数式组件。当遇到函数式组件时,jsx 转换成 js 的情况如下:
函数式组件有以下两个不同点:
- 函数式组件的 Fiber 没有 DOM 节点
- 子节点来自函数的运行而不是直接从
props
读取。
因此,当 Fiber 的类型是函数的时候,需要分类处理。
关键就是如何获得子节点。参考上面的例子,我们可以发现,运行函数即可。
同时,由于函数式组件没有 DOM 节点,所以在寻找父子节点的时候,需要跳过并找到离它最近的上(下)一个节点。
修改 commitWork
,增加 commitDeletion
8. 钩子 (Hooks)
首先加入一些全局变量,以及在 Fiber 中加入 hook
数组。
下一步是实现 useState
函数。
下一步就是实现 setState
。
最后,执行 actions
,更新状态。
相关链接:
Introducing Hooks - React
最终,我们构建了属于我们自己的 React。可以使用以下代码或者在原作者提供的 codesandbox尝试。