阅读 138

看得懂的 Vue 框架核心原理(上)

Vue 作为当下流行的三大前端框架之一,简单易用的特性深受大家的喜爱,也一直是大家平时做业务开发项目的不二利器。但是,光会用肯定不行,不明白框架内部的实现和设计思想,在使用过程中碰到问题难免会有点懵。自古有云,知其然,亦知其所以然。接下来从简单的例子一步步深入了解 Vue 框架的核心原理。

本主题为笔者平时学习整理所得,内容分为两个篇章,此为上篇。

1.三大核心板块

众所周知,Vue 框架内部由三大核心板块组成,分别是响应式板块编译板块渲染板块

  • 响应式板块:使用 JavaScript 根据组件实例构建响应式数据对象
  • 编译板块: 把 template 的内容传递给 render 函数,并返回 vnode(即虚拟 dom)
  • 渲染板块:接受 vnode 并通过比较渲染成真实的 dom

由于 Vue2.0Vue3.0 针对构建响应式数据对象使用了不同的方式,2.0 是 Object.defineProperty,3.0 是 proxy。内容较多,故把响应式板块放到下一个篇章讲解,此篇章先讲解后两个板块。

2.编译和渲染板块的简单实现

2.1 框架出现的背景及意义

话不多说,直奔主题。文章标题写的是“看得懂”,故这里拿一个最基本的例子做实现,相信能够很好地帮助大家理解框架原理。

假设我们就实现一个最简单的内容,在页面上显示一个 Hello World。显示效果如下:
如果让我们在没有框架的时代背景下完成这个效果。一般是创建一个标签,然后修改它的 textContent,并赋上颜色:

let iDiv = document.createElement('div')
iDiv.textContent = "Hello World";
iDiv.style.color = "red";
document.body.appendChild(iDiv)
复制代码

这样写没什么问题,而且在 jQuery 时代配合用上相关 api,前端行业也是一片欣欣向荣的景象。然而,时间久了,些许问题就暴露出来了。主要归纳为下面几项:

  • 频繁操作 DOM 元素:页面是由元素标签组成的,要想更新元素内容,需要开发者频繁地操作计算元素内容;
  • 数据更新效率低:变更的数据有大有小,针对大批量数据更新或者多层级 DOM 结构少量数据变更没有较好的更新策略;
  • 缺少模块化:这里指的是 HTML 的模块化,虽然 W3C 有推出 Web Components标准想要从自身就支持 HTML 的模块化,但就目前来看还不是很完善,而且也不是所有主流浏览器都支持。

针对以上三个问题,我们可以思考分析,并尝试找出解决方案。首先,第一个和第三个问题可以归为一类,都是关于 HTML 的,我们知道原生元素标签上的属性值很多,光一个 div 标签就包含几十上百个属性值:

这些内容是在刚才调用 document.createElement 就会生成的,而我们实际操作的时候只用到了 textContentstyle 两个属性,那么我们是不是可以构建一个只包含我们关注内容的对象呢。答案是可以,着就催生出了一个概念 Virtual dom,即用 js 对象去模拟真实的 DOM 结构,并且 js 还支持模块化。其次,针对第二个问题,在虚拟 DOM 的基础上,落地了各种各样的 DOM diff算法,提升更新效率。

说了这么多总结一下:框架用虚拟 DOM 模仿真实 DOM,其内部既把 DOM 实现了模块化,又能渲染数据并进行高效率的更新。开发者只需要调用 api 把相关数据进行传递赋值即可。

3.代码实操

基于上面的内容,以下代码会包含 Vue 框架三个主要函数的实现:

  • render 函数,别名 h;用于构建 vnode
  • mount 函数,将 vnode 渲染为真实节点
  • patch 函数,用于更新操作

3.1 基本结构

还是刚才的例子,先假设基本结构如下:

<style>
  .red {color: #f00;font-size:24px;}
</style>
<div id="app"></div>
复制代码

接下来是每个方法的具体实现。

3.2 Render 方法

现在要在 div 里渲染出 Hello World!。用过 Vue 的同学应该都清楚 h 这个方法的使用方式:

const vdom = h('div',{class:'red'},[
  h('span',null,['hello'])
])
复制代码

此处的 h 方法接受三个参数,并返回 vnode。本着简单实现原则,我们尽可能简单的构建虚拟 dom 结构:

/**
* tag:元素标签
* props:元素相关属性
* children:元素子节点
**/
function h(tag,props,children){
    return {
        tag,
        props,
        children
    }
}
复制代码

该方法的任务就是把模板元素转为 vnode,并返回。在 Vue 的单文件组件实例中你肯定使用过 template 语法,Vue 框架内部就是把 template 内容解析(分成 parse、optimize 与 generate 三个阶段)为字符串并传入该方法并返回 vnode 对象,当然会有很多额外工作要处理,感兴趣的同学可以阅读源码。这里只是把核心思想表达出来。

3.3 mount 方法

然后,我们将 vnode 渲染成真实 DOM:

mount(vdom,document.getElementById('app'))
复制代码

可以看到 mount 方法需要两个参数,用于把 vnode 挂载到真实的 dom 节点上。

function mount(vnode,container){
    // 把container一层层传递下去
    const el = vnode.el = document.createElement(vnode.tag);
    // props 处理
    if(vnode.props){
        for(const key in vnode.props){
            const value = vnode.props[key];
            // props 有很多类型,诸如指令,类名,方法等,这里假设都是 attribute
            el.setAttribute(key,value)
        }
    }
   // 处理 children
   if(vnode.children){
     // 假设子节点是字符串
     if(typeof vnode.children === 'string'){
         el.textContent = vnode.children;
     }else{ 
         vnode.children.forEach(child=>{
             mount(child,el)
         })
     }
   } 
   container.appendChild(el)
}
复制代码

逐步分析代码内容:

  • 每个 vnode 都带有 tag 属性,可以据此创建真实 dom 元素标签,并赋值给每个 vnode 一个新属性 el,存放其真实 dom 结构
  • 然后处理 vnode 的 props。基于简单实现原则,我们假设元素 props 只涉及 attribute,所以我们遍历 props 对象,并把所有数据设置到节点 el 的 attr 上;
  • 然后处理 vnode 的 children,这里分两种情况:当子节点是文本节点,直接将它设置给 el.textContent 即可;当子节点是数组时,则遍历所有子元素,把当前 el 作为 container 递归调用 mount 方法渲染所有子节点;
  • 最后将 el 节点挂载到最初的 container 中,这里即是 <div id="app"></div>

整理代码,即可在页面得到想要的效果(以下代码直接复制粘贴后运行即可):

<html>
<style>
  .red {color: #f00;font-size:24px;}
</style>
<div id="app"></div>
<script>
  const vdom = h('div',{class:'red'},[
    h('span',null,['hello'])
  ])
  mount(vdom,document.getElementById('app'))
  
  function h(tag,props,children){
    return {
        tag,
        props,
        children
    }
  }
  function mount(vnode,container){
    // 把container一层层传递下去
    const el = vnode.el = document.createElement(vnode.tag);
    // props 处理
    if(vnode.props){
        for(const key in vnode.props){
            const value = vnode.props[key];
            // props 有很多类型,这里假设都是 attribute
            el.setAttribute(key,value)
        }
    }
   // 处理 children
   if(vnode.children){
     // 假设子节点是字符串
     if(typeof vnode.children === 'string'){
         el.textContent = vnode.children;
     }else{ 
         vnode.children.forEach(child=>{
             mount(child,el)
         })
     }
   } 
   container.appendChild(el)
  }
</script>
</html>
复制代码

3.4 patch 方法

项目开发,页面数据不可能一成不变,更新操作则显得尤为重要。本着简单实现基本原则,假设我们现在要把页面文字改为 Hello Vue,并给它换个颜色,效果如下:

开发者使用 Vue 框架进行该项操作时,应该是变更元素标签的 class 名,并更新元素里的文本内容。在 Vue 框架内部,则会据此重新生成一棵虚拟 DOM 树,它可能是这样的:

const vdom2 = h('div',{class:'green'},[
    h('span',null,'Hello Vue!')
])
复制代码

然后操作完成后,会触发内部的 patch 方法。因为生成了两棵虚拟 DOM 树,patch 方法必然要对它们进行比较,基于简单实现原则,本文只考虑相同类型节点比较(Vue 源码会通过 key 值以及是否为静态节点等信息进行比较,感兴趣的同学可以阅读源码):

function patch(n1,n2){
  // 假设前后节点类型没有发生变化
  if(n1.tag === n2.tag){}
}
复制代码

接下来,分步骤处理数据。

  • 先处理 props
const el = n2.el = n1.el;
// 1.处理 props
const oldProps = n1.props ||{};
const newProps = n2.props || {};
// 遍历新属性所有数据
for(const key in newProps){
    const oldValue = oldProps[key];
    const newValue = newProps[key];
    // 如果新旧属性值不同,把新属性设置给当前元素
   if(newValue !== oldValue){
     el.setAttribute(key,newValue)
   } 
}
// 遍历旧属性所有数据
for(const key in oldProps){
    // 如果新结构中没有对应属性,则说明要移除对应的属性值
    if(!(key in newProps)){
        el.removeAttribute(key)
    }
}
复制代码

代码注释已经写得比较清楚。补充说明的是:这段代码基于新旧树是相同节点,分别比较新属性和旧属性内容。针对新属性,对比查看是否存在和旧属性不同的值,如果有就变更赋值。针对旧属性,遍历查看是否存在新属性中没有的值,如果不在新属性中,移除即可。

  • 然后处理子节点,这块内容较多,我们先整理思路:

1.新节点是字符串类型时,有两种情况要处理:旧节点是字符串,旧节点不是字符串;
2.新节点不是字符串时,也有两种情况要处理:旧节点是字符串,旧节点不是字符串;

先处理第一步:

// 2.处理子节点
const oldChildren = n1.children;
const newChildren = n2.children;
// 2.1 如果新的子节点是字符串类型
if(typeof newChildren === 'string'){
    // 如果旧子节点也是字符串
    if(typeof oldChildren === 'string'){
        // 如果新旧节点内容不同,则将新值赋上
        if(newChildren !== oldChildren){
            el.textContent = newChildren;
        }
    }else{
        // 旧的子节点不是字符串,新的是字符串,直接替换
        el.textContent = newChildren;
    }
}else{
}
复制代码

其实新节点是字符串的时候比较好处理,不管旧节点是否为字符串,最后都把新节点内容替换到 textContent 上即可。条件在于新旧节点内容是否发生变化。具体细节在代码注释里都写得比较清楚,这里就不再赘述。

接下来处理第二步(接上一步代码):

// 2.处理子节点
const oldChildren = n1.children;
const newChildren = n2.children;
// 2.1 如果新的子节点是字符串类型
if(typeof newChildren === 'string'){
...
}else{
  // 2.2 如果新的子节点不是字符串
  // 但是旧子节点是字符串
  if(typeof oldChildren === 'string'){
      el.innerHTML = ''; // 先把元素内容置空,用于遍历渲染新的子节点
      newChildren.forEach(child=>{
        mount(child,el); // 把子节点一个个渲染到el元素下
      })
  }else{
      // 2.3 新旧子节点都不是字符串
      const commonLength = Math.min(oldChildren.length,newChildren.length);
      // 对于所有公共节点,递归进行比较
      for(let i=0;i<commonLength;i++){
          patch(oldChildren[i],newChildren[i])
      }
      // 新节点更多,将多出来的节点添加
      if(newChildren.length > oldChildren.length){
          newChildren.slice(oldChildren.length).forEach(child=>{
            mount(child,el)
          })
      }
      // 旧节点更多,移除多出来的节点
      if(oldChildren.length > newChildren.length){
          oldChildren.slice(newChildren.length).forEach(child=>{
            el.removeChild(child.el)
          })
      }
  }
}
复制代码

具体解读这段代码,新节点不是字符串,那就是一个数组,包含多个节点内容:

  • 旧节点为字符串:把当前节点内容 innerHTML 置空,遍历新节点内容调用 mount 把每个子节点渲染到 el 元素上。
  • 旧节点不是字符串,说明现在是两个数组对象的比较。
    • 首先取新旧子节点最小长度,先把他们共有的节点内容先处理了。具体是把公共元素递归调用 patch 再进行比较看每个子元素有何不同,多出来的子节点后面再处理;
    • 接着,如果新节点数量比较多,那么把多出来的子节点遍历添加到当前 el 下即可;
    • 如果新节点数量较少,则把旧节点多出来的内容移除掉。

至此,所有的编译和渲染,以及节点更新都讲解完毕。当然,上述代码都是假设了比较理想的情况,但本着简单实现原则,希望大家通过阅读实践都能够对 Vue 框架内部基本原理有较为清晰的认知,那么这篇文章的目的也就达到了。

最后,上述的所有代码我简单整理了一下放在 codePen 里,大家可以对照着看看。虽然功能不是很强大,虽然没有很高深的 diff 算法,但现在你能够很自信地说自己完全可以手写实现一个渲染框架了(即便很简陋,哈哈哈)。

代码链接:手写实现 Vnode

感谢阅读。

题图来源:dribbble.com/mappleton

文章分类
前端
文章标签