手写 Vue (一):虚拟 DOM

前言

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

如何编写一个 Vue 框架?

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

开发一个框架也应如此。vue

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

按照这个逻辑,那么,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访问实例的数据,也就是选项对象传入的datamarkdown

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算法,实现只对发生改变的节点从新建立;

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

相关文章
相关标签/搜索