学习Vue.js源码

前言

Vue.js是目前MVVM框架中比较流行的一种,在之前的项目中也用过Vue+Vuex。在使用的过程中,也是一边看文档一边进行开发。对Vue的了解也仅限官方文档和一些社区上回答,很大程度上是知其然而不知其所以然。所以在项目告一段落之后,决定从Vue的源码入手,学习一下其内部架构。

Vue源码目录

目标Vue.js版本是2.0.5 /src目录
源码src目录
对应的目录分别为
/compiler/core/entries/platforms/server/sfc/shared

  1. /compiler目录是编译模版;
  2. /core目录是Vue.js的核心(也是后面的重点);
  3. /entries目录是生产打包的入口;
  4. /platforms目录是针对核心模块的’平台’模块,platforms目录下暂时只有web目录(在最新的开发目录里面已经有weex目录了)。web目录下有对应的/compiler、/runtime、/server、/util目录;
  5. /server目录是处理服务端渲染;
  6. /sfc目录处理单文件.vue;
  7. /shared目录提供全局用到的工具函数。
    在刚学习源码时,会对类似
    1
    2
    3
    4
    5
    export function observe (value: any): Observer | void {
    if (!isObject(value)) {
    return
    }
    }

这样函数申明或者变量申明感动疑惑的,可以先了解一下flow

独立构建&&运行时构建

Vue.js从2.0以后开始出现两个不同的构建版本,详情可以查看官网文档。一开始说到这个,是因为从Vue的build命令里面可以看到有7个build版本,这对学习其源码非常有帮助。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const builds = {
'web-runtime-dev': {
...
},
'web-runtime-cdn-dev': {
...
},
'web-runtime-cdn-prod': {
...
},
'web-standalone-dev': {
...
},
'web-standalone-prod': {
...
},
'web-compiler': {
...
},
'web-server-renderer': {
...
}
}

这里,关注的重点是runtime的版本。

Vue.js结构

通过上面的分析,可以看到Vue.js的组成是由core+对应的‘平台’补充代码构成(独立构建和运行时构建只是platforms下web平台的两种选择)。
Vue.js结构
core目录下面对应的componentsglobal-apiinstanceobserverutilvdom模块。

Vue.js的目标是通过尽可能简单的API实现响应的数据绑定组合的视图组件

Vue2.0在保持实现‘响应的数据绑定’的同时又引入了’virtual-dom’,那么它是怎么实现的呢?

响应的数据绑定

Vue.js实现数据绑定的关键是Object.defineProperty(obj, prop, descriptor),这也是为什么2.0不支持IE8原因之一,IE8下无法实现defineProperty的腻子脚本。当然实现类似功能的现代语法还有Object.observe(已经废弃)和Proxy。
Vue源码对此实现的逻辑在core/observer目录下。
关注三个类class Observer,class Dep,class Watcher
class Observer在core/observer/index.js中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export class Observer {
value: any;
dep: Dep;
vmCount: number; // number of vms that has this object as root $data

constructor (value: any) {
this.value = value
this.dep = new Dep()
this.vmCount = 0
def(value, '__ob__', this)
...
}
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i], obj[keys[i]])
}
}
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
}

Observer类实例是用来附加到每个被观察的对象(后面称之为响应式对象)上的。普通对象通常是不会变成‘响应式对象’的。经过defineReactive函数的调用,才会将传入的普通对象变成‘响应式对象’。而defineReactive就是利用了Object.defineProperty这个方法。defineReactive源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export function defineReactive () {
...
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
}
if (Array.isArray(value)) {
dependArray(value)
}
}
return value
},
set: function reactiveSetter (newVal) {
...
childOb = observe(newVal)
dep.notify()
}
})
}

Object.defineProperty(obj, prop, descriptor)中的第三个参数就是对象描述符。对象描述符的传入是有要求的,分为赋值描述符合存取描述符。而每次传入的参数只能是其中之一,很显然在defineReactive中传入的就是存取描述符,在传入的存取描述符对象中有get,set方法。set方法会实例化一个Observer,get方法会关联到一个class Dep的实例。但是仔细看get方法,发现只有在Dep.target值为true的时候才会发生关联。所以,接下来分析一下class Dep的源码。

1
2
3
4
5
6
7
8
9
10
11
12
export default class Dep {
...
}
Dep.target = null
const targetStack = []
export function pushTarget (_target: Watcher) {
if (Dep.target) targetStack.push(Dep.target)
Dep.target = _target
}
export function popTarget () {
Dep.target = targetStack.pop()
}

class Dep就是连接class Observerclass Watch类的介质。因为在set方法里面最终会调用dep.notify()方法。class Dep类中的notify方法会使这个dep实例下所有的watch数组更新一次。class Watch类的update方法会调用对应的回调方法,进行对应的更新。同时在前面提到的get方法关联class Dep实例时,是在Dep.target为true的时候才会执行。通过源码可以看到Dep.target的初始值是null,也就是默认是不会执行关联的。源码上对此做了注释,可以看到Dep.target是被赋予全局性质,用来保证同一时刻只有一个Watcher实例在被‘关联’(源码注释的地方是’evaluated’)。而激活Dep.target这个属性的是函数pushTargetpushTarget函数就在class Watcher中调用了。class Watcher的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
export default class Watcher {
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: Object = {}
) {
this.vm = vm
vm._watchers.push(this)
// options
...
this.value = this.lazy
? undefined
: this.get()
}
get () {
pushTarget(this)
const value = this.getter.call(this.vm, this.vm)
if (this.deep) {
traverse(value)
}
popTarget()
this.cleanupDeps()
return value
}
}

到这里,class Observerclass Depclass Watch三个类的关系就清楚了。这也是Vue中实现数据绑定用到的观察者模式的体现。

Virtual-dom

React的大热,是因为其带来了’Virtual-dom’和数据驱动视图的理念(尽管很多人觉得后者更重要)。这里并不想比较‘Virtual-dom’和原生的DOM操作谁快谁慢的问题(事实上在dom结构改动很多的情况下,原生DOM操作比较快。。。),仅仅是理解一下‘Virtual-dom’
‘Virtual-dom’是一系列的模块集合,用来提供声明式的DOM渲染。来看一个简单的DOM片段

1
2
3
4
5
<div id="parent">
<span class="child">item1</span>
<span class="child">item2</span>
<span class="child">item3</span>
</div>

对DOM片段结构抽象一下:一个根节点div,三个元素子节点span(对应的内部文本节点)。然后用JavaScript对象表示:

1
2
3
4
5
6
7
8
9
10
11
const dom = {
tagName: 'div',
props: {
id: 'parent'
},
children: [
{tagName: 'span', props: {class: 'child'}, children: ["item1"]},
{tagName: 'span', props: {class: 'child'}, children: ["item2"]},
{tagName: 'span', props: {class: 'child'}, children: ["item3"]},
]
}

进而扩展到整个HTML页面的结构。整个HTML页面结构其实可以用一个JavaScript对象表示,通过这个抽象,对dom对象的修改就会影响到HTML页面的结构。所以在改变HTML结构的时候,我们仅仅是修改JavaScript对象。相对以前修改HTML页面结构式通过直接修改DOM元素,现在变成修改对应的JavaScript对象。Vue.js在对DOM的抽象做的更细致,具体代码可以看core/vdom/create-element.js

在实现了对HTML结构的映射后,接下来就是‘Virtual-dom’的重点,如何比较两个不同HTML结构树的对象–diff算法。diff算法比较的就是两颗’树’的差异。而传统的’树’比较是一个时间复杂度为O(n^3),这个效率明显是不够的。而HTML的结构树的改变不同于传统的’树’。HTML结构树的改变,很少会出现跨越不同层级的改变。基于这个实际上的差异化改变,‘Virtual-dom’diff算法的时间复杂度是O(n)。有兴趣研究diff算法的,可以看下这里

在解决了diff算法核心问题后,就要把新旧虚拟树的差异应用到’老HTML结构树’上–’patch’。Vue.js在’patch’的解决方案上参考了开源项目Snabbdom。源码篇幅有点长,也有些复杂,想深入了解的可以配合开源的项目一起分析。

至此,‘Virtual-dom’大致的实现逻辑也清楚了。Vue.js的源码在架构组织上还有很多可以学习的,比如区分’核心模块’和’平台模块’。另外内部实现的’keep-alive’组件也是值得关注的地方,更多了解请到这里。以上只是很’粗浅’的学习,不对的地方希望大家指出来(认真脸.jpg)。

参考资料

Vue官网文档
What is Virtual Dom
The difference between Virtual DOM and DOM