Vue3 最 Low 版实现
引言
我在上篇文章 聊一聊 Vue3 中响应式原理 对Vue3
响应式的实现原理做了介绍,想必大家对 Vue3
中的如何利用 Proxy
实现数据代理,以及如何实现数据的响应式有了一定的了解,今天我们再次进阶,就看看它是如何与 view
视图层联系起来的,实现一个Low
版的 Vue3
。
实现思路
首先我们要知道的是,不管是 Vue
还是 React
,它们的整体实现思路都是先将我们写的template
模版或者jsx
代码转换成虚拟节点,然后经过一系列的逻辑处理后,最后通过 render
方法挂在到指定的节点上,渲染出真实的 DOM
.
所以,我们第一步要实现的就是 render
方法,将虚拟 DOM
节点转换成真实的 DOM
节点并渲染到页面上。
虚拟节点的渲染
要实现 render
方法,首先得有虚拟 DOM
, 这里我们以一个经典的 计数器 为例。
// 计数器虚拟节点
const vnode = {
tag: 'div',
props: {
style: {
textAlign: 'center'
}
},
children: [
{
tag: 'h1',
props: {
style : {
color: 'red'
}
},
children: '计数器'
},
{
tag: 'button',
props: {
onClick: () => alert('Congratulations!')
},
children: 'Click Me!'
}
]
}
这样一来,可以确定 render
方法有2个固定参数,一个是 虚拟节点 vnode
,另一个是要渲染的容器 container
,这里先以 #app
为例。
-
render
方法
// 渲染函数
export function render (vnode, container) {
// 渲染处理函数
patch(null, vnode, container);
}
render
只是做了初始化的参数接收,参考源码,我们也构建一个 patch
方法用来做渲染。
-
patch
方法
// 渲染
function patch (n1, n2, container) {
// 如果是普通标签
if( typeof n2.tag === 'string'){
// 挂载元素
mountElement(n2,container);
}else if( typeof n2.tag === 'object'){
// 如果是组件
}
}
patch
方法不光要用于初始化的渲染,还会用于后续的更新操作, 因此需要三个参数,分别是 老节点n1
, 新节点n2 , 以及 容器container
。 另外要考虑到 标签 和 组件 两种形式,需要进行单独的判断,我们先从简单的标签开始。
-
mountElment
方法
mountElment
方法就是挂载普通元素,其核心就是递归。
pass
: 在挂载元素中会频繁使用一些 dom
操作,因此需要将其这些常用的工具方法放到一个 runtime-dom.js
文件中。
// 挂载元素
function mountElement (vnode, container) {
const { tag, props, children } = vnode;
// 创建元素,将虚拟节点和真实节点建立映射关系
let el = (vnode.el = nodeOps.createElement(tag));
// 处理属性
if ( props ) {
for ( let key in props ) {
nodeOps.hostPatchProps(el, key, {}, props[key])
}
}
// children 是数组
if ( Array.isArray(children) ) {
mountChildren(children, el)
} else {
// 字符串
nodeOps.hostSetElementText(el, children);
}
// 插入节点
nodeOps.insert(el, container)
}
为了处理多个 children
的情况,我们再来定义一个 mountChildren
方法,用于递归挂载。
-
mountChildren
方法
// 递归挂载子节点
function mountChildren (children, container) {
for ( let i = 0; i < children.length; i++ ) {
let child = children[i];
// 递归挂载节点
patch(null, child, container);
}
}
至此,经过这一大波一系列操作,我们已经可以成功将我们的 vnode
渲染到页面上去了. 动动手指点一点,功能也都 Ok ~
组件的挂载
上面我们已经实现了简单的标签挂载,接下来我们来看看组件的挂载是如何实现的。
之前提到了,组件的 tag
是一个对象 object
,首先我们先构建一个自定义组件。
// my component
const MyComponent = {
setup () {
return () => { // render 函数
return {
tag: 'div',
props: { style: { color: 'blue' } },
children: [
{
tag: 'h3',
props: null,
children: '我是一个自定义组件'
}
]
}
}
}
}
要注意的是 Vue3
中的 setup
方法可以返回一个函数,即渲染函数,以此来声明组件,具体可参考文档。
pass
: 如果我们没有返回渲染函数,vue
内部会将 template
模版编译成渲染函数,再将结果挂载到 setup
的返回值中。
我们将 MyComponent
放到我们的 vnode
中,其结构如下:
{
tag: MyComponent,
props: null, // 组件的属性
children: null // 插槽
}
pass
: 我们这里暂时没有考虑 props
和 children
,即对应组件的 props
属性和组件的 slot
插槽。
-
mountComponent
方法
组件的挂载过程大致是:先构建一个组件实例,作为组件的的上下文 context
,调用组件的 setup
方法返回 render
函数,在调用 render
得到组件的虚拟节点,最后通过 patch
方法渲染到页面中。
// 挂载组件
function mountComponent (vnode, container) {
// 根据组件创建一个示例
const instance = {
vnode: vnode, // 虚拟节点
render: null, // setup的返回值
subtree: null, // render返回的结果
}
// 声明组件
const Component = vnode.tag;
// 调用 setup 返回 render
instance.render = Component.setup(vnode.props, instance);
// 调用 render 返回 subtree
instance.subtree = instance.render && instance.render();
// 渲染组件
patch(null, instance.subtree, container)
}
最后将 mountComponent
方法放到 vnode.tag === "object"
分支中即可,可以顺利得到结果。
数据响应式
上面我们已经实现了普通标签和组件的渲染操作,事件也是简单的 alert
,接着我们需要将其与 data
联系起来。
我们先声明一个data
,作为页面的数据来源,
const data = {
count: 0
}
再将之前的 vnode
的 children
部分做一个简单的修改:
{
tag: 'h1',
props: {
style: {
color: 'red'
}
},
children: '计数器,当前值:' + data.count
},
{
tag: 'button',
props: {
onClick: () => data.count++
},
children: 'Increment!'
},
{
tag: 'button',
props: {
onClick: () => data.count--
},
children: 'Minus!'
}
其渲染结果如下图:
现在我们要实现的需求很简单,当我们点击 increment
和 minus
按钮的时候,当前的count
值会对应 加1
或者 减1
。
然而,实际上我们点击的时候,页面并没有发生任何变化,其实 count
的值已经更新了,大伙可以打个断点看看就知道了。
造成这结果的原因就是,我们还没有将视图与我们的数据联系在一起,即缺少一个桥梁,类似 vue2
中的 watcher
一样。
这时候需要用到 vue3
中响应式中的两个方法 —— reactive
和 effect
方法,其作用就是数据的依赖收集以及副作用的执行,详情请戳 聊一聊 Vue3 中响应式原理,这里就不再赘述,直接用即可。
首先通过 reactive
方法,将 data
通过 proxy
进行代理:
const data = reactive({
count: 0
})
之后通过 effect
方法将其联系起来:
effect(() => {
const vnode = {
tag: 'div',
props: {
style: {
textAlign: 'center'
}
},
children: [
{
tag: 'h1',
props: {
style: {
color: 'red'
}
},
children: '计数器,当前值:' + data.count
},
{
tag: 'button',
props: {
onClick: () => data.count++
},
children: 'Increment!'
},
{
tag: 'button',
props: {
onClick: () => data.count--
},
children: 'Minus!'
},
{
tag: MyComponent,
props: null, // 组件的属性
children: null // 插槽
}
]
}
render(vnode, app)
})
通过 effect
包裹之后,reactive
进行依赖收集,就可以达到将数据于视图联系起来的效果。
我们点击试试,可以看到结果如下:
count
的结果是对了,但是我们发现无论我们点击 increment
还是 minus
都会再次创建一个新的 vnode
插入到页面上,这是因为暂时我们没有做 dom-diff
造成的,后面我们再来解决这个问题。
组件的局部更新
我们先来看组件中的一个问题,我们先给 data
新增一个 num
属性,再将我们的自定义组件作如下修改:
{
tag: 'div',
props: { style: { color: 'blue' } },
children: [
{
tag: 'h3',
props: null,
children: '我是一个自定义组件,num:' + data.num
},
{
tag : 'button',
props : {
onClick: () => {
data.num++;
}
},
children: '更新num'
}
]
}
接着我们在页面中点击 更新num
这个按钮,可以看到跟上述更新 count
类似结果,即:
问题就出在这里,我们更新的是组件的 num
, 原则上跟 count
没有关系,那么应当不用更新与 count
相关的 dom
, 只做组件自己内部的更新。
所以,我们需要对每个组件自己内部做依赖收集,来实现组件的局部刷新。
只需在我们组件 patch
的时候加上 effct
即可:
effect(()=>{
// 调用 setup 返回 render
instance.render = Component.setup(vnode.props, instance);
// 调用 render 返回 subtree
instance.subtree = instance.render && instance.render();
// 渲染组件
patch(null, instance.subtree, container)
})
这样一来就实现了组件的局部更新。
DOM-DIFF
造成上述数据更新,页面不停 append
的原因就是没有做 dom-diff
,接下来我们一起来聊一聊,做一个简单的 dom-diff
。
pass
: 笔者能力有限,暂时没有考虑组件 component
的 diff
;
先以一个 li
的例子说明 diff
:
const oldVNode = {
tag: 'ul',
props: null,
children: [
{
tag: 'li',
props: { style: { color: 'red' }, key: 'A' },
children: 'A'
},
{
tag: 'li',
props: { style: { color: 'orange' }, key: 'B' },
children: 'B'
},
]
}
render(oldVNode, app)
setTimeout(() => {
const newVNode = {
tag: 'ul',
props: null,
children: [
{
tag: 'li',
props: { style: { color: 'red' }, key: 'A' },
children: 'A'
},
{
tag: 'li',
props: { style: { color: 'orange' }, key: 'B' },
children: 'B'
},
{
tag: 'li',
props: { style: { color: 'blue' }, key: 'C' },
children: 'C'
},
{
tag: 'li',
props: { style: { color: 'green' }, key: 'D' },
children: 'D'
}
]
}
render(newVNode, app)
}, 1500)
上述 vnode
表示先渲染 oldVNode
得到 A
,B
,E
三个不同 li
, 过了 1.5s
后,先修改了 B
的颜色属性,再删除 E
,最后添加两条新的 li
,C
和 D
。
涉及到了 dom
的复用(A
),属性的修改(B
),删除(E
),新增(C
,D
);
-
patchProps
方法
先看看最简单的 props
属性的对比操作,其思路就是将新增的属性添加上去,用新的值替换老的属性值,并删除到老的有的属性而新的没有的属性。
function patchProps (el, oldProps, newProps) {
if ( oldProps !== newProps ) {
/* 1.将新的属性设置上去 */
for ( let key in newProps ) {
// 老的属性值
const prev = oldProps[key];
// 新的属性值
const next = newProps[key];
if ( prev !== next ) {
// 设置新的值
nodeOps.hostPatchProps(el, key, prev, next)
}
}
/* 2.将旧的有而新的没有的删除 */
for ( let key in oldProps ) {
if ( !newProps.hasOwnProperty(key) ) {
// 清空新的没有的属性
nodeOps.hostPatchProps(el, key, oldProps[key], null)
}
}
}
}
这样一来就完成了属性的对比,接着就是子元素的对比。
-
patchChildren
方法
子元素的对比分为这么几种情况:
- 新节点的子元素是简单的字符串,直接做字符串替换即可,将新的文本设置到对应的节点上
- 否则新节点是数组,也有两种情况,一是老节点是简单的字符串,则直接将老节点删除掉,将新的节点挂载上去即可。二是老节点也是数组,最复杂的情况,则新节点需要与老节点一一对比。
// 子元素对比
function patchChildren (n1, n2, container) {
const c1 = n1.children;
const c2 = n2.children;
if ( typeof c2 == 'string' ) { // new 子元素是字符串,文本替换
if ( c1 !== c2 ) {
nodeOps.hostSetElementText(container, c2);
}
} else { // new 子元素是数组
if ( typeof c1 == "string" ) { // 先删除 old 原有的内容,然后插入新内容
nodeOps.hostSetElementText(container, '');
// 挂在新的children
mountChildren(c2, container);
} else {
// new 和 old 的 children 都是数组
}
}
}
上述方法即可完成简单的文本替换和新节点的挂载,对于新老元素的 children
都是数组的情况,则需要通过 patchKeyChildren
方法来实现。
-
patchKeyChildren
方法((暂时不考虑没有key
的情况))
该方法先根据新节点的 key
生成一个 index
映射表,之后去老节点中去查找是否有对应的元素,如果有就要复用,之后删掉老节点中多余的部分,添加新节点中新增的部分,最后通过确定key
和 属性值判断是否进行移动。
官方源码中利用了最长递增子序列 LIS
算法,用于确定不用移动的元素索引,提升性能。
function patchKeyChildren (c1, c2, container) {
// 1.根据新节点生成 key 对应 index 的映射表
let e1 = c1.length - 1; // old 最后一项索引
let e2 = c2.length - 1; // new 最后一项索引
//
const keyToNewIndexMap = new Map();
for ( let i = 0; i <= e2; i++ ) {
const currentEle = c2[i]; // 当前元素
keyToNewIndexMap.set(currentEle.props.key, i)
}
// 2.查找老节点 有无对应的 key ,有就复用
const newIndexToOldIndexMap = new Array(e2 + 1);
// 用于标识哪个元素被patch过
for ( let i = 0; i <= e2; i++ ) newIndexToOldIndexMap[i] = -1;
for ( let i = 0; i <= e1; i++ ) {
const oldVNode = c1[i];
// 新的索引
let newIndex = keyToNewIndexMap.get(oldVNode.props.key);
if ( newIndex === undefined ) { // old 有,new 没有
nodeOps.remove(oldVNode.el) // 直接删除 old 节点
} else {// 复用
// 比对属性
newIndexToOldIndexMap[newIndex] = i + 1;
patch(oldVNode, c2[newIndex], container);
}
}
let sequence = getSequence(newIndexToOldIndexMap); // 获取最长序列个数
let j = sequence.length - 1; // 获取最后的索引
// 以上方法仅仅对比和删除无用节点,没有移动操作
// 从后往前插入
for ( let i = e2; i >= 0; i-- ) {
let currentEle = c2[i];
const anchor = (i + 1 <= e2) ? c2[i + 1].el : null;
// 新的节点比老得多
if ( newIndexToOldIndexMap[i] === -1 ) { // 新元素,需要插入到列表中
patch(null, currentEle, container, anchor); // 插入到 anchor 前面
} else {
// 获取最长递增子序列,来确定不用移动的元素,直接跳过即可
if ( i === sequence[j] ) {
j--;
} else {
// 插入元素
nodeOps.insert(currentEle.el, container, anchor);
}
}
}
}
getSequence
算法源码请戳。
我们先在原有的 vnode
的 children
子元素的 props
中添加对应的 key
元素,再来试试就发现ok了~
总结
至此,我们实现了一个非常简陋的 vue3
简易版,实现了基本的vnode
渲染以及简单的dom-diff
操作,让我们对 vue3
的内部实现有了一定的了解。
而 vue3
真正的内部实现,远比这复杂得多,有很多代码的实现思路和方法个人理解起来比较困,确也都是值得我们学习借鉴的。本篇文章也是我对学习 vue3
过程中的一点知识积累和个人记录,希望能给大家起一个抛砖引玉的作用,大家加油~
最后附上 github地址 , 望大家批评斧正。
发表评论 (审核通过后显示评论):