前端开发者应懂的n个概念-build own your react

122 阅读11分钟

前言

build own your react,算得上是React相关的历史好文。简单、清晰的思路,让初学者也能接受框架层面的代码。记录下全篇思考路径,证明下自己对一些基础的掌握。

目录

build own your react_qZcTeAt1ss.png

Preview

我们只需三行代码就可以确定一个React应用,第一行定义一个React元素,第二行获取DOM节点,第三行将React元素渲染到容器内。 image_b1nro9-Ge5.png

对于第一行JSX语句,通常可以使用babel工具简单的转换为JS。我们只需要将标签内的tag name、props、children作为参数传入React.createElementReact.createElement可以根据这些信息创建一个对象,并且还会进行一些安全性验证。因此我们可以安全地使用函数的输出。 image_jlXPJeJyoP.png

生成后的element对象如下图所示,对象含有type和props两个属性(其实还有其他更多属性,但只关注这两个属性就可以)。type是一个特殊的字符串或函数,代表我们想要创建的HTML element的tag name,props含有所有JSX的属性,其中children属性是最值得关注的。children可以使一个字符串或者一个包含更多元素的数组。这就是为什么元素是以树状结构存储的原因。 image_wT2qFVePvv.png

对于第三行Render函数ReactDOM.render是React更改DOM的地方。根据上述代码,Render函数主要分为三步:创建父节点、创建子节点、填充节点。 image_9tgh9wfp5I.png

CreateElement

一个元素其实就是一个含有typeprops的对象,CreateElement 唯一所做的事情就是创建这个对象。

CreateElement-1_r9RW66uLf7.png

正如,Preview中所提到的,props中我们最需要关注children属性,为了保证children属性总是为一个数组,需要利用剩余参数语法。具体函数如下: image_D_BfyQ-R_e.png

ES6 中剩余参数语法的设计规定,剩余参数的作用是将函数调用时传入的多余参数收集到一个数组中。当没有多余参数传入时,按照语法规则,它就会被初始化为一个空数组。对其他props使用扩展运算符,对于非对象,扩展运算符会转为对象。因此,如果除children外的props为null,则会被转为空对象。这样可以保证在函数内部对剩余参数的操作具有一致性和可预测性。

当然,children数组中的元素并不总是对象,也可以是string和number这类原始值,我们需要对children数组进行遍历并且对每个元素做判断,如果是原始值我们需要为其创建一个type值为TEXT_ELEMENT的对象。此时,createElement函数就转变为: image_jtHQ9P1A_A.png

值得注意的是,React并不会为children包装原始值或者创建空数组。这一步工作只是为了让一切更容易让人理解。此时,我们就可以使用DidactcreateElement,代替React的createElement函数调用。

createElement-2_MkpM6HIsxo.png

通过/** @jsx Didact.createElement */这种注释方式,能让 Babel 将转换过程中创建元素的操作指向自定义的Didact.createElement函数,这样在编写 JSX 代码时,就可以按照自定义函数的逻辑来创建元素,实现自定义的元素创建和处理机制。 image_lF0N399vQ_.png

Render

本节我们首先关注添加操作,更新、删除操作后续再补充。添加操作可以分为三步:

判断element.type

image_R8U-9nBDEb.png

给节点分配element属性

image_o4XcQq3JkC.png

递归添加节点

image_F1cHFNTPli.png

Concurrent Mode

上述代码中,存在一个需要优化的点。对于render函数我们使用回溯进行调用,一旦开始渲染,就无法停下。如果渲染树太大会阻塞主线程过长时间,并且对于用户输入等优先级较高的任务也无法优先处理,需要等渲染结束。 image_G8Qs7bLssc.png

所以,我们需要将渲染任务打散成更小的执行单元便于浏览器终端渲染。在React中使用Scheduler来管理任务的优先级和调度执行时间。这里我们利用浏览器的原生API requestIdleCallback来实现类似效果,该API允许接受一个回调函数作为参数,回调函数会在浏览器的空闲时间被调用。 image_U8A0wvmh_B.png

开启循环时,我们需要设置第一个执行任务,然后编写一个performUnitOfWork 函数,该函数不仅执行工作,还返回下一个工作单元。

Fibers

为了更好的组织单元工作,我们需要一种数据结构:Fiber树。

上节提到,在初次渲染,开启循环时,我们需要设置第一个执行单元。Fiber树的根节点就是这个第一个执行单元,在渲染伊始,创建Fiber树的根节点并设置为nextUnitOfWork 。其余工作我们可以在performUnitOfWork中完成,对于每个Fiber树节点,需要做三件事:

  1. 添加节点到DOM树
  2. 为子元素创建Fiber节点
    1. 检查当前 fiberprops.children 属性,该属性包含了子元素的信息。如果 props.children 是一个数组,则遍历这个数组。
    2. 对于每个子元素,创建一个新的 fiber 节点。这个新的 fiber 节点会包含子元素的 type、props(如 id、class 等)、parent、sibling 、child 、 dom(初始为 null,表示还未创建对应的真实 DOM)以及其他用于构建 fiber 树的属性。
    3. 通过设置 childsibling 关系,将这些新创建的 fiber 节点连接起来,构建 fiber 树的层次结构。
  3. 选择下一个工作单元
    1. 子节点优先
    2. 没有子节点会去搜索兄弟节点
    3. 如果子节点和兄弟节点都不存在,则会向上回溯搜索父节点的兄弟节点,直到回溯到根节点

Fiber Tree

fiber树_FGQ7dARFfN.png 构建这类数据结构的目的之一是为了更好的找到下一个执行任务单元。

image_Wwwr-MEnsu.png

因此,上述代码就需要更改为如下所示: image_yPMm0ogo3b.png

render函数中,设置nextUnitOfWorkFiber树的根节点。 image_cE1hbOTIeR.png

performUnitOfWork函数三部曲

接下来,我们需要完成performUnitOfWork函数三部曲,添加DOM节点、创建fiber节点、选择下一个任务执行单元。 image_qD3Onc8AuJ.png

首先,我们需要创建一个节点并添加到DOM树中,并在fiber.dom属性中跟踪 DOM 节点。 image_4csvOWDdw3.png

其次,对于每个子节点,我们创建一个新的fiber节点。 image_llMs-diAgC.png

然后,我们将其添加到Fiber树中,根据它是否是第一个子节点,将其设置为子节点或兄弟节点。 image_BoXPmxlFBv.png

最后,按照上述所提的工作单元寻址规则寻找下一个工作单元。 image_q4sieVVbDn.png

综上,performUnitOfWork 函数如下所示: image_BKNssB6cwP.png

Render & Commit

Concurrent Mode中我们通过requestIdleCallback (React中通过Scheduler)来解决渲染树过大导致渲染时间过长的问题。但是,如果在完成整个渲染树之前,浏览器终端了渲染工作,用户可能会看到一个不完整的用户界面。但是我们并不希望出现这种情况。因此,我们需要将渲染阶段和递交阶段解耦,每次当渲染工作全部完成后再将节点添加到DOM树中。

Render

首先,我们需要在performUnitOfWork 函数中移除修改DOM的部分,将这部分操作合并到Commit阶段。 image_Ampe1ZUAR7.png

其次,render阶段转为跟踪fiber树的根节点。 image_B75LdJKhHO.png

最后,在工作循环workLoop函数中,一旦完成了所有工作(没有下一个执行工作单元),就递交整个fiber树。 image_8NJPkj8TvF.png

Commit

commitRoot函数中,递归地将所有节点附加到 DOM。 image_1KDh4tBptq.png

Reconciliation

上面章节我们围绕初次渲染时的DOM新增工作展开。对于节点的更新和删除操作,就需要了解React在Reconciliation 阶段所做的工作:虚拟DOM对比、生成新的工作单元。

Save oldFiber

首先,我们需要在递交阶段时使用currentRoot将“最新的fiber树”保存下来以便对比。并且我们需要在每棵fiber树中添加alternate属性用于链接上一次递交阶段“旧fiber树”。 image_akGA1ymS7v.png.mark.png

DOM Diff

其次,我们需要将performUnitOfWork 函数中创建newFiber的逻辑提取出来,封装成reconcileChildren 函数,在这个函数中我们还需要进行新旧fiber的对比工作。如果忽略遍历数组和链表的样板代码,在reconcileChildren 函数的循环中我们只需要关注oldFiberelementelement是想要渲染到DOM的元素,oldFiber是上次渲染的内容。两者的比较规则如下所示:

  1. oldFiberelement 具有相同类型,保留DOM节点,做更新操作,使用新属性更新。
  2. 类型不同且存在一个新元素,需要做添加操作,新增一个新的DOM节点。
  3. 并且如果类型不同且存在旧的光纤,我们需要移除旧节点。类型不同且存在oldFiber,做删除操作,移除旧节点。 image_BktWpdUdDG.png

React可以使用类型做更好的协调工作,例如:它可以检测子元素在元素数组中改变位置的时候。

更新操作:当oldFiberelement 具有相同类型时,创建newFiber节点,保留oldFiberelement的属性并且添加属性effectTag:"UPDATE" 作为操作标识。

添加操作:使用effectTag:"PLACEMENT" 作为操作标识。

删除操作:由于没有newFiber,将effectTag:"DELETION" 添加到oldFiber上。维护一个deletions数组用于存放需要删除的oldFiber,在递交阶段使用该数组进行删除操作。 image_iWFPavSYqO.png

Commit

最后,我们需要在commit阶段来处理effectTagPLACEMENT 和之前一样直接向父节点添加子元素,DELETION 做相反的操作,从父元素上删除子元素。 image_o4mWg_DrHE.png

UPDATE 稍微复杂一点,我们需要在updateDom 函数中比较newFiberoldFiber的属性,删除不存在的属性,设置新的或已更改的属性。值得注意的是,我们需要格外关注事件监听器,以属性名"on"开头作为判断标志,因为这类属性需要特殊处理。 image_4VUcVIdcAZ.png

Function Components

performUnitOfWork

在 React 的 Fiber 架构中,Fiber 节点是对组件的一种内部表示,它包含了组件的各种信息,包括类型、状态、更新队列等。对于函数组件和类组件,Fiber 节点的主要区别在于它们如何关联到 DOM 元素。因此,我们需要在最小执行单元performUnitOfWork函数中对fiber节点做出类型判断,根据不同结果来采用不同处理方式: image_yQ5a5m4hR-.png

updateHostComponent 不同的是,在updateFunctionComponent中,我们需要运行函数来获取子元素。在获取到子节点后,reconcileChildren就可以用同样的方法进行了。 image_q4ay1JMV4b.png

commitWork

在递交阶段,我们也需要在新增删除操作中对没有dom属性的fiber做一下特殊处理。找到有dom属性的节点,以此节点为根节点做元素添加操作(没有dom属性,无法添加子节点)。同理,我们也需要找到有dom属性的节点才能做删除操作。 image_3tXK672Un2.png.mark.png

Hooks

最后,既然有了函数式组件,也需要给函数式组件添加状态。 image_vHBy4w-CZC.png

updateFunctionComponent

wipFiberhookIndex 初始化为全局变量,wipFiber(workInProgress Fiber)用于记录new fiberhookIndex用于跟踪当前hook。更新函数中我们需要向wipFiber添加一个hooks数组以支持在同一个组件中多次调用useStateimage_602NfPKCe0.png

useState

useState函数中,我们需要维护两个属性,一个是状态,一个是更新状态的函数。对于状态,我们需要利用alternate属性来判断是否有oldHook,如果有就复用状态,如果没有就需要进行初始化操作。 image_Yl6gpAN51o.png

对于更新状态函数,该函数的作用主要是维护触发动作顺序并设置下一个工作单元。 image_i82wJQB1tQ.png

我们下次渲染组件时进行此操作,从旧的钩子队列中获取所有操作,然后将它们逐个应用于新的钩子状态,因此当我们返回状态时,它已更新。 image_vY9XBVnfeG.png

总结

通常学习一门技术我会分为三个步骤:熟悉概貌、阅读经典、了解前沿。概述React中以官网为主线,写了react的初学路径,本文以build own your react为主线,体会react的经典思想。下一篇我会写一下React19,体会一下React的最新变化。

参考资料

build own your react pomb.us/build-your-…