建议PC端观看,移动端代码高亮错乱
这节来看 Vue 对用户手动添加的 watch 如何进行数据拦截。
1. 初始化过程
侦听属性的初始化也是发生在 Vue 的实例初始化阶段的 initState 函数中,在 computed 初始化之后:
// src/core/instance/state.js
export function initState (vm: Component) {
// 简化后的...
if (opts.watch) initWatch(vm, opts.watch)
}
来看一下 initWatch 的实现,它的定义在 src/core/instance/state.js 中:
// src/core/instance/state.js
function initWatch (vm: Component, watch: Object) {
for (const key in watch) {
const handler = watch[key]
if (Array.isArray(handler)) {
for (let i = 0; i < handler.length; i++) {
createWatcher(vm, key, handler[i])
}
} else {
createWatcher(vm, key, handler)
}
}
}
这里就是对 watch 对象做遍历,拿到每一个 handler,因为 Vue 是支持 watch 的同一个 key 对应多个 handler,所以如果 handler 是一个数组,则遍历这个数组,调用 createWatcher 方法,否则直接调用 createWatcher:
// src/core/instance/state.js
function createWatcher (
vm: Component,
expOrFn: string | Function,
handler: any,
options?: Object
) {
if (isPlainObject(handler)) {
options = handler
handler = handler.handler
}
if (typeof handler === 'string') {
handler = vm[handler]
}
return vm.$watch(expOrFn, handler, options)
}
这里的逻辑也很简单,首先对 hanlder 的类型做判断,拿到它最终的回调函数。
最后调用 vm.$watch(keyOrFn, handler, options) 函数,$watch 是 Vue 原型上的方法,它是在执行 stateMixin 的时候定义的:
// src/core/instance/state.js
export function stateMixin (Vue: Class<Component>) {
// ...
Vue.prototype.$watch = function (
expOrFn: string | Function,
cb: any,
options?: Object
): Function {
const vm: Component = this
if (isPlainObject(cb)) {
return createWatcher(vm, expOrFn, cb, options)
}
options = options || {}
options.user = true // user watcher
const watcher = new Watcher(vm, expOrFn, cb, options)
if (options.immediate) {
try {
cb.call(vm, watcher.value)
} catch (error) {
handleError(error, vm, `callback for immediate watcher "${watcher.expression}"`)
}
}
return function unwatchFn () {
watcher.teardown()
}
}
}
也就是说,侦听属性 watch 最终会调用 $watch 方法,
- 判断
cb如果是一个对象,则调用createWatcher方法,这是因为$watch方法是用户可以直接调用的,它可以传递一个对象,也可以传递函数。 - 接着执行
const watcher = new Watcher(vm, expOrFn, cb, options)实例化了一个user watcher,其中会进行依赖收集,稍后再展开分析。 - 如果我们设置了
immediate为true,则直接会执行回调函数cb。 - 最后返回了一个
unwatchFn方法,它会调用teardown方法去移除这个watcher。
接下来看看当实例化 user watcher 是怎样进行依赖收集的。
2. 依赖收集
下面我们结合一个例子来看看依赖收集和派发更新的流程:
var vm = new Vue({
el: '#app',
data() {
return {
obj: {
msg: 'hello'
}
}
},
watch: {
'obj.msg': function() {}
}
})
根据上面的分析,当执行到 $watch 中的 const watcher = new Watcher(vm, expOrFn, cb, options) 逻辑时:
export default class Watcher {
// ...
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: ?Object,
isRenderWatcher?: boolean
) {
// ...
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
if (!this.getter) {
this.getter = noop
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()
}
}
new watcher 这部分已经相当熟悉了,重点关注一下对 expOrFn 的处理:
在本例中我们的 expOrFn 是 'obj.msg',因此会调用 parsePath 方法并将返回值作为 this.getter。
// src/core/util/lang.js
/**
* unicode letters used for parsing html tags, component names and property paths.
* 用于解析html标签,组件名称和属性路径的unicode字母。
* using https://www.w3.org/TR/html53/semantics-scripting.html#potentialcustomelementname
* skipping \u10000-\uEFFFF due to it freezing up PhantomJS
*/
export const unicodeRegExp = /a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/
const bailRE = new RegExp(`[^${unicodeRegExp.source}.$_\\d]`)
export function parsePath (path: string): any {
if (bailRE.test(path)) {
return
}
const segments = path.split('.')
return function (obj) {
for (let i = 0; i < segments.length; i++) {
if (!obj) return
obj = obj[segments[i]]
}
return obj
}
}
- 首先对
path进行合法性校验,如果不合法则返回undefined - 将
path按.分割,比如本例中obj.msg将得到['obj', 'msg'] - 返回一个函数,这个函数会保存为
this.getter
在实例化 user watcher 的最后调用 this.get() 进行求值时,Dep.target 是当前的 user watcher,然后又执行了 this.getter.call(vm, vm),在这里函数里将遍历['obj', 'msg'],依次访问:
vm.obj,这会触发obj.__ob__.dep和obj dep两个dep的依赖收集。obj.msg,这会触发msg dep的依赖收集
因此对于 user watcher 来说,其 deps 中也保存了三者的 dep

2.1 deep options
如果我们想对一下对象做深度观测的时候,需要设置 deep 这个属性为 true。
这样就创建了一个 deep watcher 了,在 watcher 执行 get 求值的过程中有一段逻辑:
get() {
let value = this.getter.call(vm, vm)
// ...
if (this.deep) {
traverse(value)
}
}
在对 watch 的表达式求值后,会调用 traverse 函数,它的定义在 src/core/observer/traverse.js 中:
// src/core/observer/traverse.js
const seenObjects = new Set()
export function traverse (val: any) {
_traverse(val, seenObjects)
seenObjects.clear()
}
function _traverse (val: any, seen: SimpleSet) {
let i, keys
const isA = Array.isArray(val)
if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) {
return
}
if (val.__ob__) {
const depId = val.__ob__.dep.id
if (seen.has(depId)) {
return
}
seen.add(depId)
}
if (isA) {
i = val.length
while (i--) _traverse(val[i], seen)
} else {
keys = Object.keys(val)
i = keys.length
while (i--) _traverse(val[keys[i]], seen)
}
}
traverse 的逻辑也很简单,它实际上就是对一个对象做深层递归遍历,因为遍历过程中就是对一个子对象的访问,会触发它们的 getter 过程,这样就可以收集到依赖,也就是订阅它们变化的 watcher,这个函数实现还有一个小的优化,遍历过程中会把子响应式对象通过它们的 dep id 记录到 seenObjects,避免以后重复访问。
那么在执行了 traverse 后,我们再对 watch 的对象内部任何一个值做修改,也会调用 watcher 的回调函数了。
3. 派发更新
在这个例子中,当我们改变 vm.watcher 或者 vm.watcher.msg 的时候,都会触发相应的 setter,最后会执行 watcher.run
run () {
if (this.active) {
const value = this.get()
if (
value !== this.value ||
// Deep watchers and watchers on Object/Arrays should fire even
// when the value is the same, because the value may
// have mutated.
isObject(value) ||
this.deep
) {
const oldValue = this.value
this.value = value
// user watcher
if (this.user) {
try {
this.cb.call(this.vm, value, oldValue)
} catch (e) {
handleError(e, this.vm, `callback for watcher "${this.expression}"`)
}
} else {
this.cb.call(this.vm, value, oldValue)
}
}
}
}
以下三种情况需要调用监听回调函数:
- 表达式的返回值
value改变了(原始值,或者是对象的引用) - 表达式的返回值
value是对象或数组(即使value的引用没改变) - 深度监听的,不管最终的返回值是否改变,都要执行回调。因为
watcher依赖的dep的子孙属性改变了
现在了解了源码后,再看官网的这么一个 Tips:

结合如下例子分析一下:
var vm = new Vue({
el: '#app',
data() {
return {
obj: {
msg: 'hello'
}
}
},
watch: {
'obj': {
handler: function(val, oldVal) {
// 当修改 obj.msg 时,val 和 oldVal 将会相同
},
deep: true
}
}
})
vm.obj.msg = 'vue'
当修改 vm.obj.msg 时会触发 msg dep,最终执行 watcher.run,然后执行 this.cb.call(this.vm, value, oldValue) 调用回调函数。
但是此时新旧值都是相同的引用,现在是不是对官网给出的 Tips 有一种恍然大悟的感觉了~
总结
通过这两章的分析我们对计算属性和侦听属性的实现有了深入的了解,计算属性本质上是 computed watcher,而侦听属性本质上是 user watcher。
就应用场景而言,计算属性适合用在模板渲染中,某个值是依赖了其它的响应式对象甚至是计算属性计算而来;而侦听属性适用于观测某个值的变化去完成一段复杂的业务逻辑。