最近公司面试了一些中高级前端,由于公司技术栈以 Vue 为主,而对于中高级前端,必不可少要问及 Vue 源码的问题。很多面试者,对于源码只能简单讲到响应式是基于 Object.defineProperty
或者 Proxy
等老生常谈的基础概念。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
,传入包含字段data
和render
的选项对象,创建一个 Vue
实例 vm
,并挂载到id
为app
的dom
元素上。
这段代码在浏览器运行后,可以看到原来的dom
元素<div id="app"></div>
被替换成<div>hello world!</div>
, 并可以在控制台键入 vm.text = 'hello china!'
,可以看到在实例的text
属性改变后,对应的dom
元素的文本内容立即改变了。
这里包含以下三个环节:
data
定义的字段(例如text
)被映射到Vue
实例的属性中;render
函数传入了一个函数h
,并用h
函数创建虚拟节点
,调用h
使用了 1. 中映射的属性字段(this.text
);$mout
将render
返回的虚拟节点
渲染到真实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
函数,通过call
将render
函数上下文对象this
指向Vue
实例,这样render
函数内部可以通过this
访问实例的数据,也就是选项对象传入的data
。
var render = options.render
this.vnode = render.call(this, createVNode)
这里传入的函数createVNode
也就是上文中的h
函数。createVNode
可以接受3个参数。
返回一个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
参数的存在,在外部,可以使用createVNode
或h
创建一个节点树,例如:
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上。但是,目前来看它至少还缺少两个关键功能。
vm.text
)并不能触发页面的重新渲染,也就是没有响应式;vnode
的diff
算法,实现只对发生改变的节点重新创建;别急,万丈高楼平地起,正如本文开篇所讲,我们需要的是一场持久战,而不是突击战。有了最小可用功能,后面就是在此基础上做迭代和优化。感兴趣的读者,请关注后续系列更新。