前言
build own your react,算得上是React相关的历史好文。简单、清晰的思路,让初学者也能接受框架层面的代码。记录下全篇思考路径,证明下自己对一些基础的掌握。
目录
Preview
我们只需三行代码就可以确定一个React应用,第一行定义一个React元素,第二行获取DOM节点,第三行将React元素渲染到容器内。
对于第一行JSX语句,通常可以使用babel工具简单的转换为JS。我们只需要将标签内的tag name、props、children作为参数传入React.createElement
,React.createElement
可以根据这些信息创建一个对象,并且还会进行一些安全性验证。因此我们可以安全地使用函数的输出。
生成后的element对象如下图所示,对象含有type和props两个属性(其实还有其他更多属性,但只关注这两个属性就可以)。type是一个特殊的字符串或函数,代表我们想要创建的HTML element的tag name,props含有所有JSX的属性,其中children
属性是最值得关注的。children
可以使一个字符串或者一个包含更多元素的数组。这就是为什么元素是以树状结构存储的原因。
对于第三行Render函数, ReactDOM.render
是React更改DOM的地方。根据上述代码,Render函数主要分为三步:创建父节点、创建子节点、填充节点。
CreateElement
一个元素其实就是一个含有type和props的对象,CreateElement
唯一所做的事情就是创建这个对象。
正如,Preview中所提到的,props中我们最需要关注children属性,为了保证children属性总是为一个数组,需要利用剩余参数语法。具体函数如下:
ES6 中剩余参数语法的设计规定,剩余参数的作用是将函数调用时传入的多余参数收集到一个数组中。当没有多余参数传入时,按照语法规则,它就会被初始化为一个空数组。对其他props使用扩展运算符,对于非对象,扩展运算符会转为对象。因此,如果除children外的props为null,则会被转为空对象。这样可以保证在函数内部对剩余参数的操作具有一致性和可预测性。
当然,children数组中的元素并不总是对象,也可以是string和number这类原始值,我们需要对children数组进行遍历并且对每个元素做判断,如果是原始值我们需要为其创建一个type值为TEXT_ELEMENT的对象。此时,createElement
函数就转变为:
值得注意的是,React并不会为children包装原始值或者创建空数组。这一步工作只是为了让一切更容易让人理解。此时,我们就可以使用Didact的createElement
,代替React的createElement
函数调用。
通过/** @jsx Didact.createElement */
这种注释方式,能让 Babel 将转换过程中创建元素的操作指向自定义的Didact.createElement
函数,这样在编写 JSX 代码时,就可以按照自定义函数的逻辑来创建元素,实现自定义的元素创建和处理机制。
Render
本节我们首先关注添加操作,更新、删除操作后续再补充。添加操作可以分为三步:
判断element.type
给节点分配element属性
递归添加节点
Concurrent Mode
上述代码中,存在一个需要优化的点。对于render函数我们使用回溯进行调用,一旦开始渲染,就无法停下。如果渲染树太大会阻塞主线程过长时间,并且对于用户输入等优先级较高的任务也无法优先处理,需要等渲染结束。
所以,我们需要将渲染任务打散成更小的执行单元便于浏览器终端渲染。在React中使用Scheduler来管理任务的优先级和调度执行时间。这里我们利用浏览器的原生API requestIdleCallback
来实现类似效果,该API允许接受一个回调函数作为参数,回调函数会在浏览器的空闲时间被调用。
开启循环时,我们需要设置第一个执行任务,然后编写一个performUnitOfWork
函数,该函数不仅执行工作,还返回下一个工作单元。
Fibers
为了更好的组织单元工作,我们需要一种数据结构:Fiber树。
上节提到,在初次渲染,开启循环时,我们需要设置第一个执行单元。Fiber树的根节点就是这个第一个执行单元,在渲染伊始,创建Fiber树的根节点并设置为nextUnitOfWork
。其余工作我们可以在performUnitOfWork
中完成,对于每个Fiber树节点,需要做三件事:
- 添加节点到DOM树
- 为子元素创建Fiber节点
- 检查当前
fiber
的props.children
属性,该属性包含了子元素的信息。如果props.children
是一个数组,则遍历这个数组。 - 对于每个子元素,创建一个新的
fiber
节点。这个新的fiber
节点会包含子元素的 type、props(如 id、class 等)、parent、sibling 、child 、 dom(初始为null
,表示还未创建对应的真实 DOM)以及其他用于构建fiber
树的属性。 - 通过设置
child
和sibling
关系,将这些新创建的fiber
节点连接起来,构建fiber
树的层次结构。
- 检查当前
- 选择下一个工作单元
- 子节点优先
- 没有子节点会去搜索兄弟节点
- 如果子节点和兄弟节点都不存在,则会向上回溯搜索父节点的兄弟节点,直到回溯到根节点
Fiber Tree
构建这类数据结构的目的之一是为了更好的找到下一个执行任务单元。
因此,上述代码就需要更改为如下所示:
render
函数中,设置nextUnitOfWork
为Fiber树的根节点。
performUnitOfWork
函数三部曲
接下来,我们需要完成performUnitOfWork
函数三部曲,添加DOM节点、创建fiber节点、选择下一个任务执行单元。
首先,我们需要创建一个节点并添加到DOM树中,并在fiber.dom
属性中跟踪 DOM 节点。
其次,对于每个子节点,我们创建一个新的fiber节点。
然后,我们将其添加到Fiber树中,根据它是否是第一个子节点,将其设置为子节点或兄弟节点。
最后,按照上述所提的工作单元寻址规则寻找下一个工作单元。
综上,performUnitOfWork
函数如下所示:
Render & Commit
Concurrent Mode中我们通过requestIdleCallback
(React中通过Scheduler)来解决渲染树过大导致渲染时间过长的问题。但是,如果在完成整个渲染树之前,浏览器终端了渲染工作,用户可能会看到一个不完整的用户界面。但是我们并不希望出现这种情况。因此,我们需要将渲染阶段和递交阶段解耦,每次当渲染工作全部完成后再将节点添加到DOM树中。
Render
首先,我们需要在performUnitOfWork
函数中移除修改DOM的部分,将这部分操作合并到Commit阶段。
其次,render阶段转为跟踪fiber树的根节点。
最后,在工作循环workLoop函数中,一旦完成了所有工作(没有下一个执行工作单元),就递交整个fiber树。
Commit
在 commitRoot
函数中,递归地将所有节点附加到 DOM。
Reconciliation
上面章节我们围绕初次渲染时的DOM新增工作展开。对于节点的更新和删除操作,就需要了解React在Reconciliation 阶段所做的工作:虚拟DOM对比、生成新的工作单元。
Save oldFiber
首先,我们需要在递交阶段时使用currentRoot
将“最新的fiber树”保存下来以便对比。并且我们需要在每棵fiber树中添加alternate
属性用于链接上一次递交阶段“旧fiber树”。
DOM Diff
其次,我们需要将performUnitOfWork
函数中创建newFiber
的逻辑提取出来,封装成reconcileChildren
函数,在这个函数中我们还需要进行新旧fiber的对比工作。如果忽略遍历数组和链表的样板代码,在reconcileChildren
函数的循环中我们只需要关注oldFiber
和element
,element
是想要渲染到DOM的元素,oldFiber
是上次渲染的内容。两者的比较规则如下所示:
oldFiber
和element
具有相同类型,保留DOM节点,做更新操作,使用新属性更新。- 类型不同且存在一个新元素,需要做添加操作,新增一个新的DOM节点。
- 并且如果类型不同且存在旧的光纤,我们需要移除旧节点。类型不同且存在
oldFiber
,做删除操作,移除旧节点。
React可以使用类型做更好的协调工作,例如:它可以检测子元素在元素数组中改变位置的时候。
更新操作:当oldFiber
和element
具有相同类型时,创建newFiber
节点,保留oldFiber
和element
的属性并且添加属性effectTag:"UPDATE"
作为操作标识。
添加操作:使用effectTag:"PLACEMENT"
作为操作标识。
删除操作:由于没有newFiber
,将effectTag:"DELETION"
添加到oldFiber
上。维护一个deletions数组用于存放需要删除的oldFiber
,在递交阶段使用该数组进行删除操作。
Commit
最后,我们需要在commit阶段来处理effectTag
。PLACEMENT
和之前一样直接向父节点添加子元素,DELETION
做相反的操作,从父元素上删除子元素。
UPDATE
稍微复杂一点,我们需要在updateDom
函数中比较newFiber
和oldFiber
的属性,删除不存在的属性,设置新的或已更改的属性。值得注意的是,我们需要格外关注事件监听器,以属性名"on"开头作为判断标志,因为这类属性需要特殊处理。
Function Components
performUnitOfWork
在 React 的 Fiber 架构中,Fiber 节点是对组件的一种内部表示,它包含了组件的各种信息,包括类型、状态、更新队列等。对于函数组件和类组件,Fiber 节点的主要区别在于它们如何关联到 DOM 元素。因此,我们需要在最小执行单元performUnitOfWork
函数中对fiber节点做出类型判断,根据不同结果来采用不同处理方式:
与updateHostComponent
不同的是,在updateFunctionComponent
中,我们需要运行函数来获取子元素。在获取到子节点后,reconcileChildren
就可以用同样的方法进行了。
commitWork
在递交阶段,我们也需要在新增和删除操作中对没有dom
属性的fiber做一下特殊处理。找到有dom属性的节点,以此节点为根节点做元素添加操作(没有dom属性,无法添加子节点)。同理,我们也需要找到有dom属性的节点才能做删除操作。
Hooks
最后,既然有了函数式组件,也需要给函数式组件添加状态。
updateFunctionComponent
将wipFiber
及hookIndex
初始化为全局变量,wipFiber
(workInProgress Fiber)用于记录new fiber
,hookIndex
用于跟踪当前hook。更新函数中我们需要向wipFiber
添加一个hooks
数组以支持在同一个组件中多次调用useState
。
useState
useState
函数中,我们需要维护两个属性,一个是状态,一个是更新状态的函数。对于状态,我们需要利用alternate
属性来判断是否有oldHook
,如果有就复用状态,如果没有就需要进行初始化操作。
对于更新状态函数,该函数的作用主要是维护触发动作顺序并设置下一个工作单元。
我们下次渲染组件时进行此操作,从旧的钩子队列中获取所有操作,然后将它们逐个应用于新的钩子状态,因此当我们返回状态时,它已更新。
总结
通常学习一门技术我会分为三个步骤:熟悉概貌、阅读经典、了解前沿。概述React中以官网为主线,写了react的初学路径,本文以build own your react为主线,体会react的经典思想。下一篇我会写一下React19,体会一下React的最新变化。
参考资料
build own your react pomb.us/build-your-…