Libon

Vue2 OptionsAPI unwatched

7Mins #vue
学习 Vue2 中怎么主动取消在 watch 监听回调函数

ToC

我想做什么?

在重构公司前同事代码的时候,我发现在 vue@2 中有很多的 watcher,但在实际业务中,有一些条件是互斥的,比如存在 a 属性后,b 属性就不可能存在,那我就不需要去监听 b 属性的变化了,所以我想要去主动取消在 options APIwatch 选项中定义的 watcher

它在哪里做的?

在明确目的以后,就需要意识到,Vue是怎么取消组件的 watcher的。在不了解 Vue 源码的情况下,就需要去了解一下 Vue 组件的创建、执行、挂载、更新和销毁流程,也就是生命周期。这里引用 Vue.js 官网上的生命周期流程图: image.png 从图中可以看到,Vue 对 watch和对子组件 event 监听的解绑操作在 beforeDestroy之后,destroyed之前,有了目标以后,我们就可以在源码中找到其关于生命周期的处理函数的位置了:vue/src/core/instance/lifecycle.js。文件位置是这个,接下来只需要找到 beforeDestroy 钩子调用的地方,在这个文件的 :102 行有这么一个语句:callHook(vm, 'beforeDestroy')

它是怎么做的?

继续在源码中查找关于 watch 的字样,直到找到:

1
// teardown watchers
2
if (vm._watcher) {
3
vm._watcher.teardown()
4
}
5
let i = vm._watchers.length
6
while (i--) {
7
vm._watchers[i].teardown()
8
}

这一段就是取消所有 watcher 的监听。

我该怎么做?

为了验证一下刚找到的这一段代码是否正确,那么我们可以拷贝源码到本地,同时找到在源码中找到的那一段代码的位置,添加一点调试语句,debugger console.log或其他的都可以,再写一段代码用于测试。

复制 CDN 链接 里的代码到本地,方便调试。我这里用的 vue 版本是 2.6.14,新建一个 html文件,写入了类似如下的测试代码:

1
<!DOCTYPE html>
2
<html lang="en">
3
4
<head>
5
<meta charset="UTF-8" />
6
</head>
7
8
<body>
9
<div id="app"></div>
10
<!-- 下载到本地的 vue 源码 -->
24 collapsed lines
11
<script src="./vue.js"></script>
12
13
<script>
14
15
const vm = new Vue({
16
el: '#app',
17
data: {
18
count: 0,
19
message: 'Hello Vue!'
20
},
21
template: `<button @click="count++">{{ message }}{{ count }}</button>`,
22
23
watch: {
24
message: function (val, old) {
25
console.log({ val, old })
26
}
27
}
28
})
29
30
vm.message = 'Hello World!'
31
</script>
32
</body>
33
34
</html>

运行、访问这个文件,当页面中出现了一个按钮后则表示这个实例创建成功。那么就可以开始后续的操作。

代码正常运行后,在浏览器控制台就会有一行 watcher的打印信息,包含了 val old值。尝试打印一下 console.log(vm._watcher, vm._watchers) 是什么。大致结果如下:

1
Watcher {vm: Vue, deep: false, user: false, lazy: false, sync: false, …}
2
active: true,
3
before: ƒ before(),
4
cb: ƒ noop(a, b, c),
5
deep: false,
6
depIds: Set(2) {4, 3},
7
deps: (2) [Dep, Dep],
8
dirty: false,
9
expression: "function () {\n vm._update(vm._render(), hydrating)\n }",
10
getter: ƒ (),
11 collapsed lines
11
id: 2,
12
lazy: false,
13
newDepIds: Set(0) {size: 0},
14
newDeps: [],
15
sync: false,
16
user: false,
17
value: undefined,
18
vm: Vue {_uid: 0, _isVue: true, $options: {…}, _renderProxy: Proxy, _self: Vue, …},
19
[[Prototype]]: Object
20
21
[Watcher, Watcher]

一个组件实例中,至少会存在1watcher, 它用于更新视图,而在 watch配置项中定义的观察者就存在于 _watchers中。那我们要做的就是找到目标watcher,并结束它的监听。我们可以尝试一下源码中的操作,看那段源码做了什么事情:

1
if (vm._watcher) {
2
vm._watcher.teardown()
3
}
4
5
var i = vm._watchers.length
6
while (i--) {
7
vm._watchers[i].teardown()
8
}

将这段代码放到测试代码中(放在 vm.message = 'Hello World!'前),刷新页面后就能发现,数据的变化不再能触发视图的更新了,在浏览器中访问 vm 组件实例,查找 count message 属性,会发现它的值是变化了的。所以可以确定,这段代码就是取消监听的操作。_watcher属性我们已经看过了,没有眼熟的字段,接下来再去查看 _watchers。它是一个数组,数组的第二项是 _watcher属性,还是用于更新视图的,展开第一个watcher

1
0: Watcher
2
active: true,
3
before: undefined,
4
cb: ƒ (val, old),
5
deep: false,
6
depIds: Set(1) {4},
7
deps: [Dep],
8
dirty: false,
9
expression: "message",
10
getter: ƒ (obj),
9 collapsed lines
11
id: 1,
12
lazy: false,
13
newDepIds: Set(0) {size: 0},
14
newDeps: [],
15
sync: false,
16
user: true,
17
value: "Hello World!",
18
vm: Vue {_uid: 0, _isVue: true, $options: {…}, _renderProxy: Proxy, _self: Vue, …}
19
[[Prototype]]: Object

看到一个比较熟悉的字段了:expression,它的值是 message。我们尝试去修改一下 watch属性中的 message键名为:this.message,再刷新查看第一个 watcher中的 expression字段会发现也已经同步变成了 this.message,那么基本可以肯定,这个watcher就是和 message属性绑定的观察者。 在 watcher的原型上能找到 teardown方法,它的作用就是取消监听。

那么这一次学习的目的已经达到了,但是每次使用都需要去手动找显然效率不太高,我们可以去封装一个 unwatch 函数来尝试解决这个问题:

1
/**
2
* unwatch watcher
3
* @param ctx {Vue} vue instance context
4
* @param key {string} watcher expression
5
* @returns whether to successfully execute
6
*/
7
function unwatch(ctx, key) {
8
const watcher = ctx._watchers.find(({ expression }) => expression === key)
9
10
try {
7 collapsed lines
11
watcher.teardown()
12
return true
13
} catch (e) {
14
console.warn(e)
15
return false
16
}
17
}

调用时只需要传入 vue实例和 watch属性中的键名即可:

1
unwatch(this, 'message')

在点击 unwatch 按钮以后再点击 Add 按钮后控制台将不会有任何输出, 完整的示例代码如下:

1
<script>
2
function unwatch(ctx, key) {
3
const watcher = ctx._watchers.find(({ expression }) => expression === key)
4
5
try {
6
watcher.teardown()
7
return true
8
} catch (e) {
9
console.warn(e)
10
return false
38 collapsed lines
11
}
12
}
13
14
15
export default {
16
data() {
17
return {
18
count: 1
19
}
20
},
21
22
watch: {
23
count: {
24
deep: true,
25
handler(newValue, oldValue) {
26
console.log(newValue, oldValue)
27
}
28
}
29
},
30
31
methods: {
32
unwatchCount() {
33
unwatch(this, 'count')
34
}
35
}
36
}
37
</script>
38
39
<template>
40
<div class="greetings">
41
<h1 class="green">{{ count }}</h1>
42
<h3>
43
<button @click="count++">Add</button>
44
</h3>
45
46
<button @click="unwatchCount">unwatch</button>
47
</div>
48
</template>

2024-01-11 更新

我注意到在 Vue2.7.0 以后的版本因为增加了 setup 语法的原因,重写了一部分编译相关的内容,已经不存在 _watchers 属性了,所以以上的代码需要进行一些调整,兼容一下高版本的 vue,所以 unwatch 代码更新如下:

1
function unwatch(ctx, key) {
2
const watcher = (ctx._watchers || ctx._scope.effects).find(({ expression }) => expression === key)
3
4
try {
5
watcher.teardown()
6
return true
7
} catch (e) {
8
console.warn(e)
9
return false
10
}
1 collapsed line
11
}

以上。


CD ..