computed和watch的区别,你真的了解吗?

1,449 阅读12分钟

前言🤞

在vue项目中我们常常需要用到computed和watch,那么我们究竟在什么场景下使用computed和watch呢?他们之间又有什么区别呢?记录一下!

computed和watch有什么区别?

相同点:(过目一下,下面还会更新)

  • 本质上都是一个watcher实例,它们都通过响应式系统与数据,页面建立通信
  • 它们都是以Vue的依赖追踪机制为基础的

computed

简而言之,它的作用就是自动计算我们定义在函数内的“公式”

 data() {
    return {
      num1: 1,
      num2: 2
    };
  },
  computed: {
    total() {
      return this.num1 * this.num2;
    }
  }

在这个场景下,当this.num1或者this.num2变化时,这个total的值也会随之变化,为什么呢?

## 计算属性实现:

computed是一个函数可以看出,它应该也有一个初始化函数 initComputed来对它进行初始化。

  • 从vue源码可以看出在initState函数中对computed进行初始化,往下看

image-20230517160838735.png

  • initComputed函数中,有两个参数,vm为vue实例,computed就是我们所定义的computed

image-20230517161430341.png

  • 具体实现逻辑就不具体解析了,从上面源码中可以发现,initComputed函数会遍历我们定义的computed对象,然后给每一个值绑定一个watcher实例

  • image-20230517161910771.png

  • Watcher实例是响应式系统中负责监听数据变化的角色

  • 计算属性执行的时候就会被访问到,this.num1和this.num2在Data初始化的时候就被定义成响应式数据了,它们内部会有一个Dep实例Dep实例就会把这个计算属性watcher放到自己的sub数组内,往后如果子级更新了,就会通知数组内的watcher实例更新

  • 再看回源码

    const computedWatcherOptions = { lazy: true }
    
    // vm: 组件实例 computed 组件内的 计算属性对象
    function initComputed (vm: Component, computed: Object) {
      // 遍历所有的计算属性
      for (const key in computed) {
        // 用户定义的 computed
        const userDef = computed[key]
        const getter = typeof userDef === 'function' ? userDef : userDef.get
    
        watchers[key] = new Watcher( 
          vm,
          getter || noop,
          noop,
          computedWatcherOptions
        )
     
      defineComputed(vm, key, userDef)
    }
    
    
  • 可以看出在watcher实例在刚被创建时就往ComputedWatcherOptions, 传了{ lazy: true }, 即意味着它不会立即执行我们定义的计算属性函数,这也意味着它是一个懒计算的功能(标记一下)

  • 说到这,就能基本了解了计算watcher实例在计算属性执行流程的作用了,即初始化的过程,那么计算属性是怎么执行的?

  • 从上面的源码可以看出最下面还有一个defineComputed函数,它到底是干嘛的?其实它是vue中用来判断computed中的key是否已经在实例中定义过,如果未定义,则执行defineComputed函数

  • 来看一下defineComputed函数

image-20230517163841807.png

  • 可以看出这里截取了两个函数,defineComputedcreateComputedGetter两个函数

首先说说defineComputed函数

  1. 它会判断是否为服务器渲染,如果为服务器渲染则将计算属性的get、set定义为用户定义get、set;怎么理解?如果非服务器渲染的话则在定义get属性的时候并没有直接赋值用户函数,而是返回一个新的函数computedGetter
  2. 这里会判断userDef也就是用户定义计算属性key对应的value值是否为函数,如果为函数的话,则将get定义为用户函数,set赋值为一个空函数noop;如果不为函数(对象)则分别取get、set字段赋值
  3. 非服务端渲染中计算属性的get属性为computedGetter函数,在每次计算属性触发get属性时,都会从实例的_computedWatchers(在initComputed已初始化)计算属性的watcher对象中获取get函数(用户定义函数)
  4. 至此,计算属性的初始化就结束了,最终会把当前key定义到vue实例上,也就是可以this.computedKey可以获取到的原因
  • 细心的同学可能发现了,在上述源码中还有一行代码 :Object.defineProperty(target, key, sharedPropertyDefinition),它就是我接下来要说的defineComputed函数做的第二件事(第一件事就是上面的操作)。当访问一次计算属性的key 就会触发一次 sharedPropertyDefinition(我们自定义的函数),对computed做了一次劫持,Target可以理解为this,从上面源码可以看出,每次使用计算属性,都会执行一次computedGetter,跟我们一开始的DEMO一样,它就会执行我们定义的函数,具体怎么实现?

    function computedGetter () {
        // 拿到 上述 创建的 watcher 实例
        const watcher = this._computedWatchers && this._computedWatchers[key]
        if (watcher) {
          // 首次执行的时候 dirty 基于 lazy 所以是true
          if (watcher.dirty) {
            // 这个方法会执行一次计算
            // dirty 设置为 false
            // 这个函数执行完毕后, 当前 计算watcher就会推出
            watcher.evaluate()
          }
          // 如果当前激活的渲染watcher存在
          if (Dep.target) {
            /**
             * evaluate后求值的同时, 如果当前 渲染watcher 存在,
             * 则通知当前的收集了 计算watcher 的 dep 收集当前的 渲染watcher
             *
             *    为什么要这么做?
             * 假设这个计算属性是在模板中被使用的, 并且渲染watcher没有被对应的dep收集
             * 那派发更新的时候, 计算属性依赖的值发生改变, 而当前渲染watcher不被更新
             * 就会出现, 页面中的计算属性值没有发生改变的情况.
             *
             * 本质上计算属性所依赖的dep, 也可以看做这个属性值本身的dep实例.
             */
            watcher.depend()
          }
          return watcher.value
        }
      }
    
    
  • 综上所述,更加证实了文章开头所说的计算属性带有“懒计算”的功能,为什么呢?回看上面的代码中的watcher.dirty,在**计算watcher实例化的时候,一开始watcher.dirty会被设置为true**,这样一说,上面所说的逻辑好像能走通了。

  • 走到这里会执行watcher的evaluate(),即求值this.get()简单理解为执行我们定义的计算属性函数就可以了。

    evaluate () {
        this.value = this.get()
        this.dirty = false
      }
    
  • this.dirty 这时候就被变成false

  • 既然这样,我们是不是可以理解为当this.dirtyfalse时就不会执行这个函数。Vue为什么这样做? 当然是觉得, 它依赖的值没有变化, 就没有计算的必要啦

  • 那么问题来了,说了这么久,我们只看到了将this.dirty设为false,什么时候设为true呢?来看一下响应式系统set部分代码

set: function reactiveSetter (newVal) {
  const value = getter ? getter.call(obj) : val
  
  if (newVal === value || (newVal !== newVal && value !== value)) {
    return
  }

  // 通知它的订阅者更新
  dep.notify()
}


  • 这段代码只做两件事:

    1.如果新值和旧值一致,则无需做任何事。

    2.如果新值和旧值不一致,则通知这个数据下的订阅者,也就是watcher实例更新

  • Notity方法就是遍历一下它的数组,然后执行数组里每个watcherupdate方法

update () {
    /* istanbul ignore else */
    if (this.lazy) {
      // 假设当前 发布者 通知 值被重新 set
      // 则把 dirty 设置为 true 当computed 被使用的时候 就可以重新调用计算
      // 渲染wacher 执行完毕 堆出后, 会轮到当前的渲染watcher执行update
      // 此时就会去执行queueWatcher(this), 再重新执行 组件渲染时候
      // 会用到计算属性, 在这时因为 dirty 为 true 所以能重新求值
      // dirty就像一个阀门, 用于判断是否应该重新计算
      this.dirty = true
    }
  }


  • 就在这里,**dirty**被重新设置为了**true**.
  • 总结一下dirty的流程:

一开始dirtytrue,一旦执行了一次计算,就会设置为false,然后当它定义的函数内部依赖的值发生了变化,则这个值就会重新变为true。怎么理解?就拿上面的this.num1this.num2来说,当二者其中一个变化了,dirty的值就变为true

  • 说了这么久dirty,那它到底有什么作用?简而言之,它就是用来记录我们依赖的值有没有变,如果变了就重新计算一下值,如果没变,那就返回以前的值。就像一个懒加载的理念,这也是计算属性缓存的一种方式。有聪明的同学又会问了,我们好像一直在让dirty变成true |false,好像实现逻辑完全跟缓存搭不着边,也完全没有涉及到计算属性函数的执行呀?那我们回头看看computedGetter函数
function computedGetter () {
    // 拿到 上述 创建的 watcher 实例
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      // 首次执行的时候 dirty 基于 lazy 所以是true
      if (watcher.dirty) {
        // 这个方法会执行一次计算
        // dirty 设置为 false
        // 这个函数执行完毕后, 当前 计算watcher就会推出
        watcher.evaluate()
      }
      // 如果当前激活的渲染watcher存在
      if (Dep.target) {
        /**
         * evaluate后求值的同时, 如果当前 渲染watcher 存在,
         * 则通知当前的收集了 计算watcher 的 dep 收集当前的 渲染watcher
         *
         *    为什么要这么做?
         * 假设这个计算属性是在模板中被使用的, 并且渲染watcher没有被对应的dep收集
         * 那派发更新的时候, 计算属性依赖的值发生改变, 而当前渲染watcher不被更新
         * 就会出现, 页面中的计算属性值没有发生改变的情况.
         *
         * 本质上计算属性所依赖的dep, 也可以看做这个属性值本身的dep实例.
         */
        watcher.depend()
      }
      return watcher.value
    }
  }

  • 这里有一段 Dep.target 的判断逻辑. 这是什么意思呢. Dep.target当前正在渲染组件. 它代指的是你定义的组件, 它也是一个**watcher**, 我们一般称之为**渲染watcher**.

    计算属性watcher, 被通知更新的时候, 会改变**dirty的值. 而渲染watcher**被通知更新的时候, 它就会更新一次页面.

    显然我们现在的问题是, 计算属性的**dirty重新变为ture了, 怎么让页面知道现在要重新刷新**了呢?

    通过**watcher.depend()** 这个方法会通知当前数据的**Dep实例去收集我们的渲染watcher. 将其收集起来.当数据发生变化的时候, 首先通知计算watcher更改drity值, 然后通知渲染watcher更新页面. 渲染watcher更新页面的时候, 如果在页面的HTML结果中我们用到了total这个属性. 就会触发它对应的computedGetter方法. 也就是执行上面这部分代码. 这时候drityture, 就能如期执行watcher.evaluate()**方法了。

  • 至此,computed属性的逻辑已经完毕,总结来说就是:computed属性缓存功能,实际上是通过一个dirty字段作为节流阀实现的,如果需要重新求值,阀门就打开,否则就一直返回原先的值,而无需重新计算。

watch

watch更多充当监控者的角色

  • 先看例子,当total发生变化时,handler函数就会被执行。
data() {
    return {
        total:99
    }
},
watch: {
    count: {
        hanlder(){
            console.log('total改变了')
        }
    }
}


  • 相同道理,在watch初始化的时候,肯定有一个initWatch函数,来初始化我们的监听属性,来到源码
// src/core/instance/state.js
function initWatch (vm: Component, watch: Object) {
  // 遍历我们定义的wathcer
  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)
    }
  }
}

  • 不难看出,当这个函数拿到我们所定义的watch对象total对象,然后拿到handler值,当然handler也可以是一个数组,然后传进createWatcher函数中,那么在这个过程中又做了什么呢?接着看
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)
}

  • 看得出来,它会解析我们传进来的handler对象,最后调用**$watch**实现监听,当然我们也可以直接通过这个方法实现监听。为什么呢?接着看
Vue.prototype.$watch = function (
    expOrFn: string | Function, // 这个可以是 key
    cb: any, // 待执行的函数
    options?: Object // 一些配置
  ): Function {
    const vm: Component = this
    // 创建一个 watcher 此时的 expOrFn 是监听对象
    const watcher = new Watcher(vm, expOrFn, cb, options)
    
    return function unwatchFn () {
      watcher.teardown()
    }
  }

  • 从代码看的出来,watch函数Vue实例原型上的一个方法,那么我们就可以通过this的形式去调用它。而watch函数**是Vue实例原型上的一个方法,那么我们就可以通过**this**的形式去调用它。而**watch属性就实例化了一个watcher对象,然后通过这个watcher实现了监听,这就是为什么watchcomputed本质上都是一个watcher对象的原因。那既然它跟computed都是watcher实例,那么本质上都是通过Vue响应式系统实现的监听,那是不容置疑的。好,到这里我们就要想一个问题,total的Dep实例,是什么时候收集这个watcher实例的?回看实例化时的代码
Vue.prototype.$watch = function (
    expOrFn: string | Function, 
    cb: any,
    options?: Object
  )


  • vm是组件实例, 也就是我们常用的this
  • expOrFn是在我们的Demo中就是total, 也就是被监听的属性
  • cb就是我们的handler函数
if (typeof expOrFn === 'function') {
  this.getter = expOrFn
} else {
  // 如果是一个字符则转为一个 一个 getter 函数
  // 这里这么做是为了通过 this.[watcherKey] 的形式
  // 能够触发 被监听属性的 依赖收集
  this.getter = parsePath(expOrFn)
  if (!this.getter) {
    this.getter = noop
    process.env.NODE_ENV !== 'production' && warn(
      `Failed watching path: "${expOrFn}" ` +
      'Watcher only accepts simple dot-delimited paths. ' +
      'For full control, use a function instead.',
      vm
    )
  }
}
this.value = this.lazy
  ? undefined
  : this.get()

  • 这是**watcher实例化的时候, 会默认执行的一串代码, 回想一下我们在computed实例化的时候传入的函数, 也是expOrFn.** 如果是一个函数会被直接赋予. 如果是一个字符串. 则**parsePath通过创建为一个函数. 大家不需要关注这个函数的行为, 它内部就是执行一次this.[expOrFn]. 也就是this.total**

  • 最后, 因为**lazyfalse. 这个值只有计算属性的时候才会被传true.所以首次会执行this.get()**. get里面则是执行一次getter()触发响应式

  • 到这里监听属性的初始化逻辑就算是完成了, 但是在数据更新的时候, 监听属性的触发还有与计算属性不一样的地方.

  • 监听属性是异步触发的,为什么呢?因为监听属性的执行逻辑和组件的渲染是一样的,他们都会放到一个nextTick函数中,放到下一次Tick中执行

总结

说了这么多关于这两座大山的相关内容,也该来总结一下了。

相同点

  • 本质上都是一个watcher实例,它们都通过响应式系统与数据,页面建立通信,只是行为不同
  • 计算属性和监听属性对于新值和旧值一样的赋值操作,都不会做任何变化,不过这一点的实现是在响应式系统完成的。
  • 它们都是以Vue的依赖追踪机制为基础的

不同点:

  • 计算属性具有“懒计算”功能,只有依赖的值变化了,才允许重新计算,成为"缓存",感觉不够准确。
  • 在数据更新时,计算属性的dirty状态会立即改变,而监听属性与组件重新渲染,至少会在下一个"Tick"执行。

#感谢

至此,本篇有关computed和watch属性的相关内容到此就结束啦,有什么补充的可以联系我哦!