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: 我们这里暂时没有考虑 propschildren,即对应组件的 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
}

再将之前的 vnodechildren 部分做一个简单的修改:

{
    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!'
}

其渲染结果如下图:

现在我们要实现的需求很简单,当我们点击 incrementminus 按钮的时候,当前的count 值会对应 加1 或者 减1

然而,实际上我们点击的时候,页面并没有发生任何变化,其实 count 的值已经更新了,大伙可以打个断点看看就知道了。

造成这结果的原因就是,我们还没有将视图与我们的数据联系在一起,即缺少一个桥梁,类似 vue2 中的 watcher 一样。

这时候需要用到 vue3 中响应式中的两个方法 —— reactiveeffect 方法,其作用就是数据的依赖收集以及副作用的执行,详情请戳 聊一聊 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: 笔者能力有限,暂时没有考虑组件 componentdiff

先以一个 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 得到 ABE 三个不同 li , 过了 1.5s 后,先修改了 B 的颜色属性,再删除 E ,最后添加两条新的 liCD

涉及到了 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 算法源码请戳

我们先在原有的 vnodechildren 子元素的 props 中添加对应的 key 元素,再来试试就发现ok了~

总结

至此,我们实现了一个非常简陋的 vue3 简易版,实现了基本的vnode渲染以及简单的dom-diff操作,让我们对 vue3 的内部实现有了一定的了解。

vue3 真正的内部实现,远比这复杂得多,有很多代码的实现思路和方法个人理解起来比较困,确也都是值得我们学习借鉴的。本篇文章也是我对学习 vue3 过程中的一点知识积累和个人记录,希望能给大家起一个抛砖引玉的作用,大家加油~

最后附上 github地址 , 望大家批评斧正。

本文章由javascript技术分享原创和收集

发表评论 (审核通过后显示评论):