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地址 , 望大家批评斧正。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容