前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >手写 Vue (一):虚拟 DOM

手写 Vue (一):虚拟 DOM

作者头像
我是一条小青蛇
发布2020-12-09 10:14:50
6940
发布2020-12-09 10:14:50
举报
文章被收录于专栏:青笔原创青笔原创

前言

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

如何编写一个 Vue 框架?

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

开发一个框架也应如此。

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

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

代码语言:javascript
复制
<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上。

代码语言:javascript
复制
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

代码语言:javascript
复制
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的完整字段包含:

代码语言:javascript
复制
var vnode = {
  tag,
  data,
  children,
  text
}

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

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

代码语言:javascript
复制
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创建一个节点树,例如:

代码语言:javascript
复制
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节点树。

代码语言:javascript
复制
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方法的基本功能也就简单了。

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

验证最小应用

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

代码语言:javascript
复制
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:

代码语言:javascript
复制
<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显示一致。为了测试节点树的渲染,我们不妨修改一下选项对象:

代码语言:javascript
复制
{
  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算法,实现只对发生改变的节点重新创建;

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

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2020-04-122,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 如何编写一个 Vue 框架?
  • 验证最小应用
  • 还要做什么?
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档