实现一个类React的Vue3框架
上一次使用NeZha
重构博客大概是三年以前了,最近打算迭代一下,于是想着写一个融合Vue3
和React
风格的小玩具,名字也没想好(也许就叫做react-vue
了)。本文主要记录一下相关的开发过程。
整个项目都放在github上面了。
期望的框架特性
我个人是比较喜欢更灵活的代码风格,因此我希望这个框架能够具备
- 与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')!)
搭建开发环境
由于需要支持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
}
这样,我们就可以开始编码了
站在巨人的肩膀上
根据我们期望的框架特性,规划一下需要实现的功能点
- 双向绑定
- 组件渲染
- 高效的diff算法
每一个功能点都在之前的博文中有过整理,这里不再展开,直接说实现方案
@vue/reactivity双向绑定
我们可以直接使用@vue/reactivity来快速构建一个包含双向绑定特性的项目。
在组件创建时,会运行reactive
、ref
收集对象每个属性的依赖,我们要做的是注册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})
}
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
移动操作
为什么不再使用Fiber
与NeZha
不同的是,这次整个diff过程采用的还是递归而非React Fiber的循环,原因在于
React的核心思想是UI = F(state)
,state是不可变的immutable
,每次更新时需要传入一个新的state,然后从根节点开始,所有组件重新运行,并进行diff操作。
而借助reactivity
,可以非常精准地判断是哪个组件需要更新,diff过程是组件级别的。
使用Fiber需要改造数据结构、diff过程,还需要相关的调度器,这在NeZha中已经照猫画虎实现过一次了,在我的博客这个基本上没有什么复杂业务场景的情况下,因此这次决定使用最简单的递归diff操作。
整体流程
整个应用的入口是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
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
了,而mountText
和mountElement
分别对应文本和元素节点的渲染,在渲染元素节点时,还要处理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)
}
}
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。
配套生态
实现了核心功能之后,还需要实现一些配套的功能,就可以把博客迁过去了
SSR
这里可以参考NeZha,将VNode直接编译成HTML字符串。
路由组件
根据浏览器url,判断当前应该返回的组件即可
状态管理
pinia
搭配TypeScript实在是太好用了,我决定复刻一个。
小结
整个项目都放在github上面了,还在开发之中,由于最近时间比较少,列举的配套生态还都没有实现。
等我抽空写完之后,就可以把博客迁移了,暂定的时间大概是7月份左右。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。