实现一个简易的React

前两周一直在学习React源码,发现通过断点观察代码运行流程来理解React源码并不是一个很好的方法:

  • 由于需要支持ReactDOM、SSR、React Native等,React源码抽象程度比较高,分支较多,且函数调用栈也很深
  • React引入的Fiber Reconciler优化diff流程,内部使用了很多状态码和全局变量
  • 我的React开发经验有限,有些API并不是十分熟悉

虽然艰难地将大概看了一些源码,却觉得收获较少,因此决定整理React内相关概念,并手动实现一个简易的React框架,从而更深入地学习React。

<!--more-->

本文从React的设计理念开始,整理相关技术的实现原理,然后根据自己的理解简易实现每个部分,最后完成一个简化版的React库。相关实现代码放在github上。

1. React设计概念

参考:

React假定UI只是一种数据到另外一种数据的映射,其内部结构主要分为了三层

  • Virtual DOM 虚拟DOM层,React核心内容只涉及如何定义组件,通过AST定义页面的展示和状态
  • Reconciler 调和层,React渲染组件方式由环境决定,定义组件,组件状态管理,生命周期方法管理,组件更新等应该跨平台一致处理,不受渲染环境影响,因此这部分工作由Reconciler统一处理
  • Renderer 渲染层,根据运行平台,渲染对应的页面,如 ReactDOM 、ReactNative、SSR渲染等

2. 实现Virtual DOM

参考

通过babel,我们可以将JSX代码编译成createElement形式的JavaScript代码,因此这一层我们只需要实现createElement方法即可。

在babel编译后,createElement方法接收到的参数有

  • type,解析得到的标签名,类型字符串或Fuction,其中字符串表示该组件为原生DOM,函数表示该组件为类或函数组件
  • props,解析标签上的属性,如事件注册函数、style、类名都存放在这里面
  • 剩余参数,表示该标签内部的子节点children,每个子节点可能是字符串或者调用createElement返回的节点

由于React中children是props的一个特殊属性,因此我们可以将vnode定义成如下形式

export interface Props {
    children?: [],
    nodeValue?: string,
}
export interface VNode {
    type: Function | string
    props: Props,
}

一个vnode描述了渲染一个真实DOM节点需要的信息,接下来实现createElement方法,返回一个vnode

function createElement(type: Function | string, props: any, ...children: any) {
    if (!props) {
        props = {}
    }
    props.children = children
    return {
        type,
        props
    }
}

3. Fiber Reconciler

Fiber,英文含义就是“纤维”,意指比Thread更细的线,也就是比线程(Thread)控制得更精密的并发处理机制

该章节是在阅读源码之后,参考下面文章整理的关于Fiber的一些理解。相关参考:

3.1. 为什么要使用Fiber

前面提到,Reconciler调和器主要作用就是在组件状态初始化或变更时,调用组件树各组件的render方法,渲染、卸载组件。由于JavaScript单线程的特点,每个同步任务不能耗时太长,否则浏览器无法响应用户的其他操作,造成页面卡顿。

在React 15.x版本及之前版本,采用的是Stack Reconcilier,其内部通过同步操作递归形式完成组件树的渲染。当组件树比较庞大时,计算组件树变更的JavaScript单线程会阻塞浏览器渲染引擎线程,导致用户体验较差。

在React 16版本使用了一个新的调和器Fiber Reconciler,其允许渲染阶段分段进行,中间可以返回浏览器主线程执行其他任务,当浏览器的高需求绘制或更新任务完成后,才开始渲染组件。

下面这两个例子展示了这两个调和器的性能差异

可见Fiber带来的性能提升是十分明显的。

3.2. 任务优先级调度原理

那么Fiber Reconciler是如何实现按任务优先级执行代码的呢?实际上,浏览器提供了两个接口

  • requestIdleCallback,在浏览器空闲时期依次调用函数,可以让开发者在主事件循环中执行后台或低优先级的任务,而不会对像动画和用户交互这样延迟敏感的事件产生影响
  • requestAnimationFrame,告诉浏览器在下次重绘之前调用指定的回调函数更新动画

Fiber Reconciler要做的就是分解渲染任务,然后根据不同的优先级使用上面两个API,异步执行任务,

  • 交互和动画相关高优先级任务使用requestAnimationFrame注册,保证在浏览器下次重绘前先执行,
  • 其余低优先级任务后使用requestIdleCallback注册执行,在浏览器空闲时期依次处理
  • requestIdleCallback可以传入一个timeout的配置,避免长时间执行而阻塞浏览器渲染
  • 为了兼容多个浏览器,React提供了这两个方法的polyfill,源码位于packages/scheduler/src/forks/SchedulerHostConfig.default.js,暂时无需深究其实现

要实现渲染任务分解,按照优先级执行,就不能再使用递归渲染子节点的方式了,Fiber Reconciler进行增量式渲染,每执行完一段更新过程,就把控制权交还给React负责任务协调的模块,维护每一个分片的数据结构,就是Fiber。Fiber的主要目标是使React能够实现调度,具体来说,我们需要能够

  • 暂停工作,稍后再回来。
  • 为不同类型的工作分配优先权。
  • 重用以前完成的工作。
  • 如果不再需要,则中止工作。

3.3. Fiber数据结构

Fiber可以将整个组件树从树结构映射为链表结构,这样就可以利用循环链表来完成组件树的渲染,且可以实现暂停、退出循环等操作。

为了将虚拟DOM构建的组件树转换为链表,Fiber节点包含了下面几个属性

  • stateNode表示当前FiberNode对应的element组件实例
  • child表示对一个子节点的引用,sibling表示对一个兄弟节点的引用,return表示对一个父节点的引用,从而实现Fiber树
  • updateQueue指向其更新队列,更新队列也是一个链表,有first和last两个属性,指向第一个和最后一个update对象
  • alternate开始指向自己的一个复制体,update的变化会先更新到alternate上,当更新完毕,alternate替换current
  • tag,用于标记fiber的WorkTag类型,主要表示当前fiber代表的组件类型如FunctionComponentClassComponent
  • type,表示当前代表的节点类型,对应vnode的type属性
  • effectTag, 表示当前fiber标记的更新操作Placement插入、PlacementAndUpdate替换、Update更新和Deletion删除

下面这张图展示了Fiber节点之间的关系

具体来说,Fiber Reconciler在执行过程中,会分为 2 个阶段。

  • 阶段一,生成新的 Fiber 链表,获得需要更新的节点信息。这一步是一个渐进的过程,可以被打断。
  • 阶段二,将需要更新的节点一次过批量更新,这个过程不能被打断。

接下来我们看看具体的代码执行流程。

3.4. 初始化时的流程

Fiber树在首次渲染的时候会一次过生成,整理了v16.8.6源码初始化过程,大致流程如下

legacyRenderSubtreeIntoContainer

ReactDOM.render方法中调用legacyRenderSubtreeIntoContainer,主要

  • 调用legacyCreateRootFromDOMContainer方法初始化了根节点ReactSyncRoot;初始化了Fiber链表的头节点FiberRootNode
  • 初始化时会调用unbatchedUpdates清除批量更新的一些状态,然后调用updateContainer方法渲染容器

updateContainer

其内部调用computeExpirationForFiber计算过期时间,每个Fiber实例从初始化开始,都携带了一个到期执行时间ExpirationTime,到期时间越短,则表示优先级越高,越需要提前执行。

所谓到期时间,是相对于调度器初始调用的起始时间而言的一个时间段;调度器初始调用后的某一段时间内,需要调度完成这项更新,这个时间段长度值就是到期时间值。

在初始化时设置根节点的ExpirationTime取值为Sync,然后调用updateContainerAtExpirationTime,其内部调用scheduleRootUpdate从根节点开始执行初始化任务

scheduleRootUpdate

首先调用createUpdate创建了一个Update更新任务。一个更新任务携带了本次更新的一些基本信息,如

  • expirationTime, 任务过期时间
  • tag: UpdateState, 可取值UpdateState、ReplaceState、ForceUpdate和CaptureUpdate
  • payload: null, 可以保存如setState传入的新状态等
  • callback: null, 任务处理函数,初始化时调用ReactDOM.render传入的第三个回调函数,保存在该属性上

获得更新任务之后,就调用enqueueUpdate其放入更新队列中,该方法接收一个FiberNode和一个update,初识化时将该Upate对象放在FiberRootNode的updateQueue上

准备就绪之后,就调用scheduleWork准备执行更新任务,在初始化时内部调用renderRoot(root, Sync, true)执行同步渲染根节点的任务

初始化时的renderRoot

renderRoot中,

  • 首先在调用prepareFreshStack设置一些全局变量如workInProgressworkInProgressRootExitStatus等。
  • 然后执行workLoopSync方法,完成fiber树的构建。

workLoopSync

workLoopSync中,从fiber根节点开始,同步循环调用performUnitOfWork遍历,在performUnitOfWork中,又可以分为下面步骤

beiginWork,根据fiber.tag调用不同类型节点的更新方法,如函数组件会直接调用fiber.type,类组件会构造fiber.type实例然后调用render方法,从而获取当前fiber的vnode子节点列表,然后调用reconcileChildren

reconcileChildren,使用vnode构造新的fiber节点,然后

  • 设置fiber的returnsibling属性,然后更新父节点的child属性,完成当前层级fiber树的构建
  • 如果存在旧的节点,则进行diff操作,并通过effectTag标记当前fiber需要进行更新操作,初始化时节点都标记为Placement表示新插入的节点

如果当前节点已经不存在child时,表示当前节点为其子节点创建或更新fiber的任务就已经完成了,此时调用completeUnitOfWork,该方法主要完成两件事情

  • 判断当前fiber节点状态,如果已经完成,则调用completeWork完成当前节点任务
    • 将当前fiber的lastEffect汇集到父fiber上,相当于一层层往上迭代, 最后会由最高的节点收集, 并执行更新
    • 为一些特定的fiber.tag处理逻辑,如HostComponent在浏览器中会调用createInstance创造或updateHostComponent渲染DOM节点
  • 将遍历fiber链表的指针指向其兄弟节点,返回上一层的performUnitOfWork,开始执行该兄弟节点的work
  • 如果不存在兄弟节点,则将workInProgress置位其父节点,调用completeWork完成父节点的fiber节点任务
  • 然后重复步骤,将遍历fiber链表的指针指向其父节点的兄弟,依次类推

以下面代码为例,

<div>
   <span1></span1>
   <p>
     <span2><span2>
   </p>
</div>

生成的Fiber节点及遍历顺序如下图所示,具体顺序为div->span1->p->span2->p->div

当完成整个fiber链表的遍历后,将workInProgressRootExitStatus状态设置为RootCompleted,返回null,退出workLoopSync的循环,回到renderRoot方法,调用commitRoot方法开始提交

commitRoot

其内部调用commitRootImpl方法来提交变化effect

然后在commitRootImpl中,调用commitMutationEffects提交改动,根据nextEffect.effectTag来处理本次改动

  • commitPlacement处理effectTag为Placement的fiber,前面提到初识化时effectTag都被标记为Placement
  • commitWork处理Update
  • commitDeletion处理Deletion

初始化时我们只需要关注commitPlacement,其内部

  • 根据parentFiber.tag找到可以插入的容器节点
  • 将fiber.stateNode插入到正确的容器节点,最后完成整个页面的初始化渲染

3.5. 更新时的任务调度

通过前面初始化的流程可以了解到,初始化时是从根节点FiberRootNode开始执行scheduleRootUpdate方法开始更新任务的,接下来看看当某个节点发生变化时对应的更新流程。

在类组件中可以调用this.setState来更新组件的状态。在调用setState时,会判断当前运行上下文并赋值给全局变量executionContext,可取值NoContextBatchedContextEventContext等。不同的上下文执行的调度方案是不一样的。

setState中调用的是this.updater.enqueueSetState

  • 通过getInstance(this)获取当前组件对应的fiber节点
  • 计算当前fiber任务的过期时间expirationTime,该过期时间决定了后续的任务调度模式
  • 创建一个Update对象,携带当前更新的state和回调函数,通过enqueueUpdate将其挂载到fiber上
  • 调用scheduleWork方法,只不过此处传入的是当前fiber节点而非初始化时的fiberRoot节点

可以看见上面工作与初始化时调用的scheduleRootUpdate基本一致,初始化与更新流程最大的区别在于:

  • 在初始化时,渲染是同步执行的,无法看见Fiber真正的工作流程;
  • 在更新时,首先调用getCurrentPriorityLevel获取当前操作的优先级priorityLevel,然后根据expirationTimepriorityLevel调用scheduleCallbackForRoot

getCurrentPriorityLevel

其中getCurrentPriorityLevel,内部调用的是Scheduler_getCurrentPriorityLevel,返回全局变量currentPriorityLevel。在不同的交互场景(如事件)中,使用runWithPriority可以更新该全局变量

scheduleCallbackForRoot

scheduleCallbackForRoot中,根据expirationTime的值更新root.callbackNode

  • Sync时,调用scheduleSyncCallbackrunRootCallback放入全局同步更新队列syncQueue
    • 此处runRootCallback第二个参数传入的是renderRoot.bind(null, root, expirationTime)方法
    • 通过Scheduler_scheduleCallback方法注册flushSyncCallbackQueueImpl,实现在next Tick时刷新syncQueue
    • 在刷新syncQueue时,会执行renderRoot方法,完成diff操作,然后提交更新
  • 其他情况调用scheduleCallback的返回值,内部同样调用Scheduler_scheduleCallback,同时传入了reactPriorityToSchedulerPriority优先级

函数执行完毕后,会判断当前执行上下文,如果是NoContext则直接调用flushSyncCallbackQueue更新视图,比如在定时器等任务中执行的setState就会进入该逻辑,否则会等待Scheduler_scheduleCallback任务调度。flushSyncCallbackQueue方法内部调用的实际上也是flushSyncCallbackQueueImpl

Scheduler_scheduleCallback

Scheduler_scheduleCallback中,会根据传入的priorityLevel和配置参数上的delay,判断当前任务是否需要推迟,

  • 如果不需要,则调用insertScheduledTask方法插入任务,该任务携带了回调函数flushSyncCallbackQueueImpl,然后调用requestHostCallback注册flushWork任务
  • 如果需要,则调用requestHostTimeout(在浏览器中实际就是setTimeout)注册延迟任务,延迟任务内部实际上也是调用requestHostCallback

requestHostCallback中,将传入的callback赋值给全局变量scheduledHostCallback,然后调用requestAnimationFrame注册回调函数,在下次绘制之前调用onAnimationFrame

onAnimationFrame中,

  • 由于requestAnimationFrame当标签页在后台时会被节流,因此需要注册一个setTimeout任务,调用performWorkUntilDeadline方法执行scheduledHostCallback,实际上就是执行flushWork
  • 递归调用requestAnimationFrame注册onAnimationFrame方法,在下一帧继续执行未完成的任务

flushTask

flushWork中会检测是否存在firstTask,如存在则遍历task任务链表,依次调用flushTask,其内部

  • 首先判断task.expirationTime <= currentTime是否成立,并将值赋值给didUserCallbackTimeout
  • 然后调用task.callback方法执行先前注册的任务回调函数,即flushSyncCallbackQueueImpl,并传入didUserCallbackTimeout

flushSyncCallbackQueueImpl方法中,会遍历syncQueue,依次执行任务队列中的任务,即前面提到的runRootCallback

  • 内部调用renderRoot方法开始diff节点
  • 注意此处didUserCallbackTimeout会作为renderRoot的第三个参数isSync传入,并返回renderRoot是否有为函数类型的返回值continuationCallback
  • 如果有,则表示上次diff操作被暂停,后面需要恢复执行,此处会保存相关任务信息

更新时的renderRoot

renderRoot第三个参数表示是执行同步更新还是异步更新方法。在初始化或同步更新时执行workLoopSync方法;如果是异步更新,则需要调用workLoop方法,与workLoopSync相比,增加了Scheduler_shouldYield方法:在完成单个performUnitOfWork方法生成一个新的fiber节点之后,检查当前帧是否已经超过过期时间

  • 如果没有,则继续构建树的过程,生成下一个fiber节点
  • 如果已经超过时间,则暂停循环,
    • 返回renderRoot.bind(null, root, expirationTime),在flushTask中会判断continuationCallback然后挂载到任务上
    • 将代码控制权交回给主线程,并在下一帧的requestAnimationFrame中继续执行未被清除的scheduledHostCallback,此处即flushWork
    • 在flushWork中继续执行上一个未被完成的任务,此处即前面暂停循环退出的renderRoot方法
    • 检查根节点和ExpirationTime是否改变
      • 如果未改变,则从之前的workInProgress继续调用performUnitOfWork构建fiber树
      • 如果已经改变,则需要从新从根节点开始调用performUnitOfWork,重新构建fiber树
  • 整个过程反复在requestAnimationFrame调度中执行,最后完成fiber树的绘制,进行commitRoot阶段

因此workLoop会检测当前帧的剩余时间,然后将代码控制权交回给主线程,所以才能实现比较流畅的更新过程

从上面流程可以看出,在更新时会根据已有树和最新 Virtual DOM 的信息,调用renderRoot生成一棵新的树。我们知道在beiginWork中根据fiber.tag调用不同类型节点的更新方法,前面我们在类组件中调用了setState,这里以类组件更新为例

当类组件更新时,此时已经有了组件实例instance = fiber.stateNode

  • 会对比fiber.alternate和fiber的stateprops属性,更新instance为setSate传入的新值,
  • 更新state和props后,会重新调用instance.render方法获得最新的子节点nextChildren
  • 然后调用reconcileChildren,将新旧子节点进行对比,标记需要更新的子节点,这里就是React diff算法的核心

当所有子节点都完成之后,继续调用commitRoot然后提交需要变化,最后完成页面的渲染更新。

3.6. diff算法

参考:

diff算法的工作是:在更新时,组件树从旧状态更新为新状态,计算旧组件树结构转换成新组件树结构的最少操作。

React 通过下面策略,将 O(n^3) 复杂度的问题转换成 O(n) 复杂度的问题。

  • Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计。
    • 当跨级移动时,对应的处理时删除之前的节点,然后在移动位置新建节点
  • 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构。
    • 如果是同一类型的组件,按照原策略继续比较 virtual DOM tree,且可以使用shouldComponentUpdate进行优化
    • 如果不是,则将该组件判断为 dirty component,从而直接替换整个组件下的所有子节点。
  • 对于同一层级的一组子节点,它们可以通过唯一 id 进行区分。
    • 节点处于同一层级时,会发生三种节点操作,分别为:INSERT_MARKUP(插入)、MOVE_EXISTING(移动)和 REMOVE_NODE(删除)
    • 可以根据key快速找到旧节点中的某个节点,此时只需要移动节点即可

因此diff算法实际上是在同一层级的子节点之间的比较,React diff的核心代码可以在reconcileChildren方法中查看。

3.7. 小结

  • 为了避免递归diff占用线程时间过长,引入了Fiber将递归修改为循环,在完成单个节点的任务之后,将
  • 使用requestAnimationFrame在每一帧的空闲时间遍历更新Fiber树,最后统一提交更改
  • 如果无数的优先级更高的任务插进来, 就会形成饥饿现象,fiber树一直无法完成,这种问题该如何处理呢?

4. 实现简易的Fiber Reconciler

在上一章节中我们了解了Fiber Reconciler的大致原理和工作流程,接下来我们实现一个简单的Fiber系统。

4.1. Fiber

前面提到,fiber实际上是一个链表节点,我们简化其定义,只保留一些基本属性

export enum FiberTag {HostRoot, HostComponent, FunctionComponent, ClassComponent}
export interface Fiber {
    updateQueue?: Array<Fiber>

    return?: Fiber,
    sibling?: Fiber,
    child?: Fiber,
    children?: Array<Fiber>,

    vnode: VNode,
    tag: FiberTag,

    patchTag: PatchTag, // 当前fiber对应的改动
    stateNode?: any, // dom实例或class组件实例

    newState?: Object, //修改state
    alternate?: Fiber // 旧的fiber
}

4.2. workLoop

从根节点开始,循环遍历子节点,构建fiber树

function workLoop() {
    // 某个节点发生变化,则其该节点和其子节点均需要进行diff操作
    // 整个工作流程为:首先从根节点向下更新fiber树,然后从叶子节点向上complete准备提交
    while (workInProgress) {
        workInProgress = performUnitWork(workInProgress)
    }

    if (pendingCommit.length) {
        commitWork(pendingCommit)
    }
}

performUnitWork中主要获取节点的子节点,并进行diff操作

function performUnitWork(fiber: Fiber) {
    // 根据fiber.tag来调用不同的子节点获取防范,然后diff其子节点,更新下一层的fiber树
    switch (fiber.tag) {
        case FiberTag.HostRoot:
            updateHost(fiber)
            break
        case FiberTag.FunctionComponent:
            updateFunctionComponent(fiber)
            break
        case FiberTag.ClassComponent:
            updateClassComponent(fiber)
            break
        case FiberTag.HostComponent:
            updateHostComponent(fiber)
            break
        default:
            console.log(`无tag为${fiber.tag}的处理方法`, fiber)
    }

    // 开始遍历子节点
    if (fiber.child) {
        return fiber.child
    }

    while (fiber) {
        // 从叶子节点开始完成工作,内部主要处理有patchTag和updateQueue的fiber,并委托到父节点上
        completeWork(fiber)
        // 然后遍历兄弟节点,完成兄弟节点的diff操作
        if (fiber.sibling) {
            return fiber.sibling
        }
        // 完成父节点的工作
        fiber = fiber.return

        if (currentWorkRoot === fiber) {
            return null
        }
    }

    return null // 遍历fiber完毕,则退出workLoop循环

    // 这里仅列出了函数组件的处理方式
    function updateFunctionComponent(fiber: Fiber) {
        const {vnode} = fiber
        const component = vnode.type
        // @ts-ignore
        let children = component(vnode.props)
        reconcileChildren(fiber, children)
    }
}

每种处理函数实际上都是先获取到当前fiber的子节点,然后调用reconcileChildren方法,在内部实现子节点的diff,更多实现可移步项目源码查看。

4.3. diff

下面是一个比较简陋的diff实现,后续会进行完善

// 比较新旧fiber的子节点,并将可用的fiber节点转换为fiber
export function diff(parentFiber: Fiber, newChildren: Array<VNode>) {
    const oldChildren = parentFiber.children // 获取旧的子节点列表
    let newFibers = []
    let prevFiber = null
    let i
    // 新节点与旧节点对比
    for (i = 0; i < newChildren.length; ++i) {
        let newNode = newChildren[i]
        let oldFiber = oldChildren[i]
        let newFiber: Fiber
        if (!newNode) {
            continue
        }
        if (oldFiber) {
            if (isSameVnode(newNode, oldFiber)) {
                // 如果存在相同类型的旧节点,则可以直接复用对应的dom实例,即newFiber.stateNode
                // 保留props属性等待complete时更新
                newFiber = createFiber(newNode, PatchTag.UPDATE)
                newFiber.children = oldFiber.children
                newFiber.stateNode = oldFiber.stateNode // dom类型的fiber其stateNode为dom实例
                newFiber.alternate = oldFiber
            } else {
                // 如果类型不同,则需要删除对应位置的旧节点,然后插入新的节点
                // todo 添加key判断,如果存在key相同的子节点,只需要移动位置即可
                newFiber = createFiber(newNode, PatchTag.REPLACE)
                newFiber.alternate = oldFiber
            }
        } else {
            // 当前位置不存在旧节点,表示新增
            newFiber = createFiber(newNode, PatchTag.ADD)
        }

        // 调整fiber之间的引用,构建新的fiber树
        newFiber.return = parentFiber
        if (prevFiber) {
            prevFiber.sibling = newFiber
        } else {
            parentFiber.child = newFiber
        }
        prevFiber = newFiber
        newFibers.push(newFiber)
    }

    parentFiber.children = newFibers // 保存更新后的children,用作下次diff使用

    // 移除剩余未被比较的旧节点
    for (; i < oldChildren.length; ++i) {
        let oldFiber = oldChildren[i]
        oldFiber.patchTag = PatchTag.DELETE
        enqueueUpdate(parentFiber, oldFiber) // 由于被删除的节点不存在fiber树中,因此交给父节点托管
    }
}

4.4. commit

当新的fiber树构建完毕之后,就可以提交更新操作,更新DOM节点,完成页面的渲染更新,其中updateElement等方法在renderDOM中实现

// 提交单个fiber,根据patchTag执行对应逻辑,然后调用
// pendingCommit的顺序为后续遍历,因此叶子节点最先执行
function commit(fiber: Fiber) {
    let parentFiber = fiber.return
    if (!parentFiber) {
        return
    }

    // todo 这里对于非dom组件的处理太草率了
    if ([FiberTag.FunctionComponent, FiberTag.ClassComponent].includes(parentFiber.tag)) {
        parentFiber = fiber.return.return
    }

    const container = parentFiber.stateNode

    if (fiber.tag === FiberTag.HostComponent) {
        const {patchTag} = fiber
        switch (patchTag) {
            case PatchTag.ADD:
                container.appendChild(fiber.stateNode)
                break
            case PatchTag.UPDATE:
                let alternate = fiber.alternate
                updateElement(fiber.stateNode, alternate.vnode.props, fiber.vnode.props)
                break
            case PatchTag.DELETE:
                container.removeChild(fiber.stateNode)
                break
            case PatchTag.MOVE:
                console.log('//todo move')
                break
            case PatchTag.REPLACE:
                let sibling = fiber.alternate.stateNode
                container.insertBefore(fiber.stateNode, sibling)
                container.removeChild(sibling)
            default:
        }
    }
}

4.5. 小结

本章节主要实现了

  • workLoop方法,遍历生成fiber树,目前暂时未实现任务调度,仅仅是同步循环完成构建
  • diff方法,对比新旧子节点,并将更新类目保存在patchTag
  • commit方法,统一提交变化,完成DOM节点更新

其中部分功能实现都比较草率,后面会进一步完善。

5. 实现RenderDOM

ReactDOM的主要工作是将Reconciler层处理的虚拟DOM转换成真实DOM并挂载到页面节点上,因此这一层我们主要需要实现下面几个方法

5.1. createElement

将VNode渲染为真实的DOM节点,在初始化时会遍历并将所有的vnode转换为DOM节点

export function createElement(fiber: Fiber) {
    const {vnode} = fiber;
    const {type, props} = vnode
    // 判断是渲染文本节点还是元素节点
    const element =
        type === "text"
            ? document.createTextNode('')
            : document.createElement(<string>type);

    updateElement(element, {}, props);
    return element;
}

5.2. updateElement

与之前的props进行对比,更新DOM节点的属性

export function updateElement(element: any, props: any, newProps: any) {
    if (!newProps) {
        return
    }
    Object.keys(newProps)
        .forEach(key => {
            // 相同的属性跳过更新
            if (props[key] === newProps[key]) {
                return
            }

            if (key === 'nodeValue') {
                // 处理文本节点
                element[key] = newProps[key]
            } else {
                // 处理其他值
                updateProperty(element, key, props[key], newProps[key])
            }
        })
}
// 将不同的属性名更新到DOM节点上
function updateProperty(element: any, name: string, value: any, newValue: any) {
    if (name === 'style') {
        for (let key in newValue) {
            let style = !newValue || !newValue[key] ? '' : newValue[key]
            element[name][key] = style
        }
    } else if (name.indexOf('on') > -1) {
        name = name.slice(2).toLowerCase()
        if (value) {
            element.removeEventListener(name, value)
        }
        element.addEventListener(name, newValue)
    } else if (isValidAttr(name)) {
        element.setAttribute(name, newValue)
    }
}

6. 小结

手动实现一个迷你React库最大的收获是可以在开发调试的过程中看出这个库存在哪些功能上的缺少,从而理解React的设计概念。

本文从React的内部三层结构开始

  • 理解Virtual DOM的抽象意义,并定义了createElement返回的数据类型
  • 阅读React源码,了解Fiber Reconciler的实现原理,然后追踪了初始化和更新时的代码执行流程,然后实现了一个不包含调度系统的简易调和器
  • 实现了在浏览器进行commitRoot操作时需要的renderDOM相关方法,渲染DOM节点

由于本人水平有限,文中理解和代码实现比较浅陋,如有问题,还请大家能帮忙指出。NeZha这个库我会继续更新完善,最终目标就是把当前博客使用的swig同构渲染干掉。

至此,大致了解了React在浏览器中的运行流程,在下一篇博文中,会继续从源码层面分析React的相关原理,如合并更新、组件生命周期、合成事件等细节。