阅读 187

手写 Vue (一):虚拟 DOM

前言

最近公司面试了一些中高级前端,由于公司技术栈以 Vue 为主,而对于中高级前端,必不可少要问及 Vue 源码的问题。很多面试者,对于源码只能简单讲到响应式是基于 Object.defineProperty 或者 Proxy 等老生常谈的基础概念。Vue 经过这么多年的发展,成了很多前端开发者职业生涯不可或缺的一个框架。诚然,每个人都可以在短时间学习一个框架的使用,但是要深入阅读它的源码确实不是一件容易的事。这里面有很多因素,除了业务开发繁忙外,面对一个复杂庞大的代码库,以及众多平时不经常使用的构建工具和新的编程语言等干扰因素,我们时常不知道该从哪里切入。为了应付面试,只能通过一些面经文章和博客,快速获得一些基本的认知,但一旦面试官深入拷问,真正看过源码还是只看过文章,就水落石出。真正读懂源码不是靠一场突击战就能做到的,而是像浇花种树一样,日积月累,反复刻意的练习和回顾,到最后甚至可以自己写出一个框架,才算真正掌握。既然是一场持久战,我们就不能指望在短时间内把整个框架一口吃进去,而是将其分割成一个个小的技术点,一次消化一个单一技术点,连点成线,最后就能吃下整个框架。本文以及接下来一系列文章,尝试将 Vue 源码拆分成独立的技术点,并动手编码实现。

如何编写一个 Vue 框架?

虽然,绝大多数开发者,职业生涯几乎不会参与到一个框架的开发,更不用说开发一个成功的被广泛使用的框架。但是,我们不妨假设,开发一个框架和开发一个业务产品的基本逻辑是一样的,就是首先,我们需要产品需求分析,然后将需求拆分成不同子模块,分别开发各个子模块后,再集成到一起组成一个完整的系统。

开发一个框架也应如此。

首先,需求分析,我们应该先问自己,这个框架要提供的核心功能是什么;其次,要实现这些功能,我们需要实现哪些技术点;最后,如何将这些分离的技术点组合复用成一个完整满足需求的框架。

按照这个逻辑,那么,Vue 的核心功能是什么?Vue2 为例,创建一个最简单的 Vue 应用的代码如下:

<div id="app"></div>
<script src="vue.js"></script>
<script>
var vm = new Vue(
  {
    data: {
      text: 'hello world!'
    },
    render(h) {
      return h('div', this.text)
    }
  }
).$mount('#app')
</script>
复制代码

这段代码,使用框架导出的一个构造函数 Vue ,传入包含字段datarender的选项对象,创建一个 Vue 实例 vm,并挂载到idappdom元素上。

这段代码在浏览器运行后,可以看到原来的dom元素<div id="app"></div>被替换成<div>hello world!</div>, 并可以在控制台键入 vm.text = 'hello china!',可以看到在实例的text属性改变后,对应的dom元素的文本内容立即改变了。

这里包含以下三个环节:

  1. data定义的字段(例如text)被映射到Vue实例的属性中;
  2. render函数传入了一个函数h,并用h函数创建虚拟节点,调用h使用了 1. 中映射的属性字段(this.text);
  3. 实例方法$moutrender返回的虚拟节点渲染到真实dom中;

首先,我们定义Vue的构造函数,读取选项对象的data字段,遍历data的所有键值,并克隆到实例对象this上。

function Vue(options) {
  var data = options.data
  var keys = Object.keys(data)
  for (var i = 0, l = keys.length; i < l; i++) {
    var key = keys[i]
    this[key] = data[key]
  }
}
复制代码

第二步,在 Vue 构造函数调用选项传入的render函数,通过callrender函数上下文对象this指向Vue实例,这样render函数内部可以通过this访问实例的数据,也就是选项对象传入的data

var render = options.render
this.vnode = render.call(this, createVNode)
复制代码

这里传入的函数createVNode也就是上文中的h函数。createVNode可以接受3个参数。

  • tag: string, 节点标签
  • data: object, 节点属性数据(包含 id, class, style)
  • children: array, 子节点数组

返回一个VNode对象,也就是通常我所说的虚拟DOM。要实现createVNode函数,我们需要先知道VNode到底为何物。所谓虚拟DOM,就是用一个普通的JS对象去建模真实的DOM,因此,直接修改虚拟DOM的属性,不会触发我们在页面可见DOM的改变,但是,它的结构是和真实DOM节点一一对应的。我们知道在浏览器中,每一个DOM节点都是一棵“树”。作为树中一个节点,至少包含两个部分,即节点数据和子节点。对应到DOM,一个节点自身的数据就是元素的标签和属性,子节点可以包含任意多个,因此使用数组表示。createVNode函数用于提供给应用构建视图的虚拟节点树,创建树的过程由外部提供,因此自身不需要递归创建子节点,而是简单接受参数,并根据参数传入类型和数量来决定VNode对应属性赋值。

目前,我需要的VNode的完整字段包含:

var vnode = {
  tag,
  data,
  children,
  text
}
复制代码

tag 为元素标签,data为属性数据,当节点是叶子节点,没有children,那么就用text表示节点显示的文本(事实上,文本在真实DOM中也是一个特殊的节点,它没有tag,因此为了处理方便,在虚拟节点中,children 中表示是有 tag 的元素节点)。

因此,createVNode 接受的参数与我们返回的结果基本一致,仅仅对传入的第2个参数进行判断,如果是字符串,就认为要创建的是一个只有文本的叶子节点,否则将第二个参数作为节点属性数据,第三个参数作为子节点数组。

function createVNode(tag, data, children) {
  var vnode = { tag: tag, data: undefined, children: undefined, text: undefined }
  if (typeof data === 'string') {
    vnode.text = data
  } else {
    vnode.data = data
    if (Array.isArray(children)) {
      vnode.children = children
    } else {
      vnode.children = [ children ]
    }
  }
  return vnode
}
复制代码

由于children参数的存在,在外部,可以使用createVNodeh创建一个节点树,例如:

var vnode = createVNode('ul', {}, [
  createVNode('li', {}, [
    createVNode('span', 'text')
  ]),
  createVNode('li', {}, [
    createVNode('span', 'text')
  ])
])
复制代码

创建的虚拟节点树,只是框架对应用视图的内部表示,要获得真实可见的DOM,需要一个函数将VNode转换成真实DOM。定义这个函数为createElm。这个函数除了将VNode转换成真实DOM元素,同时还将创建的DOM元素插入页面中。插入的位置包含了两个真实DOM元素,即插入元素的父节点,以及参考节点,参考节点是要替换的节点,是可选的,存在则插入到参考节点前面,并删除参考节点,不存在则直接将新创建的节点(根据VNode创建的真实DOM节点)插入到父节点中。和createVNode不同的是,createElm接受的vnode参数是一课树,因此,需要使用递归遍历整个VNode树,最后得到实际也是一个真实DOM节点树。

function createElm(vnode, parentElm, refElm) {
  var elm
  // 创建真实DOM节点
  if (vnode.tag) {
    elm = document.createElement(vnode.tag)
  } else if (vnode.text) {
    elm = document.createTextNode(vnode.text)
  }
  // 将真实DOM节点插入到文档中
  if (refElm) {
    parentElm.insertBefore(elm, refElm)
    parentElm.removeChild(refElm)
  } else {
    parentElm.appendChild(elm)
  }

  // 递归创建子节点
  if (Array.isArray(vnode.children)) {
    for (var i = 0, l = vnode.children.length; i < l; i++) {
      var childVNode = vnode.children[i]
      createElm(childVNode, elm)
    }
  } else if (vnode.text) {
    elm.textContent = vnode.text
  }

  return elm
}
复制代码

有了createElm函数,实现$mount方法的基本功能也就简单了。

Vue.prototype.$mount = function (id) {
  var refElm = document.querySelector(id)
  var parentElm = refElm.parentNode
  createElm(this.vnode, parentElm, refElm)
  return this
}
复制代码

验证最小应用

到此为止,似乎已经将前文创建简单Vue应用用到的所有功能实现了一遍。接下来,我们将代码整合一下,保存到文件myvue.js:

function Vue(options) {
  var data = options.data
  var keys = Object.keys(data)
  for (var i = 0, l = keys.length; i < l; i++) {
    var key = keys[i]
    this[key] = data[key]
  }

  var render = options.render
  this.vnode = render.call(this, createVNode)
}

function createVNode(tag, data, children) {
  var vnode = { tag: tag, data: undefined, children: undefined, text: undefined }
  if (typeof data === 'string') {
    vnode.text = data
  } else {
    vnode.data = data
    if (Array.isArray(children)) {
      vnode.children = children
    } else {
      vnode.children = [ children ]
    }
  }
  return vnode
}

function createElm(vnode, parentElm, refElm) {
  var elm
  // 创建真实DOM节点
  if (vnode.tag) {
    elm = document.createElement(vnode.tag)
  } else if (vnode.text) {
    elm = document.createTextNode(vnode.text)
  }
  // 将真实DOM节点插入到文档中
  if (refElm) {
    parentElm.insertBefore(elm, refElm)
    parentElm.removeChild(refElm)
  } else {
    parentElm.appendChild(elm)
  }

  // 递归创建子节点
  if (Array.isArray(vnode.children)) {
    for (var i = 0, l = vnode.children.length; i < l; i++) {
      var childVNode = vnode.children[i]
      createElm(childVNode, elm)
    }
  } else if (vnode.text) {
    elm.textContent = vnode.text
  }

  return elm
}

Vue.prototype.$mount = function (id) {
  var refElm = document.querySelector(id)
  var parentElm = refElm.parentNode
  createElm(this.vnode, parentElm, refElm)
  return this
}
复制代码

然后将html文件中的vue.js改成myvue.js:

<div id="app"></div>
<script src="myvue.js"></script>
<script>
var vm = new Vue(
  {
    data: {
      text: 'hello world!'
    },
    render(h) {
      return h('div', this.text)
    }
  }
).$mount('#app')
</script>
复制代码

在浏览器打开html文件,可以看到,结果与vue.js显示一致。为了测试节点树的渲染,我们不妨修改一下选项对象:

{
  data: {
    items: [
      'item1',
      'item2',
      'item3',
    ]
  },
  render(h) {
    var children = this.items.map(item => h('li', item))
    var vnode = h('ul', null, children)
    console.log(vnode)
    return vnode
  }
}
复制代码

还要做什么?

眨一看,好像一切如我们所料。它成功利用我们传入的数据和渲染函数,创建虚拟节点,并且挂载到真实DOM上。但是,目前来看它至少还缺少两个关键功能。

  1. 重新修改实例属性值(例如vm.text)并不能触发页面的重新渲染,也就是没有响应式;
  2. 只有完整创建一个新的DOM树的方法,对于已经创建好的DOM,重新更新,必须销毁整个DOM树,重新创建,即没有对新旧vnodediff算法,实现只对发生改变的节点重新创建;

别急,万丈高楼平地起,正如本文开篇所讲,我们需要的是一场持久战,而不是突击战。有了最小可用功能,后面就是在此基础上做迭代和优化。感兴趣的读者,请关注后续系列更新。