Vue.js最显著的功能就是响应式系统,它是一个典型的MVVM框架,模型(Model)只是普通的JavaScript对象,修改它则视图(View)会自动更新。这种设计让状态管理变得非常简单而直观,不过理解它的原理也很重要,可以避免一些常见问题。下面让我们深挖Vue.js响应式系统的细节,来看一看Vue.js是如何把模型和视图建立起关联关系的。
如何追踪变化我们先来看一个简单的例子。代码示例如下:

运行后,我们可以从页面中看到,count后面的times每隔1s递增1,视图一直在更新。在代码中仅仅是通过setInterval方法每隔1s来修改vm.times的值,并没有任何DOM操作。那么Vue.js是如何实现这个过程的呢?我们可以通过一张图来看一下,如下图所示。

模型和视图关联关系图
图中的模型(Model)就是data方法返回的{times:1},视图(View)是最终在浏览器中显示的DOM。模型通过Observer、Dep、Watcher、Directive等一系列对象的关联,最终和视图建立起关系。归纳起来,Vue.js在这里主要做了三件事:
通过Observer对data做监听,并且提供了订阅某个数据项变化的能力。
把template编译成一段document fragment,然后解析其中的Directive,得到每一个Directive所依赖的数据项和update方法。
通过Watcher把上述两部分结合起来,即把Directive中的数据依赖通过Watcher订阅在对应数据的Observer的Dep上。当数据变化时,就会触发Observer的Dep上的notify方法通知对应的Watcher的update,进而触发Directive的update方法来更新DOM视图,最后达到模型和视图关联起来。
接下来我们就结合Vue.js的源码来详细介绍这三个过程。
Observer首先来看一下Vue.js是如何给data对象添加Observer的。我们知道,Vue实例创建的过程会有一个生命周期,其中有一个过程就是调用vm.initData方法处理data选项。initData方法的源码定义如下:

在initData中我们要特别注意proxy方法,它的功能就是遍历data的key,把data上的属性代理到vm实例上。_proxy方法的源码定义如下:

proxy方法主要通过Object.defineProperty的getter和setter方法实现了代理。在前面的例子中,我们调用vm.times就相当于访问了vm.data.times。
在_initData方法的最后,我们调用了observe(data, this)方法来对data做监听。observe方法的源码定义如下:

observe方法首先判断value是否已经添加了ob属性,它是一个Observer对象的实例。如果是就直接用,否则在value满足一些条件(数组或对象、可扩展、非vue组件等)的情况下创建一个Observer对象。接下来我们看一下Observer这个类,它的源码定义如下:

Observer类的构造函数主要做了这么几件事:首先创建了一个Dep对象实例(关于Dep对象我们稍后作介绍);然后把自身this添加到value的ob属性上;最后对value的类型进行判断,如果是数组则观察数组,否则观察单个元素。其实observeArray方法就是对数组进行遍历,递归调用observe方法,最终都会调用walk方法观察单个元素。接下来我们看一下walk方法,它的源码定义如下:

walk方法是对obj的key进行遍历,依次调用convert方法,对obj的每一个属性进行转换,让它们拥有getter、setter方法。只有当obj是一个对象时,这个方法才能被调用。接下来我们看一下convert方法,它的源码定义如下:

convert方法很简单,它调用了defineReactive方法。这里this.value就是要观察的data对象,key是data对象的某个属性,val则是这个属性的值。defineReactive的功能是把要观察的data对象的每个属性都赋予getter和setter方法。这样一旦属性被访问或者更新,我们就可以追踪到这些变化。接下来我们看一下defineReactive方法,它的源码定义如下:
export function defineReactive (obj, key, val) {
var dep = new Dep()
var property = Object.getOwnPropertyDescriptor(obj, key)
if (property && property.configurable === false) {
return
}
// cater for pre-defined getter/setters
var getter = property && property.get
var setter = property && property.set
var childOb = observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
var value = getter ? getter.call(obj) : val if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
}
if (isArray(value)) {
for (var e, i = 0, l = value.length; i < l; i++) {
e = value[i]
e && e.__ob__ && e.__ob__.dep.depend()
}
}
}
return value },
set: function reactiveSetter (newVal) {
var value = getter ? getter.call(obj) : val if (newVal === value) {
return
}
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal }
childOb = observe(newVal)
dep.notify()
}
})}
defineReactive方法最核心的部分就是通过调用Object.defineProperty给data的每个属性添加getter和setter方法。当data的某个属性被访问时,则会调用getter方法,判断当Dep.target不为空时调用dep.depend和childObj.dep.depend方法做依赖收集。如果访问的属性是一个数组,则会遍历这个数组收集数组元素的依赖。当改变data的属性时,则会调用setter方法,这时调用dep.notify方法进行通知。这里我们提到了dep,它是Dep对象的实例。接下来我们看一下Dep这个类,它的源码定义如下:

Dep类是一个简单的观察者模式的实现。它的构造函数非常简单,初始化了id和subs。其中subs用来存储所有订阅它的Watcher,Watcher的实现稍后我们会介绍。Dep.target表示当前正在计算的Watcher,它是全局唯一的,因为在同一时间只能有一个Watcher被计算。
前面提到了在getter和setter方法调用时会分别调用dep.depend方法和dep.notify方法,接下来依次介绍这两个方法。depend方法的源码定义如下:

depend方法很简单,它通过Dep.target.addDep(this)方法把当前Dep的实例添加到当前正在计算的Watcher的依赖中。接下来我们看一下notify方法,它的源码定义如下:

notify方法也很简单,它遍历了所有的订阅Watcher,调用它们的update方法。
至此,vm实例中给data对象添加Observer的过程就结束了。接下来我们看一下Vue.js是如何进行指令解析的。
DirectiveVue指令类型很多,限于篇幅,我们不会把所有指令的解析过程都介绍一遍,这里结合前面的例子只介绍v-text指令的解析过程,其他指令的解析过程也大同小异。
前面我们提到了Vue实例创建的生命周期,在给data添加Observer之后,有一个过程是调用vm.compile方法对模板进行编译。compile方法的源码定义如下:
Vue.prototype._compile = function (el) {
var options = this.$options // transclude and init element
// transclude can potentially replace original
// so we need to keep reference; this step also injects
// the template and caches the original attributes
// on the container node and replacer node.
var original = el
el = transclude(el, options)
this._initElement(el)
// handle v-pre on root node (#2026)
if (el.nodeType === 1 && getAttr(el, 'v-pre') !== null) {
return
}
// root is always compiled per-instance, because
// container attrs and props can be different every time.
var contextOptions = this._context && this._context.$options var rootLinker = compileRoot(el, options, contextOptions)
// resolve slot distribution
resolveSlots(this, options._content)
// compile and link the rest
var contentLinkFn var ctor = this.constructor // component compilation can be cached
// as long as it's not using inline-template
if (options._linkerCachable) {
contentLinkFn = ctor.linker if (!contentLinkFn) {
contentLinkFn = ctor.linker = compile(el, options)
}
}
// link phase
// make sure to link root with prop scope!
var rootUnlinkFn = rootLinker(this, el, this._scope)
var contentUnlinkFn = contentLinkFn ? contentLinkFn(this, el)
: compile(el, options)(this, el)
// register composite unlink function
// to be called during instance destruction
this._unlinkFn = function () {
rootUnlinkFn()
// passing destroying: true to avoid searching and
// splicing the directives
contentUnlinkFn(true)
}
// finally replace original
if (options.replace) {
replace(original, el)
}
this._isCompiled = true
this._callHook('compiled')
}
我们可以通过下图来看一下这个方法编译的主要流程。

vm._compile编译主要流程图
这个过程通过el = transclude(el, option)方法把template编译成一段document fragment,拿到el对象。而指令解析部分就是通过compile(el, options)方法实现的。接下来我们看一下compile方法的实现,它的源码定义如下:
export function compile (el, options, partial) {
// link function for the node itself.
var nodeLinkFn = partial || !options._asComponent ? compileNode(el, options)
: null
// link function for the childNodes
var childLinkFn =
!(nodeLinkFn && nodeLinkFn.terminal) &&
!isScript(el) &&
el.hasChildNodes()
? compileNodeList(el.childNodes, options)
: null
/**
* A composite linker function to be called on a already
* compiled piece of DOM, which instantiates all directive
* instances.
*
* @param {Vue} vm
* @param {Element|DocumentFragment} el
* @param {Vue} [host] - host vm of transcluded content
* @param {Object} [scope] - v-for scope
* @param {Fragment} [frag] - link context fragment
* @return {Function|undefined}
*/
return function compositeLinkFn (vm, el, host, scope, frag) {
// cache childNodes before linking parent, fix #657
var childNodes = toArray(el.childNodes)
// link
var dirs = linkAndCapture(function compositeLinkCapturer () {
if (nodeLinkFn) nodeLinkFn(vm, el, host, scope, frag)
if (childLinkFn) childLinkFn(vm, childNodes, host, scope, frag)
}, vm)
return makeUnlinkFn(vm, dirs)
}}
compile方法主要通过compileNode(el, options)方法完成节点的解析,如果节点拥有子节点,则调用compileNodeList(el.childNodes, options)方法完成子节点的解析。compileNodeList方法其实就是遍历子节点,递归调用compileNode方法。因为DOM元素本身就是树结构,这种递归方法也就是常见的树的深度遍历方法,这样就可以完成整个DOM树节点的解析。接下来我们看一下compileNode方法的实现,它的源码定义如下:

compileNode方法对节点的nodeType做判断,如果是一个非script普通的元素(div、p等);则调用compileElement(node, options)方法解析;如果是一个非空的文本节点,则调用compileTextNode(node, options)方法解析。我们在前面的例子中解析的是非空文本节点count: {{times}},这实际上是v-text指令,它的解析是通过compileTextNode方法实现的。接下来我们看一下compileTextNode方法,它的源码定义如下:

compileTextNode方法首先调用了parseText方法对node.wholeText做解析。主要通过正则表达式解析count: {{times}}部分,我们看一下解析结果,如下图所示。

parseText解析文本节点结果
解析后的tokens是一个数组,数组的每个元素则是一个Object。如果是count: 这样的普通文本,则返回的对象只有value字段;如果是{{times}}这样的插值,则返回的对象包含html、onTime、tag、value等字段。
接下来创建document fragment,遍历tokens创建DOM节点插入到这个fragment中。在遍历过程中,如果token无tag字段,则调用document.createTextNode(token.value)方法创建DOM节点;否则调用processTextToken(token, options)方法创建DOM节点和扩展token对象。我们看一下调用后的结果,如下图所示。

processTextToken解析文本节点结果
可以看到,token字段多了一个descriptor属性。这个属性包含了几个字段,其中def表示指令相关操作的对象,expression为解析后的表达式,filters为过滤器,name为指令的名称。
在compileTextNode方法的最后,调用makeTextNodeLinkFn(tokens, frag, options)并返回该方法执行的结果。接下来我们看一下makeTextNodeLinkFn方法,它的源码定义如下:

makeTextNodeLinkFn这个方法什么也没做,它仅仅是返回了一个新的方法textNodeLinkFn。往前回溯,这个方法最终作为compileNode的返回值,被添加到compile方法生成的childLinkFn中。
我们回到compile方法,在compile方法的最后有这样一段代码:

compile方法返回了compositeLinkFn,它在Vue.prototype._compile方法执行时,是通过compile(el, options)(this, el)调用的。compositeLinkFn方法执行了linkAndCapture方法,它的功能是通过调用compile过程中生成的link方法创建指令对象,再对指令对象做一些绑定操作。linkAndCapture方法的源码定义如下:

linkAndCapture方法首先调用了linker方法,它会遍历compile过程中生成的所有linkFn并调用,本例中会调用到之前定义的textNodeLinkFn。这个方法会遍历tokens,判断如果token的tag属性值为true且oneTime属性值为false,则调用vm.bindDir(token.descriptor, node, host, scope)方法创建指令对象。vm.bindDir方法的源码定义如下:

Vue.prototype.bindDir方法就是根据descriptor实例化不同的Directive对象,并添加到vm实例的directives数组中的。到这一步,Vue.js从解析模板到生成Directive对象的步骤就完成了。接下来回到linkAndCapture方法,它对创建好的directives进行排序,然后遍历directives调用dirs[i].bind方法对单个directive做一些绑定操作。dirs[i]._bind方法的源码定义如下:
Directive.prototype._bind = function () {
var name = this.name var descriptor = this.descriptor // remove attribute
if (
(name !== 'cloak' || this.vm._isCompiled) &&
this.el && this.el.removeAttribute ) {
var attr = descriptor.attr || ('v-' + name)
this.el.removeAttribute(attr)
}
// copy def properties
var def = descriptor.def if (typeof def === 'function') {
this.update = def } else {
extend(this, def)
}
// setup directive params
this._setupParams()
// initial bind
if (this.bind) {
this.bind()
}
this._bound = true
if (this.literal) {
this.update && this.update(descriptor.raw)
} else if (
(this.expression || this.modifiers) &&
(this.update || this.twoWay) &&
!this._checkStatement()
) {
// wrapped updater for context
var dir = this
if (this.update) {
this._update = function (val, oldVal) {
if (!dir._locked) {
dir.update(val, oldVal)
}
}
} else {
this._update = noop }
var preProcess = this._preProcess ? bind(this._preProcess, this)
: null
var postProcess = this._postProcess ? bind(this._postProcess, this)
: null
var watcher = this._watcher = new Watcher(
this.vm,
this.expression,
this._update, // callback
{
filters: this.filters,
twoWay: this.twoWay,
deep: this.deep,
preProcess: preProcess,
postProcess: postProcess,
scope: this._scope }
)
// v-model with inital inline value need to sync back to
// model instead of update to DOM on init. They would
// set the afterBind hook to indicate that.
if (this.afterBind) {
this.afterBind()
} else if (this.update) {
this.update(watcher.value)
}
}}
Directive.prototype._bind方法的主要功能就是做一些指令的初始化操作,如混合def属性。def是通过this.descriptor.def获得的,this.descriptor是对指令进行相关描述的对象,而this.descriptor.def则是包含指令相关操作的对象。比如对于v-text指令,我们可以看一下它的相关操作,源码定义如下:

v-text的def包含了bind和update方法,Directive在初始化时通过extend(this, def)方法可以对实例扩展这两个方法。Directive在初始化时还定义了this.update方法,并创建了Watcher,把this.update方法作为Watcher的回调函数。这里把Directive和Watcher做了关联,当Watcher观察到指令表达式值变化时,会调用Directive实例的_update方法,最终调用v-text的update方法更新DOM节点。
至此,vm实例中编译模板、解析指令、绑定Watcher的过程就结束了。接下来我们看一下Watcher的实现,了解Directive和Observer之间是如何通过Watcher关联的。
Watcher我们先来看一下Watcher类的实现,它的源码定义如下:

Directive实例在初始化Watcher时,会传入指令的expression。Watcher构造函数会通过parseExpression(expOrFn, this.twoWay)方法对expression做进一步的解析。在前面的例子中,expression是times,passExpression方法的功能是把expression转换成一个对象,如下图所示。

passExpression执行结果
可以看到res有两个属性,其中exp为表达式字符串;get是通过new Function生成的匿名方法,可以把它打印出来,如下图所示。

res.get方法打印结果
可以看到res.get方法很简单,它接受传入一个scope变量,返回scope.times。对于传入的scope值,稍后我们会进行介绍。在Watcher构造函数的最后调用了this.get方法,它的源码定义如下:

Watcher.prototype.get方法的功能就是对当前Watcher进行求值,收集依赖关系。它首先执行this.beforeGet方法,源码定义如下:

Watcher.prototype.beforeGet很简单,设置Dep.target为当前Watcher实例,为接下来的依赖收集做准备。我们回到get方法,接下来执行this.getter.call(scope, scope)方法,这里的scope是this.vm,也就是当前Vue实例。这个方法实际上相当于获取vm.times,这样就触发了对象的getter。在20.1.1节我们给data添加Observer时,通过Object.defineProperty给data对象的每一个属性添加getter和setter。回顾一下代码:

当获取vm.times时,会执行到get方法体内。由于我们在之前已经设置了Dep.target为当前Watcher实例,所以接下来就调用dep.depend()方法完成依赖收集。它实际上是执行了Dep.target.addDep(this),相当于执行了Watcher实例的addDep方法,把Dep实例添加到Watcher实例的依赖中。addDep方法的源码定义如下:

Watcher.prototype.addDep方法就是把dep添加到Watcher实例的依赖中,同时又通过dep.addSub(this)把Watcher实例添加到dep的订阅者中。addSub方法的源码定义如下:

至此,指令完成了依赖收集,并且通过Watcher完成了对数据变化的订阅。
接下来我们看一下,当data发生变化时,视图是如何自动更新的。在前面的例子中,我们通过setInterval每隔1s执行一次vm.times++,数据改变会触发对象的setter,执行set方法体的代码。回顾一下代码:

这里会调用dep.notify()方法,它会遍历所有的订阅者,也就是Watcher实例。然后调用Watcher实例的update方法,源码定义如下:

Watcher.prototype.update方法在满足某些条件下会直接调用this.run方法。在多数情况下会调用pushWatcher(this)方法把Watcher实例推入队列中,延迟this.run调用的时机。pushWatcher方法的源码定义如下:

pushWatcher方法把Watcher推入队列中,通过nextTick方法在下一个事件循环周期处理Watcher队列,这是Vue.js的一种性能优化手段。因为如果同时观察的数据多次变化,比如同步执行3次vm.time++,同步调用watcher.run就会触发3次DOM操作。而推入队列中等待下一个事件循环周期再操作队列里的Watcher,因为是同一个Watcher,它只会调用一次watcher.run,从而只触发一次DOM操作。接下来我们看一下flushBatcherQueue方法,它的源码定义如下:

flushBatcherQueue方法通过调用runBatcherQueue来run Watcher。这里我们看到Watcher队列分为内部queue和userQueue,其中userQueue是通过$watch()方法注册的Watcher。我们优先run内部queue来保证指令和DOM节点优先更新,这样当用户自定义的Watcher的回调函数触发时DOM已更新完毕。接下来我们看一下runBatcherQueue方法,它的源码定义如下:

runBatcherQueued的功能就是遍历queue中Watcher的run方法。接下来我们看一下Watcher的run方法,它的源码定义如下:

Watcher.prototype.run方法再次对Watcher求值,重新收集依赖。接下来判断求值结果和之前value的关系。如果不变则什么也不做,如果变了则调用this.cb.call(this.vm, value, oldValue)方法。这个方法是Directive实例创建Watcher时传入的,它对应相关指令的update方法来真实更新DOM。这样就完成了数据更新到对应视图的变化过程。 Watcher巧妙地把Observer和Directive关联起来,实现了数据一旦更新,视图就会自动变化的效果。尽管Vue.js利用Object.defineProperty这个核心技术实现了数据和视图的绑定,但仍然会存在一些数据变化检测不到的问题,接下来我们看一下这部分内容。
书籍介绍
本文节选自滴滴公共前端团队张耀春、黄轶、王静、苏伟等著的《Vue.js权威指南》一书。
Vue.js是一个用来开发Web界面的前端库。本书致力于普及国内Vue.js技术体系,让更多喜欢前端的人员了解和学习Vue.js。如果你对Vue.js基础知识感兴趣,如果你对源码解析感兴趣,如果你对Vue.js 2.0感兴趣,如果你对主流打包工具感兴趣,如果你对如何实践感兴趣,本书都是一本不容错过的以示例代码为引导、知识涵盖全面的最佳选择。全书一共30章,由浅入深地讲解了Vue.js基本语法及源码解析。主要内容包括数据绑定、指令、表单控件绑定、过滤器、组件、表单验证、服务通信、路由和视图、vue-cli、测试开发和调试、源码解析及主流打包构建工具等。该书内容全面,讲解细致,示例丰富,适用于各层次的开发者。