学习Vue.js源码
前言
Vue.js 是目前 MVVM 框架中比较流行的一种,在之前的项目中也用过 Vue + Vuex。在使用的过程中,也是一边看文档一边进行开发。对 Vue 的了解也仅限官方文档和一些社区上回答,很大程度上是知其然而不知其所以然。所以在项目告一段落之后,决定从 Vue 的源码入手,学习一下其内部架构。
Vue 源码目录
目标 Vue.js 版本是 2.0.5 /src目录
对应的目录分别为
/compiler
、/core
、/entries
、/platforms
、/server
、/sfc
、/shared
- /compiler 目录是编译模版;
- /core 目录是 Vue.js 的核心(也是后面的重点);
- /entries 目录是生产打包的入口;
- /platforms 目录是针对核心模块的 ‘平台’ 模块,platforms 目录下暂时只有 web 目录(在最新的开发目录里面已经有 weex 目录了)。web 目录下有对应的 /compiler、/runtime、/server、/util目录;
- /server 目录是处理服务端渲染;
- /sfc 目录处理单文件 .vue;
- /shared 目录提供全局用到的工具函数。
在刚学习源码时,会对类似
export function observe (value: any): Observer | void {
if (!isObject(value)) {
return
}
}
这样函数申明或者变量申明感动疑惑的,可以先了解一下 flow。
独立构建&&运行时构建
Vue.js 从 2.0 以后开始出现两个不同的构建版本,详情可以查看官网文档。一开始说到这个,是因为从 Vue 的 build 命令里面可以看到有 7 个 build 版本,这对学习其源码非常有帮助。
const builds = {
// Runtime only (CommonJS). Used by bundlers e.g. Webpack & Browserify
'web-runtime-dev': {
entry: path.resolve(__dirname, '../src/entries/web-runtime.js'),
dest: path.resolve(__dirname, '../dist/vue.common.js'),
format: 'cjs',
banner
},
// runtime-only build for CDN
'web-runtime-cdn-dev': {
entry: path.resolve(__dirname, '../src/entries/web-runtime.js'),
dest: path.resolve(__dirname, '../dist/vue.runtime.js'),
format: 'umd',
banner
},
// runtime-only production build for CDN
'web-runtime-cdn-prod': {
entry: path.resolve(__dirname, '../src/entries/web-runtime.js'),
dest: path.resolve(__dirname, '../dist/vue.runtime.min.js'),
format: 'umd',
env: 'production',
banner
},
// Runtime+compiler standalone development build.
'web-standalone-dev': {
entry: path.resolve(__dirname, '../src/entries/web-runtime-with-compiler.js'),
dest: path.resolve(__dirname, '../dist/vue.js'),
format: 'umd',
env: 'development',
banner,
alias: {
he: './entity-decoder'
}
},
// Runtime+compiler standalone production build.
'web-standalone-prod': {
entry: path.resolve(__dirname, '../src/entries/web-runtime-with-compiler.js'),
dest: path.resolve(__dirname, '../dist/vue.min.js'),
format: 'umd',
env: 'production',
banner,
alias: {
he: './entity-decoder'
}
},
// Web compiler (CommonJS).
'web-compiler': {
entry: path.resolve(__dirname, '../src/entries/web-compiler.js'),
dest: path.resolve(__dirname, '../packages/vue-template-compiler/build.js'),
format: 'cjs',
external: ['he', 'de-indent']
},
// Web server renderer (CommonJS).
'web-server-renderer': {
entry: path.resolve(__dirname, '../src/entries/web-server-renderer.js'),
dest: path.resolve(__dirname, '../packages/vue-server-renderer/build.js'),
format: 'cjs',
external: ['stream', 'module', 'vm', 'he', 'de-indent']
}
}
这里,关注的重点是 runtime 的版本。
Vue.js 结构
通过上面的分析,可以看到 Vue.js 的组成是由 core + 对应的 ‘平台’ 补充代码构成(独立构建和运行时构建只是 platforms 下 web 平台的两种选择)。
core 目录下面对应的 components
、global-api
、instance
、observer
、util
、vdom
模块。
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 中
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)
if (Array.isArray(value)) {
const augment = hasProto
? protoAugment
: copyAugment
augment(value, arrayMethods, arrayKeys)
this.observeArray(value)
} else {
this.walk(value)
}
}
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 源码如下:
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: Function
) {
const dep = new Dep()
const property = Object.getOwnPropertyDescriptor(obj, key)
if (property && property.configurable === false) {
return
}
const getter = property && property.get
const setter = property && property.set
let childOb = observe(val)
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) {
const value = getter ? getter.call(obj) : val
if (newVal === value) {
return
}
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
if (setter) {
setter.call(obj, newVal)
} else {
val = 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 的源码。
export default class Dep {
static target: ?Watcher;
id: number;
subs: Array<Watcher>;
constructor () {
this.id = uid++
this.subs = []
}
addSub (sub: Watcher) {
this.subs.push(sub)
}
removeSub (sub: Watcher) {
remove(this.subs, sub)
}
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
notify () {
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
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 Observer 和 class 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 这个属性的是函数pushTarget 。pushTarget 函数就在 class Watcher 中调用了。class Watcher 的源码如下:
export default class Watcher {
vm: Component;
expression: string;
cb: Function;
id: number;
deep: boolean;
user: boolean;
lazy: boolean;
sync: boolean;
dirty: boolean;
active: boolean;
deps: Array<Dep>;
newDeps: Array<Dep>;
depIds: Set;
newDepIds: Set;
getter: Function;
value: any;
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: Object = {}
) {
this.vm = vm
vm._watchers.push(this)
// options
this.deep = !!options.deep
this.user = !!options.user
this.lazy = !!options.lazy
this.sync = !!options.sync
this.expression = expOrFn.toString()
this.cb = cb
this.id = ++uid // uid for batching
this.active = true
this.dirty = this.lazy // for lazy watchers
this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
if (!this.getter) {
this.getter = function () {}
process.env.NODE_ENV !== 'production' && warn(
`Failed watching path: "${expOrFn}" ` +
'Watcher only accepts simple dot-delimited paths. ' +
'For full control, use a function instead.',
vm
)
}
}
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 Observer、class Dep 和 class Watch 三个类的关系就清楚了。这也是 Vue 中实现数据绑定用到的观察者模式的体现。
Virtual-dom
React 的大热,是因为其带来了 ‘Virtual-dom’ 和数据驱动视图的理念(尽管很多人觉得后者更重要)。这里并不想比较 ‘Virtual-dom’ 和原生的 DOM 操作谁快谁慢的问题(事实上在 dom 结构改动很多的情况下,原生 DOM 操作比较快。。。),仅仅是理解一下 Virtual-dom
。
‘Virtual-dom’是一系列的模块集合,用来提供声明式的DOM渲染。来看一个简单的 DOM 片段
<div id="parent">
<span class="child">item1</span>
<span class="child">item2</span>
<span class="child">item3</span>
</div>
对 DOM 片段结构抽象一下:一个根节点 div ,三个元素子节点 span (对应的内部文本节点)。然后用 JavaScript 对象表示:
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结构树’相对’老HTML结构树’的差异应用到’老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