vue在渲染的时候有一个依赖收集的过程,data属性对应的dep,会收集watcher,同时watcher也会收集dep。dep收集watcher的原因是为了属性值更改的时候,通过dep通知watcher更新,这一点很多文章已有详细分析,但是几乎没有文章详细分析watcher为何反向收集dep,所以此篇文章我将结合vue源码与实际业务代码案例,详细分析原因。如有错误,欢迎指正。
首先,我们先简单回顾一下vue初始化过程,看看vue是如何进行依赖收集
先忽略其他与此次主题无关的逻辑,new Vue干了两件事:
// new vue的时候仅仅调用init init方法是在initMixin挂在到Vue原型上
function Vue (options) {
// ....
this._init(options)
}
function initMixin (Vue: Class<Component>) {
Vue.prototype._init = function (options?: Object) {
const vm: Component = this
// 合并options
if (options && options._isComponent) {
initInternalComponent(vm, options)
} else {
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
// initState中对我们传入对data进行了数据劫持
initState(vm)
// 调用$mount开始挂载
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
}
复制代码
initState主要是对我们传入的options进行了初始化,这里我们先关注initData,即初始化我们传入的data
function initState (vm: Component) {
vm._watchers = []
const opts = vm.$options
if (opts.props) initProps(vm, opts.props)
if (opts.methods) initMethods(vm, opts.methods)
if (opts.data) {
initData(vm)
} else {
observe(vm._data = {}, true /* asRootData */)
}
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}
复制代码
initData主要干了两件事:
function initData (vm: Component) {
let data = vm.$options.data
data = vm._data = typeof data === 'function'
? getData(data, vm)
: data || {}
// ...
const keys = Object.keys(data)
const props = vm.$options.props
const methods = vm.$options.methods
let i = keys.length
while (i--) {
// key重复校验 如methods中的key不能和data中的key重复,因为两者最终都会被代理this上
const key = keys[i]
if (process.env.NODE_ENV !== 'production') {
if (methods && hasOwn(methods, key)) {
warn(
`Method "${key}" has already been defined as a data property.`,
vm
)
}
}
if (props && hasOwn(props, key)) {
process.env.NODE_ENV !== 'production' && warn(
`The data property "${key}" is already declared as a prop. ` +
`Use prop default value instead.`,
vm
)
} else if (!isReserved(key)) {
// this._data.xxx变this.xxx
proxy(vm, `_data`, key)
}
}
// 数据变响应式
observe(data, true /* asRootData */)
}
复制代码
数据劫持核心方法defineReactive,在$mount调用后,生成vnode的时候触发get的时候调用dep.depend()进行依赖收集。
function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
const dep = new Dep()
const property = Object.getOwnPropertyDescriptor(obj, key)
if (property && property.configurable === false) {
return
}
// cater for pre-defined getter/setters
const getter = property && property.get
const setter = property && property.set
if ((!getter || setter) && arguments.length === 2) {
val = obj[key]
}
let childOb = !shallow && 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
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
/* eslint-enable no-self-compare */
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
// #7981: for accessor properties without setter
if (getter && !setter) return
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
childOb = !shallow && observe(newVal)
dep.notify()
}
})
}
复制代码
重点:dep.depend()中watcher收集了dep
//dep.js
// 这里Dep.target指向的是wather 原因稍后分析
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
//watcher.js
addDep (dep: Dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
// watcher的dep对象收集了dep
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
// dep中收集了watcher
dep.addSub(this)
}
}
}
复制代码
vue中的watcher大体可以分成三类:
在以下三种方法被调用时,Dep.target会指向其对应的watcher:
//$mount调用的实际是mountComponent方法
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
//mountComponent方法
function mountComponent(
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el;
// ...
callHook(vm, "beforeMount");
let updateComponent;
// ...
updateComponent = () => {
vm._update(vm._render(), hydrating);
};
//
new Watcher(
vm,
updateComponent,
noop,
{
before() {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, "beforeUpdate");
}
},
},
true /* isRenderWatcher */
);
hydrating = false;
// manually mounted instance, call mounted on self
// mounted is called for render-created child components in its inserted hook
if (vm.$vnode == null) {
vm._isMounted = true;
callHook(vm, "mounted");
}
return vm;
}
复制代码
在mountComponent方法中,创建了watcher实例,最后一个参数为true,表明这是一个渲染watcher。
渲染watcher lazy为false,所以调用get方法,get方法调用的getter 就是我们传入的mountComponent。
export default class Watcher {
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: ?Object,
isRenderWatcher?: boolean
) {
this.vm = vm
// ...
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
if (!this.getter) {
this.getter = noop
}
}
this.value = this.lazy
? undefined
: this.get()
}
get () {
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (e) {
if (this.user) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
} else {
throw e
}
} finally {
// "touch" every property so they are all tracked as
// dependencies for deep watching
if (this.deep) {
traverse(value)
}
popTarget()
this.cleanupDeps()
}
return value
}
}
复制代码
我们看看pushTarget,pushTarget的逻辑很简单,把当前watcher入栈,并把Dep.target指向watcher。
Dep.target = null
const targetStack = []
function pushTarget (target: ?Watcher) {
targetStack.push(target)
Dep.target = target
}
复制代码
所以这时候Dep.target为true,并且updateComponent中调用_render生成vnode的时候,会访问到data的数据。此时,被访问到的data收集了该渲染Watcher,渲染Watch也收集了data的dep。
updateComponent = () => {
vm._update(vm._render(), hydrating);
};
复制代码
const computedWatcherOptions = { lazy: true }
function initComputed (vm: Component, computed: Object) {
const watchers = vm._computedWatchers = Object.create(null)
const isSSR = isServerRendering()
for (const key in computed) {
const userDef = computed[key]
const getter = typeof userDef === 'function' ? userDef : userDef.get
// ...
if (!isSSR) {
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
computedWatcherOptions
)
}
// ...
if (!(key in vm)) {
defineComputed(vm, key, userDef)
}
}
}
复制代码
在initComputed中,为每个计算属性创建了Watcher,并传入了computedWatcherOptions表明这是一个计算watcher,并调用了defineComputed
defineComputed主要是为了数据劫持,当我们访问this.xxx计算属性的时候,实际上访问的是
sharedPropertyDefinition.get指向的createComputedGetter方法
export function defineComputed (
target: any,
key: string,
userDef: Object | Function
) {
const shouldCache = !isServerRendering()
if (typeof userDef === 'function') {
sharedPropertyDefinition.get = shouldCache
? createComputedGetter(key)
: createGetterInvoker(userDef)
sharedPropertyDefinition.set = noop
} else {
sharedPropertyDefinition.get = userDef.get
? shouldCache && userDef.cache !== false
? createComputedGetter(key)
: createGetterInvoker(userDef.get)
: noop
sharedPropertyDefinition.set = userDef.set || noop
}
if (process.env.NODE_ENV !== 'production' &&
sharedPropertyDefinition.set === noop) {
sharedPropertyDefinition.set = function () {
warn(
`Computed property "${key}" was assigned to but it has no setter.`,
this
)
}
}
Object.defineProperty(target, key, sharedPropertyDefinition)
}
复制代码
createComputedGetter方法也很简单,取出该计算属性对应的watcher,dirty默认为true,我们先看watcher.evaluate()
function createComputedGetter (key) {
return function computedGetter () {
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
if (watcher.dirty) {
watcher.evaluate()
}
if (Dep.target) {
watcher.depend()
}
return watcher.value
}
}
}
复制代码
evaluate就是调用get方法,根据上面$mount方法调用的分析,get方法执行的时候,会把Dep.target指向当前的计算Watcher,并调用计算属性对应的方法(也就是cValue),在这个过程会访问到data的数据(在这里就是value)。此时,computed依赖的data收集了该计算Watcher,计算Watch也收集了所依赖data的dep。
// 示例
computed:{
cValue(){
return this.value
}
}
复制代码
evaluate () {
this.value = this.get()
this.dirty = false
}
复制代码
initWatch最终调用的是$watch方法
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);
}
}
}
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);
}
复制代码
$watch中我们看new watcher的逻辑
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 || {};
// 该参数表明这是watchWatcher
options.user = true;
const watcher = new Watcher(vm, expOrFn, cb, options);
if (options.immediate) {
const info = `callback for immediate watcher "${watcher.expression}"`;
pushTarget();
invokeWithErrorHandling(cb, vm, [watcher.value], vm, info);
popTarget();
}
return function unwatchFn() {
watcher.teardown();
};
};
复制代码
// 从这里可以看到 watcher这次接收的expOrFn不是func
createWatcher(vm, key, handler);
// Wacther类中的构造函数 所以这是会走parsePath逻辑
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
}
复制代码
parsePath返回了一个函数,这个函数在Watcher的get方法中调用,会传入vue实例,所以也就访问到了data属性 ,这一过程watchWatcher收集了data的dep。
const bailRE = new RegExp(`[^${unicodeRegExp.source}.$_\\d]`)
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
}
}
复制代码
以上,我们分析了vue初始化的时候,watcher收集dep的的时机。下面我们分别讨论三种watcher为何都要收集dep。
这是Watcher get函数的部分逻辑,我们重点看cleanupDeps
get () {
pushTarget(this)
value = this.getter.call(vm, vm)
// ...
this.cleanupDeps()
return value
}
复制代码
cleanupDeps主要逻辑是对比新旧deps,把不在新deps中的旧dep对应watcher删除。这里watcher中存放了dep,能够通知dep 把自己remove掉。
cleanupDeps () {
let i = this.deps.length
while (i--) {
const dep = this.deps[i]
if (!this.newDepIds.has(dep.id)) {
dep.removeSub(this)
}
}
let tmp = this.depIds
this.depIds = this.newDepIds
this.newDepIds = tmp
this.newDepIds.clear()
tmp = this.deps
this.deps = this.newDeps
this.newDeps = tmp
this.newDeps.length = 0
}
复制代码
从上面分析可以知道,渲染watcher对应的getter是updateComponent方法,updateComponent最终会导致页面重新渲染。为什么vue每次更新页面,都会清理依次依赖呢?
我们考虑一下这种情况:
初始化的时候渲染watcher订阅了a的dep,然后我们执行了toggle方法把isTrue置为false。如果不清理依赖,调用change A的时候,a的dep会通知渲染watcher更新,这显然不合理。所以第一次旧的deps包含a的dep,再对比新deps,并没有a的dep,说明这次渲染a没有被收集到,所以需要清理掉dep中的watcher避免无用的更新。
<template>
<div>
<div v-if="isTrue">{{a}}</div>
<div v-else>{{b}}</div>
</div>
</template>
<script>
export default {
data(){
return {
isTrue: true,
a:1,
b:2
}
},
methods: {
toggle(){
this.isTrue = false
},
changeA(){
this.a = Math.random()
}
},
}
</script>
复制代码
首先还是看场景,下面这种场景计算属性在template上,但是依赖的data不在template上。那么在生成vnode的时候,并不会访问到所依赖data,那么这些所依赖的data的dep也就无法通知watcher。而计算watcher是不具备更新页面功能的,我们先看一下如下场景
<template>
<div class="about">
<h1>{{ person }}</h1>
</div>
</template>
<script>
export default {
data() {
return { firstName: 'Hello', lastName: 'World' }
},
computed: {
person() {
return this.firstName + this.lastName
}
}
}
</script>
复制代码
计算watcher仅仅是更改dirty属性,并没有更新页面功能
update () {
// 计算属性走this.lazy逻辑
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
复制代码
为了使这种情况能更新页面上的person,我们看一下vue的实现方式。
上面有分析过,当渲染时方法this.person,实际上是调用computedGetter方法。
function createComputedGetter(key) {
return function computedGetter() {
const watcher = this._computedWatchers && this._computedWatchers[key];
if (watcher) {
if (watcher.dirty) {
watcher.evaluate();
}
if (Dep.target) {
watcher.depend();
}
return watcher.value;
}
};
}
复制代码
此时的Dep.target还是渲染watcher,调用 watcher.depend(), this.deps存的是firstName和lastName对应的dep
depend () {
let i = this.deps.length
while (i--) {
this.deps[i].depend()
}
}
复制代码
循环执行this.deps[i].depend(),也就是让person所依赖的firstName和lastName的dep去收集当前的watcher
//dep.js
depend () {
if (Dep.target) {
// 当前watcher收集dep
Dep.target.addDep(this)
}
}
// watch.js
addDep (dep: Dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
// dep收集wathcer
dep.addSub(this)
}
}
}
// dep.js
addSub (sub: Watcher) {
this.subs.push(sub)
}
复制代码
从源码可以看出,生成vnode的时候虽然渲染watcher没有订阅计算属性所依赖data的dep,但是计算watcher初始化的时候收集到了所依赖data的dep,这时就可以让所依赖data的dep的去收集渲染watcher。
但是这种方式会有问题,就是当计算属性最后返回的值没有变化,也会触发vnode的重新生成。虽然在新旧vnode进行diff算法的时候最终不会生成真实dom,但是也浪费了性能。我们看一下场景
export default {
data() {
return { a: 1 }
},
computed: {
b() {
return this.a * 0
}
}
}
复制代码
无论a怎么变化,b的值始终是0。但是由于a变化了,会触发渲染watcher更新。 在vue-2.5.17-beta.0中修改了computed的实现方式解决上面这个问题,但是带来了其他问题而没有被采用,感兴趣的朋友可以看看。 Component is re-rendered when computed value stay same and its dep value changes. · Issue #11399 · vuejs/vue (github.com)
watchWatch这种场景比较容易理解,原因是$watch方法返回了unwatchFn方法,也就是解除订阅,这一点实现方式和cleanupDep的方式一样,移除watcher的订阅。
Vue.prototype.$watch = function (
expOrFn: string | Function,
cb: any,
options?: Object
): Function {
// ...
return function unwatchFn() {
watcher.teardown();
};
};
复制代码
watcher.teardown()方法,将watcher从dep中移除
teardown () {
if (this.active) {
// ...
let i = this.deps.length
while (i--) {
this.deps[i].removeSub(this)
}
this.active = false
}
}
复制代码
经过以上分析,可以知道除了计算watcher,另外两种watcher收集dep都是为了后面便于解除watcher的订阅,而计算watcher收集dep目的是为了让这些dep能够有机会收集渲染watcher。