目录
1. Watcher构造函数参数options和渲染watcher标志位
2. watcher收集的新老依赖deps和newDeps的作用
3. watcher中getter的目的就是去touch目标数据以触发依赖收集
4. Watcher设置依赖收集标志时为什么要pushTarget和popTarget
这篇文章是紧跟着《Vue源码分析基础之响应式原理》的一篇文章。如果刚开始接触vue源码的童鞋,推荐先看完上面一篇文章,这样理解起来可能会轻松点。
vue源码的watcher实现和我们《Vue源码分析基础之响应式原理》分析的思路大致差不多,但是具体实现细节上会有不少的差异,所以下面会做一些补充性的分析。
这里我们先看下watcher类的构造函数
export default class Watcher {
constructor(
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: ?Object,
isRenderWatcher?: boolean
) {
this.vm = vm;
if (isRenderWatcher) {
vm._watcher = this;
}
vm._watchers.push(this);
// options
if (options) {
this.deep = !!options.deep;
this.lazy = !!options.lazy;
this.sync = !!options.sync;
...
}
// options
...
this.cb = cb;
this.deps = [];
this.newDeps = [];
this.depIds = new Set();
this.newDepIds = new Set();
// parse expression for getter
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();
}
}
首先,我们来分析下构造函数的参数参数比我们《Vue源码分析基础之响应式原理》构建的watcher多了两个:
这里跟着需要提一提的是deps和newDeps,因为每次数据更新时vue都会触发渲染watcher进而调用到render来重新构建虚拟dom,构建虚拟dom时需要解析模板中绑定的数据的变化,从而为模板中用到的这些data中的属性建立起watcher的依赖订阅。但是新的页面因为已经发生了变化,很有可能在之前我们模板中用到了某个data中的属性,但更新后的模板不再用这个属性了,那么我们就需要将渲染watcher从该属性的dep.subs中删除,否则就会造成修改该属性时依然触发页面重新渲染的bug。
比如下面的模板代码
<template>
<div>
<div v-if="show">Hello world</div>
<div @click="show = !show">toggle</div>
</div>
</template>
假如当前show为true,那么在点击toggle的时候,show会被改成false,从而导致了依赖show变量的渲染watcher的update方法的运行来重新构建虚拟DOM和更新真实DOM,期间show自身的dep.subs原来是有watcher的订阅在里面的,这时我们就需要将其移除掉了。
而watcher上面的deps和newDeps要做的就是这些事情。
那么newDeps是什么时候产生的呢?当然是在依赖收集的时候了。
在watcher收集依赖的时候,会触发自身的get方法,关键的代码如下
// observer/watcher.js文件
get() {
pushTarget(this)
let value = this.getter.call(vm, vm)
popTarget()
this.cleanupDeps()
return value
}
// observer/dep.js文件
export function pushTarget(target: ?Watcher) {
targetStack.push(target)
Dep.target = target
}
get方法首先做的就是调用pushTarget来将watcher自身推入到targetStack里面,然后和以前分析的那样设置全局的Dep.target为watcher自身。这里为什么要用pushTarget,我们下面会另外起个小节来分析,这里我们就认为它仅仅是设置了Dep.target就行了。
跟着调用getter去触发依赖的收集,这样当某个被obeserved的data的属性被读取(touch)的时候,就会触发该属性自身对应的依赖对象dep的depend方法来进行依赖收集。
// observer/index.js文件
function defineReactive(data, key, val = data[key]) {
const dep = new Dep()
let childOb = observe(val)
Object.defineProperty(data, key, {
// getter
get() {
if (Dep.target) {
dep.depend()
}
return val
},
});
}
// observer/dep.js文件
depend() {
if (Dep.target) {
Dep.target.addDep(this)
}
}
最终进入到watcher的addDep中
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.addSub(this)
}
}
}
从中我们将上面defineReactive中对应属性的dep加入到了这个watcher的newDeps,同时将自己加入了该dep的subs中,实现了依赖的收集。
但这并没有完。这里还只是走完了get方法的getter调用,get方法后面还调用了一个cleanupDeps的方法,而该方法的作用,就是我们上面说的将以前依赖某个data属性的watcher,从该属性的dep.subs中移除,然后形成该watcher所依赖的所有属性的dep组成的最新的deps,并保存到watcher.deps中
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
}
跟着要看下的就是构造函数中对getter的操作。
// parse expression for getter
if (typeof expOrFn === "function") {
this.getter = expOrFn;
} else {
this.getter = parsePath(expOrFn);
if (!this.getter) {
this.getter = noop;
}
}
我们知道watcher中的getter的作用是去取得被监控数据具体的属性,通常是在构造函数调用get方法时去调用下getter,从而读取下这个属性,因为此前对这个对象进行了observe(data),所以一旦我们读取了这个属性,那么get方法设置了Dep.target的标识位的情况下就会触发依赖收集。
这就是构造函数传入的expOrFn是"count.total"这种形式的数据的情况。
但是expOrFn也可能是个函数,比如我们刚才说的渲染watcher初始化时传入的就是updateComponent这个方法。
这个时候构造函数发现你传入的是个函数,它就会直接将这个方法赋值给getter。这样在跟着调用的get,然后触发getter时,就是直接调用到这个函数。比如updateComponent方法,开始去调用渲染函数从新生成虚拟DOM,期间touch到所有页面引用的data数据,触发依赖收集。
所以本质上来说,该getter的作用和上面的情况是一致的,也就是说,getter存在的目的就是为了去touch下目标监控数据,从而触发对这些数据的依赖的收集!
正如我们前面看到的,在vue的源代码中,开启依赖收集标志和关闭依赖收集标志并不是直接操作Dep.target的赋值,而是通过pushTarget和popTarget来做的
// observer/dep.js文件
export function pushTarget(target: ?Watcher) {
targetStack.push(target)
Dep.target = target
}
export function popTarget() {
targetStack.pop()
Dep.target = targetStack[targetStack.length - 1]
}
首先,代码字面意义没有什么不好理解的,就是pushTarget的时候将当前watcher推入到栈中,并将该watcher设置成Dep.target。popTarget的时候从栈顶pop一个watcher出来并将其设置到Dep.target。
问题是why?
其实这里主要是要解决依赖收集的嵌套的问题。
这里我能想到的一个应用场景是在计算属性watcher中,计算属性初始话时会调用initComputed方法。关键代码如下:
const computedWatcherOptions = { lazy: true }
function initComputed(vm: Component, computed: Object) {
...
const watchers = vm._computedWatchers = Object.create(null)
for (const key in computed) {
const userDef = computed[key];
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
computedWatcherOptions
);
if (!(key in vm)) {
defineComputed(vm, key, userDef);
}
}
}
首先以用户自定义的computed函数作为getter创建watcher并放到vm的_computedWatchers数组下。但因为设置了lazy为tru,所以watcher构造时默认不会自动调用getter来立刻更新计算属性,而是等到如页面{{计算属性}}使用时再触发下面的computeGetter来触发更新,可以参考上面watcher的构造函数对lazy的判断部分:
this.value = this.lazy ? undefined : this.get();
跟着为我们定义的每个计算属性调用defineComputed方法
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)
}
这个方法主要的目的是将用户自定义计算属性挂到vm上面,以便页面通过{{计算属性}}直接访问。然后将该计算属性的getter定义为computedGetter,这样一来,在页面等访问{{计算属性}}时该函数将调用_computedWatchers对应的watcher的getter来重新计算属性并触发所依赖属性的依赖收集。
该computedGetter会在什么时候被触发呢?
在组件渲染watcher执行updateComponent时。因为render渲染函数需要读取页面引用到的data属性和computed计算属性来生成虚拟dom。
此时Dep.target是该组件的渲染watcher,栈顶也是该渲染watcher。这个需要记一下,我们下面需要用。
跟着,我们看下计算属性被访问时触发的computedGetter具体是怎么实现的:
function createComputedGetter(key) {
return function computedGetter() {
const watcher = this._computedWatchers && this._computedWatchers[key]
...
watcher.evaluate()
...
watcher.depend()
return watcher.value
}
}
首先从vm中拿到我们上面创建的这个计算属性对应的watcher,然后执行evaludate
evaluate() {
this.value = this.get()
this.dirty = false
}
很明显,evaluate就是执行下watcher的get方法,进而触发我们自定义的计算属性方法,如果该方法使用了data属性,则会针对这个watcher开始对这些属性进行依赖收集。比如下面的自定义计算属性就依赖了data中的属性counter,所以counter的dep.subs会收集这个计算属性watcher到其囊中。
computed: {
twiceCounter: function() {
return this.counter * 2
}
}
但是,这里要注意了,在执行get方法时,里面首先会做一个pushTarget。
记得此前Dep.target是谁吧?本组件的渲染watcher。这时你要对计算属性watcher做依赖收集(即看下我们自定义计算属性的方法体里面用了哪些data属性,将watcher自身加入到这些属性的dep.subs中),所以就会将计算属性watcher压栈,将Dep.target设置成自身,这样所依赖的属性的dep.subs收集到的才是计算属性watcher,好让该属性改变时自动触发计算属性的更新。
完了后,popTarget,计算属性watcher出栈,此时栈顶又变为组件的渲染watcher,同时设置为Dep.target。
为什么呢?因为createComputedGetter紧跟着执行的watcher.depend()需要用到本组件的渲染watcher。
// observer/watcher.js文件
depend() {
let i = this.deps.length
while (i--) {
this.deps[i].depend()
}
}
注意这里的this是计算属性watcher,而this.deps指的是该计算属性watcher所有收集到的依赖,比如由data多个属性的dep所组成,同时,这时Dep.target是组件的渲染watcher而非计算属性watcher。
那么这个方法所实现的逻辑就是,让组件渲染watcher订阅所有该计算属性watcher收集到的依赖。
也就是说,一旦这个计算属性所依赖的某个data属性修改了,将首先会通知计算属性watcher来将计算属性进行更新,然后通知渲染watcher来重新渲染整个组件。
而这,也就是为什么需要pushTarget/popTarget以及targetStack的原因,如前面所说,主要为了解决依赖收集过程中的嵌套问题。即在收集渲染watcher得依赖过程中,发现需要先收集计算属性watcher的依赖,这时就要将渲染watcher先压栈,把栈顶和Dep.target留给计算属性watcher,等计算属性watcher收集完依赖后,再恢复现场,把组件渲染watcher置顶,并设置Dep.target,以完成渲染watcher后续的依赖收集动作。
我是@天地会珠海分舵,「青葱日历」和「三日清单」 作者。能力一般,水平有限,觉得我说的还有那么点道理的不妨点个赞关注下!