「学习记录」vue何时使用watch你知道吗(附watch源码解析

1,244 阅读6分钟

前提

上一期分享了computed的一些使用方式和源码解析

突然想到在日常开发中,很多开发者并不太清楚何时该用computed,何时该用watch

所以本文决定来总结一下watch的用法与watch的源码解析

目标

  1. 熟悉watch的各种用法
  2. 了解watch的源码
  3. 清楚watch的使用场景

watch侦听器

在vue2的官方文档中,watch被定义为侦听器,

这与computed计算属性在命名上已经可以看出他们的使用场景的不同

在vue2的optionApi中,watch在以对象key-value的形式定义

接下来先来看看watch在开发中的几种不同的写法

watch的写法1(字符串)

很多开发者可能不知道watch还可以传入字符串,该字符串是你定义在methods中的方法名或者data中的对象key值,

传入方法名

<body>
    <div id="app">{{aa}}</div>
    <script type="module">
        import Vue from 'https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.esm.browser.js'
        new Vue({
            data(){
                return{
                    aa:1,
                    watchValue:1
                }
            },
            watch:{
                watchValue:'handler_watch'
            },
            mounted(){
                setTimeout(()=>{
                    this.watchValue = 2
                },1000)
            },
            methods:{
                handler_watch(newVal,oldVal){
                    this.aa = newVal * 2
                }
            }
        }).$mount('#app')
    </script>
</body>

传入对象名

<body>
    <div id="app">{{aa}}</div>
    <script type="module">
        import Vue from 'https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.esm.browser.js'
        new Vue({
            data() {
                return {
                    aa: 1,
                    watchValue: 1,
                    watchObj: {
                        handler: function (newVal, oldVal) {
                            this.aa = newVal * 2
                        },
                    }
                }
            },
            watch: {
                watchValue: 'watchObj'
            },
            mounted() {
                setTimeout(() => {
                    this.watchValue = 2
                }, 1000)
            },
        }).$mount('#app')
    </script>
</body>

个人是不太建议这样的写法,因为这不太符合optionApi的使用习惯,在阅读代码时不太直观

watch的写法2(函数)

这是最常用的一种写法,vue会给这个函数传入两个参数

参数1是新的值,参数2则是旧值

这种写法非常直观简洁

<body>
    <div id="app">{{aa}}</div>
    <script type="module">
        import Vue from 'https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.esm.browser.js'
        new Vue({
            data() {
                return {
                    aa: 1,
                    watchValue: 1,
                }
            },
            watch: {
                watchValue(newVal,oldVal){
                    this.aa = newVal * 2
                }
            },
            mounted() {
                setTimeout(() => {
                    this.watchValue = 2
                }, 1000)
            }
        }).$mount('#app')
    </script>
</body>

watch的写法3(对象、deep、immediate)

这也是最常用的一种写法,传入一个对象,对象需要包含一个名为handler的方法,

可选属性有deep与immediate

当deep为true时,该handler回调会在任何被侦听的对象的 property 改变时被调用,不论其被嵌套多深

当immediate为true时,该handler回调将会在侦听开始之后被立即调用

<body>
    <div id="app">{{aa}}</div>
    <script type="module">
        import Vue from 'https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.esm.browser.js'
        new Vue({
            data() {
                return {
                    aa: 1,
                    watchValue: {
                        a: 1, b: { c: 3 }
                    },
                }
            },
            watch: {
                watchValue: {
                    handler: function (val, oldVal) {
                        console.log(val, oldVal,val===oldVal)
                        this.aa++
                    },
                    deep:true,
                    immediate:true
                }
            },
            mounted() {
                setTimeout(() => {
                    this.watchValue.b.c = 2
                }, 1000)
            },
        }).$mount('#app')
    </script>
</body>

注意这里有两个坑,大家可以运行一下上面的代码看看

  1. 在watch监听的数据类型是对象而开启了深度监听deep时,新旧两个值都是返回的该对象的引用

可以在控制台看到val===oldVal是为true的,这也说明了,这两个引用指向的对象内部的数据也是一样的

所以我们其实是无法获取到oldVal值的,如果需要拿到旧值,则需要额外的实现方式

  1. immediate为true时,第一次调用回调函数也是没有old值的

watch的写法4(数组)

这个用法比较少见,但是还是有了解的必要

<body>
    <div id="app">{{aa}}{{bb}}</div>
    <script type="module">
        import Vue from 'https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.esm.browser.js'
        new Vue({
            data() {
                return {
                    aa: 1,
                    bb: 1,
                    watchValue: 1,
                }
            },
            watch: {
                watchValue: [
                    function () {
                        console.log(1)
                        this.bb++
                    },
                    {
                        handler: function (val, oldVal) {
                            console.log(2)
                            this.aa++
                        },
                        immediate: true
                    }]
            },
            mounted() {
                setTimeout(() => {
                    this.watchValue = 2
                }, 1000)
            },
        }).$mount('#app')
    </script>
</body>

我们可以给watch传入一个回调函数数组,里面的元素可以为前面的三种写法,这些回调函数会被逐一调用

watch的写法5(监听对象属性变化)

当我们需要监听响应式数据对象属性的变化

我们可以给watch的key值传入一个字符串

这里要注意,我们监听的是watchValue.b.c的值,但是由于.b的值也会决定c的值的变化

所以,修改与c有关的数据时,都会触发watch

大家可以在控制台输出看看

<body>
    <div id="app">{{aa}}</div>
    <script type="module">
        import Vue from 'https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.esm.browser.js'
        new Vue({
            data() {
                return {
                    aa: 1,
                    watchValue: { a: 1, b: { c: 3 ,d:4} },
                }
            },
            watch: {
                'watchValue.b.c':
                {
                    handler: function (val, oldVal) {
                        console.log(val, oldVal)
                        this.aa++
                    },
                }
            },
            mounted() {
                setTimeout(() => {
                    this.watchValue.a = 2 // 不触发
                    this.watchValue.b = 2 // 触发
                    this.watchValue.b.c = 2 // 触发
                    this.watchValue.b.d = 2 // 不触发
                }, 1000)
            },
        }).$mount('#app')
    </script>
</body>

watch的写法6(调用实例方法$watch)

这是调用全局的vm.$watch方法去注册一个watch,而且通过实例方法注册的watch还可以自定义初始化与取消侦听的时机,这是一种非常灵活的用法

先看看$watch可传入的参数

  • 参数

    • {string | Function} expOrFn

    • {Function | Object} callback

    • {Object} [options]
      
      • {boolean} deep
      • {boolean} immediate
  • 返回值{Function} unwatch

expOrFn:可以是一个实例上的key值或者键路径,也可以是一个类似于计算属性的函数

callback:回调函数

options:与上面的写法一致

unwatch:用于解除侦听器

我们来看看一个使用的例子

<body>
    <div id="app">{{aa}}</div>
    <script type="module">
        import Vue from 'https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.esm.browser.js'
        new Vue({
            data() {
                return {
                    aa: 1,
                    watchValue: {
                        a: 1, b: { c: 1 }
                    },
                }
            },
            mounted() {
                // 
                let unwatch = this.$watch('watchValue.b.c', function (newVal, oldVal) {
                    console.log(newVal, oldVal)
                    this.aa++
                    if(newVal == 3){
                        unwatch()
                        console.log('watch解除')
                    }
                })
                setTimeout(() => {
                    this.watchValue.b.c = 2
                }, 1000)
                setTimeout(() => {
                    this.watchValue.b.c = 3
                }, 2000)
                setTimeout(() => {
                    this.watchValue.b.c = 4
                }, 3000)
            },
        }).$mount('#app')
    </script>
</body>

这代码主要完成了这几个操作:

  1. 在mounted中注册了一个$watch
  2. 设置了三个定时器,用来改变this.watchValue.b.c的值
  3. watch返回的unwatch的函数会在newVal等于3时被调用

我们在控制台可以观察到:

当this.watchValue.b.c = 3时,侦听器的回调函数被触发,然后unwatch()也满足条件触发,解除了侦听器,

所以在后续this.watchValue.b.c = 4时,侦听器已经不会再被触发了

注意:

  1. 监听数组的变更不需要设置deep
  2. 注意在带有 immediate选项时,不能在第一次回调时取消侦听给定的 property,如果仍然希望在回调内部调用一个取消侦听的函数,应该先检查其函数的可用性
  3. expOrFn设置为一个函数时,应该按照如下写法
// 函数
vm.$watch(
  function () {
    // 表达式 `this.a + this.b` 每次得出一个不同的结果时
    // 处理函数都会被调用。
    // 这就像监听一个未被定义的计算属性
    return this.a + this.b
  },
  function (newVal, oldVal) {
    // 做点什么
  }
)

watch的原理

在了解了watch的用法后,我们就可以更好的去理解vue中watch源码的实现原理

我们先找到源码中对watch属性进行初始化的地方

初始化initWatch

我们找到vue2源码中的src/core/instance/state.js

image.png

在initState函数中,我们看到了optionApi初始化的过程,

我们可以看到红框2中的initWatch函数是用来初始化watch的,

而且在这么多选项中watch是最后一个进行初始化的。

这里我们关注一下红框1,这是一个兼容性写法,

在src/core/util/env.js中有提到,是为了区别Firefox中Object.prototype的watch属性

接下来看看initWatch函数做了些什么

image.png

initWatch这里首先上来就是一个for in的对象遍历,然后红框1中,

对watch[key]为数组类型的写法进行了一个for循环遍历,

这也对应了上面介绍的数组写法,

接下来是红框2中的createWatcher,由名字我们可以知道,这是一个创建watcher的工厂函数

createWatcher应该就是实现watch的关键了

创建侦听器createWatcher

我们来看看createWatcher做了些啥

image.png

首先我们关注红框1内的代码isPlainObject(handler),

这个isPlainObject函数在源码的src/shared/util.js中

作用是严格的对象类型检查,对于普通JavaScript对象,返回true。

这正好对应了上面介绍的写法3对象类型,

这里把options赋值为handler,handler改变为handler.handler,指向对象里的回调函数。

再关注红框2内的代码,这正好对应着watch的字符串写法,在vm实例上找到对应的对象或者方法,并赋值到handler上

最后调用原型上的方法vm.$watch(expOrFn, handler, options),创建一个用户自定义的watch,并返回

全局方法$watch

接下来我们来关注一下全局方法$watch

这个方法在stateMixin方法中优先挂载到vue的原型上,所以我们可以在createWatcher中使用它,

src/core/instance/state.js

image.png

在全局方法中我们看到

红框1,又一次进行了isPlainObject(cb)的判断,这是为了兼容字符串写法如果获取到的是对象的情况。

红框2,options是在createWatcher保存的,包含handler,deep,immediate各种属性的对象,这里手动给它添加了一个user属性,用于在Watcher类中判断是否是又用户自定义的watch

红框3,这是设置了immediate时,要调用一次回调函数,这里的pushTarget()与popTarget()本文暂时不会介绍,我们只需要知道这是为了让侦听器对应的响应式数据可以收集到这个用户自定义watcher的依赖,这是设计模式中的观察者模式

红框4,这是返回一个解除watch的函数,这在前面的写法6里有所介绍

Watcher类

在上一期computed计算属性源码中,我们了解到实现计算属性是要依靠一个Watcher类,

而实现用户自定义的侦听器同样需要Watcher类,下面就看看Watcher类为了实现用户自定义watcher做了些什么?

我们先来看Watcher类的构造器

src/core/observer/watcher.js

image.png

我们先关注构造器红框1中的代码

我们记得在上面的代码中传入的options.user是为true的,而deep也是可以为true的情况,所以这一步其实是初始化实例内这两个的属性。

而红框2中的代码,则是完成了回调函数的赋值。

image.png

接下来的红框3代码主要是获取watch需要监听的对象方法

  1. 如果是函数的形式,则直接赋值,这对应写法6中的传入函数的形式;

  2. 如果是字符串的形式,则调用parsePath返回一个取值函数,调用该函数就可以获取对应数据的值,这里也完成了键路径求值的兼容,可以识别 'a.b.c.d' 这样的字符串。

下面的红框4代码,则是value的第一次调用get()函数求值,如果是使用了immediate模式,则会直接当作第一次触发的new值;否则当作下一次值变化时调用回调函数的old值。

image.png

这get函数里面的代码我挑了几个地方来简单说明:

红框1与红框4的代码:在调用getter取值前后,分别是把当前watcher实例推到全局dep.target和弹出当前dep.target,简单来说就是作用于依赖的收集;

红框2的代码:用于取当前监听的值,注意call方法,让方法的this指向vm实例

红框3的代码:用于深度监听对象时,深度遍历到的所有值都会收集当前watcher,也是依赖收集的作用,这里不过多展示代码。

接下来是实现数据更新后调用回调函数cb的逻辑

image.png

首先我们抛出一个前置知识,在响应式数据改变时,会触发它所收集到的watcher调用update函数去完成视图更新,调用自定义回调,或者计算属性的更新。

红框1的代码:queueWatcher其实是一个watcher的队列,会把所有的被触发的watcher放入一个微任务里面,这里用的是vue的nextTick,然后在微任务里面调用这些watcher.run()函数

红框2的代码:watcher.run()函数,重新求值后,把该值作为new值

红框3的代码:watcher.run()函数中,user为true的调用回调函数,分别传入old值与new值

到这里一个用户自定义watch侦听器的基础实现已经完成,但是有一些细节,还是需要大家自己去源码中仔细阅读。

watch的使用场景

看了一些组件库的代码中使用watch的场景,大多数是“一对多”。

即是由某个响应式数据(或计算属性)的改变,而去触发一个回调函数,并在里面执行多种操作,

”一个改变,多种影响“,可能才是watch的使用场景。

当然使用watch也可以实现computed的“多对一”的计算效果,

只是computed的缓存机制会更优于使用watch实现“多对一”计算。

Watch源码执行流程图

初始化:

image.png

触发回调:

image.png