watch侦听器
依旧从一个最简单的例子开始
<div id="app">
{{a}}
</div>
<script>
let vm = new Vue({
el: '#app',
data: {
a: 1,
b: 2,
d: 3
},
watch: {
a: function (val, oldval) {
console.log('new: %s, old: %s', val, oldval)
},
// 对象形式
b: {
handler: function (val, oldval) {
console.log('new: %s, old: %s', val, oldval)
},
deep: true
},
d: {
handler: 'someMethod',
immediate: true
},
e: [
function handle2() {},
function handle3() {},
function handle4() {},
]
},
methods: {
someMethod(val, oldval) {
console.log('new: %s, old: %s', val, oldval)
}
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
可以看到,watch的书写形式很多,在官方文档api中还有更多的书写形式。点击进入查看。有这么多形式,在vue处理的时候 肯定不会一个个去单独处理,需要统一成一种格式,方便之后处理。这就是合并策略的作用。
在_init方法中,有这么一段代码,这块的主要功能是通过策略模式将用户书写的各个属性props、data、methods、watch、computed等序列化成vue需要的格式 因此我们直接在这里打个断点,看vm.options的生成格式就成。
// 合并选项并赋值给 $options
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
// 用户传进来的options 或者为空
options || {},
vm
)
2
3
4
5
6
7
可以看到,本身还是对象形式,对应三种格式,后面的代码都是以这三种格式来解析的
{
watch:{
a: ƒ (val, oldval)
b: {deep: true, handler: ƒ}
d: {handler: 'someMethod', immediate: true}
e: (3) [ƒ, ƒ, ƒ]
}
}
2
3
4
5
6
7
8
initWatch
在initState方法中,我们可以看到拿的就是vm.$options的数据,并且还有一个判断opts.watch !== nativeWatch。这是因为在firefox中, Object有一个watch方法,所以需要做一个判断。
// instance/state.js
export function initState (vm: Component) {
//这个数组将用来存储所有该组件实例的 watcher 对象
vm._watchers = []
const opts = vm.$options
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}
2
3
4
5
6
7
8
9
然后我们进去initWatch看看,很简单就是拿到key和value, 并传给了createWatcher方法,只是对不同格式做了一次处理。 而在createWatcher方法中,对对象类型的handle和字符串类型的handle分别做了处理。可以看到,字符串类型的handle值是从 vm上获得的,那么其实就能猜到methods方法除了在options有定义,实例上也有。
注意:
最后vue调用了vm.$watch,所以不管是函数形式的watch还是对象形式,最后都会调用$watch,这才是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)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
vm.$watch
这里我们一行行代码看,第一个判断主要是当我们使用$watch去创建监听函数的时候,需要对cb进行重新调整。 比如cb可以是这种形式{handle:function(){}, deep:true},这时候传入的options会被覆盖。这里可以写个 例子测试一下,比如this.$watch('e', {handle:function(){}, deep:true}, {immediate: true}), 可以单步调试看看,后面的options字段将被覆盖。
之后两行代码是核心,vue给options添加了一个user属性,并且赋值为true。之后new Watcher创建构造函数。 可以发现这是第三种Watcher,我们将它命名为用户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 || {}
// 表示为用户创建
options.user = true
// 创建watcher对象
const watcher = new Watcher(vm, expOrFn, cb, options)
...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
好接下来,看new Watcher,因为这段代码已经贴过好几回了,这里捡之前没讲过的,options这一块等到一个单独的章节一起讲。 先来看 求值表达式expOrFn,和其他不同,用户watcher支持使用字符串,所以这块可能走parsePath方法,这个方法返回了一个 expOrFn经过处理的函数,并且能传入obj,和之前写的简易响应式很像,如果obj传入的是this,那么我们调用的就是this[a], 第二次就是this[a][b]
export default class Watcher {
constructor (
vm: Component,
// 求值表达式
expOrFn: string | Function,
// 回调
cb: Function,
// 选项
options?: ?Object,
// 是否是渲染watcher
isRenderWatcher?: boolean
) {
// options
...
this.cb = cb // 回调
this.id = ++uid // uid for batching 唯一标识
this.active = true // 激活对象
// parse expression for getter
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
// 处理表达式 obj.a
this.getter = parsePath(expOrFn)
}
// 当时计算属性 构造函数是不求值的
this.value = this.lazy
? undefined
: this.get()
}
}
// core/util/lang.js
export function parsePath (path: string): any {
const segments = path.split('.')
// 返回的还是函数, 会出现obj[a][b]
return function (obj) {
for (let i = 0; i < segments.length; i++) {
if (!obj) return
obj = obj[segments[i]]
}
return obj
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
这样一个普通的不传入任何options的watch就会正常的执行到this.get方法。pushTarget方法已经讲过好几次了, 功能就两个
- 将
Dep.target赋值为当前Watcher - 将当前
Watcher放到targetStack数组中
然后来看这段代码this.getter.call(vm, vm),在看parsePath的返回赋值给了this.getter,所以其实我们执行的是 parsePath返回的函数,并且正好我们传入了vm,这就和我上面说的一样了。单步执行,就会触发this.a,也就是this._data.a 触发data里a的拦截器。
get () {
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (e) {
...
} finally {
// 清除当前 target
popTarget()
// 清空依赖
this.cleanupDeps()
}
return value
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
后面的和computed一样,会将当前用户watcher存到对应a的dep.subs中。流程不细说了,建议自己debug一下。走完就会正常的,回到get方法 走下面的清理流程。这样就结束初始化了吗?没有,我们还要回到$watch方法。中间步骤先不说,刚刚我们只是执行了new Watcher。之后我们还会走下面的流程, 并且返回了一个unwatchFn。这个方法,可以执行teardown
Vue.prototype.$watch = function (
expOrFn: string | Function,
cb: any,
options?: Object
): Function {
...
const watcher = new Watcher(vm, expOrFn, cb, options)
...
// 返回一个解除函数
return function unwatchFn () {
watcher.teardown()
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
那么这样 初始化就完成了。下面开始执行例子。
触发watch
这里我们把例子改一下,用最简单的例子做测试。
<div id="app">
</div>
<script>
let vm = new Vue({
el: '#app',
data: {
a: 1,
},
watch: {
a: function (val, oldval) {
console.log('new: %s, old: %s', val, oldval)
},
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这里我们做一个不一样的操作,将template里面的去掉,这时候我们看看vm._render生成的匿名函数
(function anonymous(
) {
with(this){return _c('div',{attrs:{"id":"app"}})}
})
2
3
4
没有a,那么就不会调用a的get。这样就不会收集a的渲染watcher。因此a上只有一个用户watcher。 这时候我们再触发a的set。在console中执行vm.a = 6。在set处断点,单步执行可以看到,执行流程是 dep.notify-->subs[i].update-->queueWatcher(this)-->nextTick(flushSchedulerQueue)走到了nextTick, 将当前用户Watcher放到了队列中,该队列会在flushSchedulerQueue中执行。
之后执行到flushSchedulerQueue的时候,就会将队列中的watcher拿出来顺序执行,也就是执行watcher.run方法。
run () {
// 观察者是否处于激活状态
if (this.active) {
// 重新求值
const value = this.get()
// 在渲染函数中 这里永远不会被执行,因为 两次值都是 undefiend
if (
value !== this.value ||
// 这里当值相等,可能是对象引用,值改变 引用还是同一个,所以判断是否是对象,
// 是的话也执行
isObject(value) ||
this.deep
) {
// 保存旧值, set 新值
const oldValue = this.value
this.value = value
// 观察者是开发者定义 即 watch $watch
if (this.user) {
const info = `callback for watcher "${this.expression}"`
invokeWithErrorHandling(this.cb, this.vm, [value, oldValue], this.vm, info)
} else {
this.cb.call(this.vm, value, oldValue)
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
这个方法要详细说说,首先会进行一次求值,这里主要是为了拿到新值,后面的依赖因为已经存在,会被重复的判断跳过。 这时候会新旧值同时缓存,然后当前我们的user=true,所以就会执行invokeWithErrorHandling。这方法就是执行 我们定义的handle,不过因为是用户定义,所以需要try catch。这样一次完整的watcher就执行完了。
export function invokeWithErrorHandling (
handler: Function,
context: any,
args: null | any[],
vm: any,
info: string
) {
let res
try {
res = args ? handler.apply(context, args) : handler.call(context)
} catch (e) {
handleError(e, vm, info)
}
return res
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
options 各个参数在vue中的执行过程
这样基础的watch就解析完了,现在我们看看每一种options,在vue中的执行过程。也就是new Watcher时候,构造函数内的这段代码
if (options) {
this.deep = !!options.deep // 是否使用深度观测
this.user = !!options.user // 用来标识当前观察者实例对象是 开发者定义的 还是 内部定义的
this.lazy = !!options.lazy // 惰性watcher 第一次不请求
this.sync = !!options.sync // 当数据变化的时候是否同步求值并执行回调
this.before = options.before // 在触发更新之前的 调用回调
}
2
3
4
5
6
7
immediate
同样使用最初的例子,我们加上options。
<div id="app">
</div>
<script>
let vm = new Vue({
el: '#app',
data: {
a: 1,
},
watch: {
a: {
handler: function (val, oldval) {
console.log('new: %s, old: %s', val, oldval)
},
immediate: true
},
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
然后看源码,很简单在初始化过程中,new Watcher结束后,马上执行了一次invokeWithErrorHandling, 也就是执行了自定义的函数回调,并且传入的值就是当前new Watcher通过计算拿到的值。
Vue.prototype.$watch = function (
expOrFn: string | Function,
cb: any,
options?: Object
): Function {
// 立即执行
if (options.immediate) {
const info = `callback for immediate watcher "${watcher.expression}"`
pushTarget()
// 获取观察者实例对象,执行了 this.get
invokeWithErrorHandling(cb, vm, [watcher.value], vm, info)
popTarget()
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
lazy
computed的本质就是lazy watcher。并且vue为我们实现了值的缓存。所以一般我们不会再watch中传入lazy
sync
设置这个值,顾名思义,就是同步,看这段代码, 在Watcher类的update方法中,也就是在我们触发拦截器set的时候,通过dep.notify 到循环执行watcher的update方法,这里如果sync=true,就不会将当前watcher放到微任务队列中,而是直接执行。
update () {
/* istanbul ignore else */
// 计算属性值是不参与更新的
if (this.lazy) {
this.dirty = true
// 是否同步更新变化
} else if (this.sync) {
this.run()
} else {
// 将当前观察者对象放到一个异步更新队列
queueWatcher(this)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
deep
修改例子
<div id="app">
</div>
<script>
let vm = new Vue({
el: '#app',
data: {
b: {
c: 2,
d: 3
}
},
watch: {
b: {
handler: function(val, oldval) {
console.log(`new: ${JSON.stringify(val)}, old: ${JSON.stringify(oldval)}`)
},
deep: true
}
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
deep表示深层监听,那么思考一下,vue会在哪里触发深层对象的拦截器?一般来说是在表层的a经过get的拦截器触发,存放 watcher之后,那么显而易见了。查看watcher类里的get方法,也就是调用求值表达式的地方
get () {
// 给Dep.target 赋值 Watcher
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (e) {
...
} finally {
if (this.deep) {
traverse(value)
}
// 清除当前 target
popTarget()
// 清空依赖
this.cleanupDeps()
}
return value
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
在清除依赖之前,vue判断了deep,然后调用了traverse方法。
这里的代码比较难以理解,我们从最初开始,首先在第一次触发求值表达式的时候,触发的b的get,这时候会先把用户watcher放到 defineReactive定义的关于b的闭包dep里。我们这么表示,同级还有一个 new Observer创建的__ob__
{
b(-->闭包dep{subs:[Watcher], id:3}):{
c: 2,
d: 3,
__ob__: {
value: {},
id: 4,
subs: []
}
}
__ob__: {
value: {},
id: 2,
subs: []
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这里回忆一下data嵌套对象的初始化,并且再来看一下源码,childOb是有值的,初始化后被闭包保存着,而且值就是b的对象, 而且value也是它。既然它有值,那么就会进入childOb.dep.depend()方法,这时候我们就在__ob__中存了一个watcher。
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
// 依赖框
const dep = new Dep()
let childOb = !shallow && observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
get: function reactiveGetter () {
// 如果存在自定义getter 执行自定义的
const value = getter ? getter.call(obj) : val
// 要被收集的依赖
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
}
}
return value
},
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
也就是说这样,还有一点要注意,这时候watcher的newDepIds有两个值[3, 4]
{
b(-->闭包dep{subs:[Watcher], id:3}):{
c: 2,
d: 3,
__ob__: {
value: {},
dep: {
id: 4,
subs: [
Watcher // 通过childOb存的用户watcher
]
},
vmCount:0
}
}
__ob__: {
value: {},
dep: {
id: 2,
subs: []
},
vmCount: 1
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
之后b的拦截器就结束了,这时候进入traverse方法。首先查看val的值,它是通过上一次的计算拿到的,也就是b的值是对象。
const seenObjects = new Set()
export function traverse (val: any) {
_traverse(val, seenObjects)
seenObjects.clear()
}
function _traverse (val: any, seen: SimpleSet) {
let i, keys
// 检查 val是不是数组
// * val 为 被观察属性的值
const isA = Array.isArray(val)
// * 解决循环引用导致死循环的问题
// 拿到 Dep中的唯一值 进行已响应式对象去除
if (val.__ob__) {
const depId = val.__ob__.dep.id
if (seen.has(depId)) {
return
}
seen.add(depId)
}
// val[i] 和 val[key[i]] 都是在求值,这将触发紫属性的get拦截器
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)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
中间这块__ob__的判断就不讲了,注释上写的很明白,其实就是当我们存在互相引用的时候,如果有__ob__就退出。以免死循环。 这里直接进入这行while (i--) _traverse(val[keys[i]], seen)代码,val[keys[i]]明显会触发d的拦截器,这时候就会 给d的dep添加watcher,同理c也是,这样初始化就完成了。
{
c(-->闭包dep{subs:[watcher], id:5}): 2,
d(-->闭包dep{subs:[watcher], id:6}): 3,
__ob__: {
value: {},
dep: {
id: 4,
subs: [
Watcher // 通过childOb存的用户watcher
]
},
vmCount:0
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
因为在相关属性上的dep都保存了用户watcher所以,我们设置多种属性都能触发watcher,尝试下面代码
vm.b.c = 7
// new: {"c":7,"d":3}, old: {"c":7,"d":3}
vm.b.d = 8
// new: {"c":7,"d":8}, old: {"c":7,"d":8}
vm.b = 6
// new: 6, old: {"c":7,"d":8}
vm.$set(vm.b, 'e', 6)
// new: {"c":2,"d":3,"e":6}, old: {"c":2,"d":3,"e":6}
2
3
4
5
6
7
8
因为vue在b对象上的__ob__属性内dep保存了用户watcher,所以对b的操作也是生效的,除非我们真要这么做, 深度观测上这样其实还是蛮消耗性能的,如果层级再多一点。我们有更好的处理方式。
观察parsePath方法,有这么一段代码path.split('.'),所以如果我们想观测深层,例如想观测c可以这么写
<div id="app">
</div>
<script>
let vm = new Vue({
el: '#app',
data: {
b: {
c: 2,
d: 3
}
},
watch: {
'b.c': {
handler: function(val, oldval) {
console.log('new: %s, old: %s', val, oldval)
},
}
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
这样我们只对b、b下的属性__ob__、c保存了watcher。如果b内属性很多,相当于少了n-1/n。很大的优化了。
before
这不是一个官方文档中使用的属性,但也是可以使用的,如下
<div id="app">
</div>
<script>
let vm = new Vue({
el: '#app',
data: {
a: 1
},
watch: {
a: {
handler: function (val, oldval) {
// console.log(`new: ${JSON.stringify(val)}, old: ${JSON.stringify(oldval)}`)
console.log('new: %s, old: %s', val, oldval)
},
before: function () {
console.log('调用了before')
}
}
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
在源码中,它在watcher.run()之前运行,而在我们使用渲染watcher的时候,他被用作于触发beforeUpdate。 而上面的例子,很显然也会在handler之前运行
if (watcher.before) {
watcher.before()
}
id = watcher.id
has[id] = null
watcher.run()
2
3
4
5
6
函数调用的形式
使用$watch并没有什么不同,但是它有声明式不具备的功能,想想computed,它在new Watcher的时候求值表达式一直是函数。那么显然 watch也应该支持传入函数,这就是$watch的作用。例如下面的例子
<div id="app">
</div>
<script>
let vm = new Vue({
el: '#app',
data: {
a: 1,
b: 2
},
mounted() {
this.$watch(() => ([this.a, this.b]), (val, oldval)=> {
console.log(`new: ${val}, old: ${oldval}`)
})
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这是分别触发a或者b都会触发监听回调。至于原理显然是a和b的dep里都保存了该用户watcher。
vm.a = 7
// 24 new: 7,2, old: 1,2
vm.b = 5
// new: 7,5, old: 7,2
2
3
4
teardown
上面还有一个遗漏的东西没有讲,在我们执行完$watch的时候,会返回一个unwatchFn。比如例子
<div id="app">
</div>
<script>
let vm = new Vue({
el: '#app',
data: {
a: 1,
},
mounted() {
let unwatch = this.$watch('a', (val, oldval)=> {
console.log(`new: ${val}, old: ${oldval}`)
unwatch()
})
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
vm.a = 5
// new: 5, old: 1
vm.a = 6
2
3
执行它能发现,第二次set就不会执行了,所以他所做的工作就是清理。分为三步
- 清除当前
_watchers里对应的watcher - 清除
dep里面的当前用户watcher,注意Dep实例和Watcher是相互保存的,而这个就是为了清除 - 解除观察者激活状态
teardown () {
if (this.active) {
// 在组件没有被销毁时,移除该watcher对象
if (!this.vm._isBeingDestroyed) {
remove(this.vm._watchers, this)
}
let i = this.deps.length
// 一个观察者可以同时观察多个属性,所以要移除该观察者观察的所有属性
while (i--) {
this.deps[i].removeSub(this)
}
// 解除观察者的激活状态
this.active = false
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
结尾and碎碎念
这样watch也算解析完毕了,这几天的文章写下来,对于我个人来说帮助非常大,基本相关代码都逐行去调试了。 如果有人也有这想法,建议在无痕模式下,并且多f5刷新几次清除缓存的影响。
其实每天文章量还蛮大的,但是当初的想法就是一个相关属性一篇解析,如果分开就不完整。