实现一个类React的Vue3框架

上一次使用NeZha重构博客大概是三年以前了,最近打算迭代一下,于是想着写一个融合Vue3React风格的小玩具,名字也没想好(也许就叫做react-vue了)。本文主要记录一下相关的开发过程。

<!--more-->

整个项目都放在github上面了。

1. 期望的框架特性

我个人是比较喜欢更灵活的代码风格,因此我希望这个框架能够具备

  • 与React setState先比,更灵活的Vue的双向绑定
  • 与Vue SFC组件文件,更具有表达性的JSX(虽然Vue也支持JSX,但还是需要写在SFC单组件文件中)
  • 此外我还希望能够实现css scoped等显著提高生产力的特性

因此,组件的定义可能类似于

function Count({value = 0}: CountProps) {
  const data = reactive({
    count: value
  })
  const onClick = () => {
    data.count++
  }

  const render = () => {
    return (<button onClick={onClick}>click {data.count} </button>)
  }

  return render
}

与React Function Component不同的是,这个组件返回的是一个render方法,因此更像是Vue 组件的setup。

在组件生命周期期间,只会在create时运行一次,render方法会在每次更新时会重复调用,用于生成新的虚拟DOM树并参与diff流程。

整个应用的初始化大概是下面的过程

function App() {
  return () => {
    return (<div>
     <Count value={10}/>
    </div>)
  }
}

createApp(<App/>).mount(document.querySelector('#root')!)

2. 搭建开发环境

由于需要支持JSX,因此开发环境需要babel等一系列配置。这里直接偷懒,直接使用大家都喜欢的vite啦。

首先创建一个 vite 项目,选择 react 模式

yarn create vite

创建完毕后,移除vite.config.ts中的 react 插件,然后配置一下esbuild

import { defineConfig } from "vite";
// import react from '@vitejs/plugin-react'

export default defineConfig({
    esbuild: {
        jsxFactory: "h",
        jsxFragment: "Fragment",
        target: "es2020",
        format: "esm",
    },
});

注意声明了jsx的 createElement 变量名是h,因此需要在每个jsx文件头部引入

import { h } from "@shymean/react-vue";

这个 h 就是我们要实现的创建虚拟 DOM 的方法,大概实现类似于

export function h(type: any, props: any, ...children: any[]): VNode {
  const node: VNode = {
    type,
    props: isNullOrUndef(props) ? {} : props,
    children,
  }
  node.nodeType = getNodeType(node)
  return node
}

这样,我们就可以开始编码了

3. 站在巨人的肩膀上

根据我们期望的框架特性,规划一下需要实现的功能点

  • 双向绑定
  • 组件渲染
  • 高效的diff算法

每一个功能点都在之前的博文中有过整理,这里不再展开,直接说实现方案

3.1. @vue/reactivity双向绑定

我们可以直接使用@vue/reactivity来快速构建一个包含双向绑定特性的项目。

在组件创建时,会运行reactiveref收集对象每个属性的依赖,我们要做的是注册effect方法,在属性变化时,触发effect,很显然,这个effect最大的作用就是重新执行render方法

function mountComponent(nextVNode: VNode, parentDOM: Element) {
  // 监听props的变化
  const props = reactive(nextVNode.props)

  // 创建组件
  const render = (nextVNode.type as Function)(props)

  nextVNode.$instance = {props, render} as IComponent

  let last: VNode
  // 收集render方法中的依赖,注册回调
  effect(() => {
    const child = render()
    patch(last, child, parentDOM)
    last = child
  }, {lazy: false})
}

3.2. inferno diff算法

inferno 是目前所有虚拟DOM框架中效率最高的,之前也分析过其源码,其diff算法是在做了很多优化措施之后,使用最长上升子序列来减少移动操作的。

我们可以参考这个步骤,来实现一个精简版的diff算法。

第一步,首尾对比,缩小操作范围

第二步,通过 map 保存 key 节点,空间换时间,找到需要移动、删除和新增的节点

对于

a b c d e f
a c b e f g

首先是缩小操作范围,首尾对比的方式,忽略那些key相同的。这一步看起来无关紧要,但在前端开发的业务场景中,比如增加、删除等操作,可以节省非常多的时间

b c d e f
c b e f g

然后我们来处理哪些需要增加、删除和移动的节点。

首先,我们定义旧列表的索引值是升序的,即

{b:0, c:1, d:2, e:3, f:4}

这样,要求从列表A修改为列表B的最小步骤,只需要找到列表B中的最长上升子序列,然后移动那些不在原本位置上的元素即可。

为什么是最长?因为最长的升序子序列,我们可以假定他们是无需进行移动操作的,无需移动的节点越多,那需要移动的节点肯定就越少。

所以我们要做的工作就是先获取新节点的索引值列表B,再求B的最长上升子序列就可以了。

let keyIndex: Record<string, number> = nextChildren.reduce((acc, child, index) => {
  const key = child.key as string
  acc[key] = index
  return acc
}, {} as { [key: string]: number })

let sources = new Int32Array(nextChildren.length);
lastChildren.forEach((child, index) => {
  const key = child.key as string
  // 不在新列表中,说明需要移除
  if (!(key in keyIndex)) {
    unmount(child, parentDOM)
    return
  }
  let idx = keyIndex[key]  // 旧节点在新列表中的位置
  sources[idx] = index + 1 // +1用来区分原本的占位0
})

其中,sources对应的就是新列表中每个节点在旧列表中的位置

b c d e f
c b e f g
keyIndex = {c:0,b:1,e:2,f:3,g:4}
sources = [1+1,0+1,3+1,4+1,0] = [2,1,4,5,0]

然后就是求[2,1,4,5,0]的最长升序子序列,求得值结果为[1,4,5],为了方便计算,我们同样取索引值结果[1,2,3]

得到了sources和其最长上升子序列之后,我们就可以开始处理dom了

// 找到最长升序子序列找到,这样就可以将需要移动的节点数量控制在最少
const seq = lis_algorithm(sources);
let j = seq.length - 1;

// 从后向前遍历source,找到需要新增的节点,0表示新增
// 从后向前是为了使用insertBefore
for (let i = sources.length - 1; i >= 0; --i) {
  // 处理新增节点
  if (sources[i] === 0) {
    let child = nextChildren[i]
    mount(child, parentDOM)
    continue
  }

  const nextChild = nextChildren[i]
  const oldChild = lastChildren[sources[i] - 1]
  patch(oldChild, nextChild, parentDOM)

  if (j < 0 || i !== seq[j]) {
    // 需要移动
    moveVNode(nextChild, parentDOM)
  } else {
    j--
  }
}

其中

  • sources中为0的元素则表示新增
  • 不为0的元素则表示可以复用旧的DOM节点,进行patch操作
  • 当节点的索引值不再最长上升子序列中时,说明需要进行moveVNode移动操作

3.3. 为什么不再使用Fiber

NeZha不同的是,这次整个diff过程采用的还是递归而非React Fiber的循环,原因在于

React的核心思想是UI = F(state),state是不可变的immutable,每次更新时需要传入一个新的state,然后从根节点开始,所有组件重新运行,并进行diff操作。

而借助reactivity,可以非常精准地判断是哪个组件需要更新,diff过程是组件级别的。

使用Fiber需要改造数据结构、diff过程,还需要相关的调度器,这在NeZha中已经照猫画虎实现过一次了,在我的博客这个基本上没有什么复杂业务场景的情况下,因此这次决定使用最简单的递归diff操作。

4. 整体流程

整个应用的入口是createApp

function render(oldVNode: VNode | undefined, newVNode: VNode, parent: Element) {
    if (!oldVNode) {
        mount(newVNode, parent);
    } else {
        patch(oldVNode, newVNode, parent);
    }
}

export function createApp(vNode: VNode) {
    return {
        mount(container: Element) {
            render(undefined, vNode, container);
        },
    };
}

首次渲染走mount,更新时走patch

4.1. mount

mount时,根据节点类型进行进行初始化

export function mount(newVNode: VNode, parentDOM: Element) {
  switch (newVNode.nodeType) {
    case NODE_YPE.TEXT:
      mountText(newVNode, parentDOM)
      break
    case NODE_YPE.HTML_TAG:
      mountElement(newVNode, parentDOM)
      break
    case NODE_YPE.COMPONENT:
      mountComponent(newVNode, parentDOM)
      break
  }
}

上面已经展示过mountComponent了,而mountTextmountElement分别对应文本和元素节点的渲染,在渲染元素节点时,还要处理props更新attributes等逻辑

export function setAttribute(dom: Element, prop: string, lastValue: any, nextValue: any) {
    if (isFilterProp(prop)) return
    if (prop === 'dangerouslySetInnerHTML') {
      if (lastValue !== nextValue) {
        dom.innerHTML = nextValue.__html;
      }
    } else if (isEventProp(prop)) {
      const eventName = normalizeEventName(prop)
      if (lastValue) {
        dom.removeEventListener(eventName, lastValue)
      }
      dom.addEventListener(eventName, nextValue)
    } else {
      if (prop === 'className') prop = 'class'
      dom.setAttribute(prop, nextValue)
    }
  }

4.2. patch

前面提到,patch是在effect中触发的,当响应式数据发生变化时,就会触发effect更新DOM

export function patch(lastVNode: VNode | undefined, nextVNode: VNode, parentDOM: Element,) {
  if (!lastVNode || !isSameNode(lastVNode, nextVNode)) {
    lastVNode && unmount(lastVNode)

    mount(nextVNode, parentDOM)
    return
  }

  switch (nextVNode.nodeType) {
    case NODE_YPE.TEXT:
      patchText(lastVNode, nextVNode)
      break
    case NODE_YPE.HTML_TAG:
      patchElement(lastVNode, nextVNode)
      break
    case NODE_YPE.COMPONENT:
      patchComponent(lastVNode, nextVNode)
      break
  }
}

patchComponent时,会更新vnode.$instance相关的props,从而实现props变化时更新组件的逻辑。

patchElement时,会调用patchChildren对比子节点列表,从而进入上面的最长上升子序列diff算法更新DOM。

5. 配套生态

实现了核心功能之后,还需要实现一些配套的功能,就可以把博客迁过去了

5.1. SSR

这里可以参考NeZha,将VNode直接编译成HTML字符串。

5.2. 路由组件

根据浏览器url,判断当前应该返回的组件即可

5.3. 状态管理

pinia搭配TypeScript实在是太好用了,我决定复刻一个。

6. 小结

整个项目都放在github上面了,还在开发之中,由于最近时间比较少,列举的配套生态还都没有实现。

等我抽空写完之后,就可以把博客迁移了,暂定的时间大概是7月份左右。