Vue.js 深入理解 computed 与 watch

前言

vue中computed和watch是vue.js开发者的利器,也是面试必问的题目之一,问题的答案也是可深可浅,可以反应回答者对个这个问题的认识程度(类似于输入url到页面渲染发生了哪些事情)


分析

1 用法上的区别:

我的理解是,用到computed往往是我们需要使用他的值(vm[computedKey]),这个值是多个值求值的结果,相当于是我们保存了计算过程,计算过程中使用过的值发生变化时,会触发重新执行computed[key]函数(或者computed[key].get),例如:

vm = new Vue({
  el: '#demo',
  data: {
    firstName: 'Foo',
    lastName: 'Bar'
  },
  computed: {
    fullName: function () {
      return this.firstName + ' ' + this.lastName
    }
  }
})

fullName就是我们需要的值,fullName依赖this.firstNamethis.lastName,这两个依赖值变化时会触发computed函数重新执行求值。如果该需求使用watch就是这样子的:

var vm = new Vue({
  el: '#demo',
  data: {
    firstName: 'Foo',
    lastName: 'Bar',
    fullName: 'Foo Bar'
  },
  watch: {
    firstName: function (val) {
      this.fullName = val + ' ' + this.lastName
    },
    lastName: function (val) {
      this.fullName = this.firstName + ' ' + val
    }
  }
})

与computed相比,watch实现这种需求显得很繁琐。
watch的使用场景如他的名字一样: 观察。webpack中可以在config中或者命令行模式中使用watch字段:
webpack.config.js

module.exports = {
  //...
  watch: true
};

命令行

webpack --watch

达到的效果是:当前执行目录(process.cwd())里面文件发生改变时,webpack能检测到他变化了,重新打包和热更新。
vue.js中也是如此,我们观察某个值变化,这个值变化了,我们来做相应的事情。
例如:
组件的v-model语法糖:

{
    props: {
        value: {
            type: String,
            required: true
        }
    },
    data () {
        return {
            text: ''
        }
    },
    watch: {
        // 父组件中可能改变value绑定的值
        value (val) {
            this.text = val
        },
        text (val) {
            this.$emit('input', val)
        }
    }
}

一句话概括就是: computed[key]这个值我们需要用到它,依赖变化运算过程自动执行,watch[key]这个值我们不需要用到它,它变化了我们想做一些事情。
当然,理论上来说computed能实现上面的需求:

computed: {
   // 这里xxx我们还需要使用到,不然无法触发求值
    xxx () {
        this.value // 这里啥都不做就是想做个依赖收集
        this.text  // 同上
        // this.text和this.value旧值都需要缓存起来
        if (this.value !== this.value的旧值) {
            this.text = this.value
        }
        if (this.text !== this.text的旧值) {
            this.$emit('input', this.text)
        }
    }
}

这样实现太繁琐,所以合适的场景使用合适的api的,这样才符合设计的初衷。有些场景两者使用没有多大区别。

2 源码分析:

看过源码的同学都清楚,watch和computed的每一项最终都会执行new Watcher生成一个watcher实例,执行上面会有一些差异。下面开始从源码分析一下:

注意:vue.js每个版本可能会更改一些逻辑,当前分析版本: v2.6.11 web版,下文中提到的vm[key]相当于我们在vue中使用的this.xxx属性值

当执行 new Vue({})的时候或者生成组件实例,(组件会类似Copm extend Vue 派生出组件构造类在Vue上),最终都会执行_init()逻辑,如下(这里其他逻辑省略):

_init () {
    ...
    initState(vm)
    ...
}

initState () {
   ...
   initComputed(vm, opts.computed)
   initWatch(vm, opts.watch)
   ...
}

1. watch:

function initWatch (vm, watch) {
    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,expOrFn,handler,options) {
  if (isPlainObject(handler)) { // handler 是否为对象
    // watch[key]可以是函数或者对象
    options = handler
    handler = handler.handler
  }
  //
  if (typeof handler === 'string') {
    handler = vm[handler]
  }
  return vm.$watch(expOrFn, handler, options)
}

Vue.prototype.$watch = function (expOrFn, cb, options) {
    options = options || {}
    options.user = true
    const watcher = new Wacter(vm, expOrFn,cb, options)
    if (options.immediate) {
      cb.call(vm, watcher.value)
    }
}

 class Watcher {
  constructor (vm, expOrFn, cb, options) {
   // 保留关键代码
    if (options) {
      this.user = !!options.user
      this.lazy = !!options.lazy
    }
    this.cb = cb
    this.active = true
    this.id = ++uid // uid for batching
    // parse expression for getter
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      // 用户watch逻辑下 expOrFn 为watch[key]的key,类型为 string
      this.getter = parsePath(expOrFn)
    }
    this.value = this.lazy
      ? undefined
      : this.get()
    }
    get () {
      // 这里做的事情是 Dep.target = this
      pushTarget(this)
      let value
      const vm = this.vm
      // 防止用户(你)做傻事让js报错终止运行
      try {
        // 访问了 vm.obj.a.b
        value = this.getter.call(vm, vm)
      } catch (e) {
        if (this.user) {
          handleError(e, vm, `getter for watcher "${this.expression}"`)
        } else {
          throw e
        }
      } finally {
        if (this.deep) {
          // deep 对obj的每个obj[key]访问 触发依赖收集
          traverse(value)
        }
        // Dep.target = 上一个watcher 实例
        popTarget()
      }
      return value
    }
    addDep (dep) {
      // 一个dep 在一个watcher上只添加一次
      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)
        }
      }
    }
    update () {
      if (this.lazy) {
        this.dirty = true
      } else if (this.sync) {
        this.run()
      } else {
        queueWatcher(this)
      }
    }
    
    run () {
      if (this.active) {
        const value = this.get()
        if (
          value !== this.value ||
          isObject(value) ||
          this.deep
        ) {
          const oldValue = this.value
          this.value = value
          if (this.user) {
            try {
              this.cb.call(this.vm, value, oldValue)
            }
          } 
        }
      }
    }
  }
  
  function parsePath (path) {
    // 这个函数的目的是返回我们需要观察的那个值的求值函数
    /*
    我们的定义watch可能是 watch: {
        'obj.a.b.c': {
            handler () {}
        }
    }
    */
    const segments = path.split('.')
    return function (obj) {
      for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]
      }
      return obj
    }
}

// 响应式核心代码
// 一个值有一个dep实例
const dep = new Dep()
Object.defineProperty(obj, key, {
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      dep.notify()
    }
  })
  
  class Dep {
  constructor () {
    this.id = uid++
    this.subs = []
  }

  addSub (sub) {
    this.subs.push(sub)
  }
  removeSub (sub) {
    remove(this.subs, sub)
  }

  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  notify () {
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      // 先创建的先执行 用户watcher computed watcher 在渲染watcher之前
      subs.sort((a, b) => a.id - b.id)
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

流程大概是这样的:

  1. _init -> initWath -> createWatcher(vm, key, handler) -> vm.$watch -> new Watch() -> this.get

  2. this.get()的时候,会执行const value = getter ? getter.call(obj) : valpushTarget(this)(做的事情:Dep.target =this),getter就是对我们要观测到的值访问值(比如:'obj.a.b' => obj.a.b),会触发obj.a.b的get劫持。针对deep的情况会进一步的递归访问值,触发get劫持。

  3. 执行: dep.depend() -> Dep.target.addDep(dep实例) ->dep.addSub(当前watcher实例),依赖收集完成。


  1. 派发更新逻辑开始, 当obj.a.b的值发生改变时,会触发set函数,执行dep.notify -> subs[i].update() -> watcher实例.update() -> queueWatcher(push到watcher队列,排序watcher)->nextTick(flushSchedulerQueue)(下个tick执行watcher队列的)->watcher.run()
    (后面的代码分支有点多,就不一一贴上了)

  2. 执行this.get,相当于执行了第二步的,逻辑,比较新旧值是否相等(value基础类型,引用类型或者deep直接执行接下来的逻辑),执行this.cb.call(this.vm, value, oldValue),this.cb就是用户定义的watch[key]的函数。所以我们在定义watch函数的时候第一个参数是newValue 第二个参数是oldValue

总结:我们在Vue.js中使用的watch是userWatch,我们观测某个属性的变化,监测逻辑和渲染时的依赖收集一样:dep添加watch,这个值变化了通知所有的watch, 最后会执行我们的定义的watch[key]的handler函数。

提示:<i>渲染watcher类似与上面的watcher,监测的是template中用的值,只要有一个值发生变化,watcher就会触发,重新渲染。</i>

2. computed:

const computedWatcherOptions = { lazy: true }
function initComputed (vm, computed) {
  for (const key in computed) {
    // 不考虑设置了computed get set 
    const userDef = computed[key]
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    if (!isSSR) {
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      )
    }
    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    }
  }
}

class Watcher {
  constructor (vm,expOrFn,cb,options) {
    this.vm = vm
    vm._watchers.push(this)
    if (options) {
      this.lazy = !!options.lazy
    }
    this.cb = cb
    this.id = ++uid // uid for batching
    this.active = true
    this.dirty = this.lazy // for lazy watchers
    this.deps = []
    this.newDeps = []
    this.depIds = new Set()
    this.newDepIds = new Set()
    this.getter = expOrFn // 用户computed[key]的值
    // computed 不执行this.get
    this.value = undefined
  }
  get () {
    // Dep.target = 当前watcher
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      value = this.getter.call(vm, vm)
    }
    // Dep.target设置为上一个watcher 渲染watcher
    popTarget()
    this.cleanupDeps()
    return value
  }
  evaluate () {
    this.value = this.get()
    this.dirty = false
  }

  /**
   * Depend on all deps collected by this watcher.
   */
  depend () {
    let i = this.deps.length
    while (i--) {
      this.deps[i].depend()
    }
  }
  
  notify () {
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      // 先创建的先执行 用户watcher computed watcher 在渲染watcher之前
      subs.sort((a, b) => a.id - b.id)
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
  update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
}
  
function defineComputed (
  target: any,
  key: string,
  userDef: Object | Function
) {
  if (typeof userDef === 'function') {
    sharedPropertyDefinition.get =  createComputedGetter(key)
    sharedPropertyDefinition.set = noop // noop 为空函数
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

function createComputedGetter (key) {
  return function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        // 确保执行一次
        watcher.evaluate()
      }
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

大概的流程是:

  1. initComputed遍历options.computed对象执行new Watcher和defineComputed
  • new Watcher: new Watcher -> 只会执行Watcher类的构造函数

注意:组件这个逻辑会在Vue.extend()(Vue派生的组件构造类)过程中执行,这里分析根节点的

  • defineComputed: sharedPropertyDefinition.get = createComputedGetter(key)(生成vm[key] computed[key] 的get劫持函数),通过object.defineProperty设置vm[key]的get和set,所以能通过this[key]的方式访问computed的值
  1. 当我们组件中使用到computed[key]即是vm[key]或this[key] (如:前面提到的fullName),触发createComputedGetter(key)生成的get函数:
  • watcher.evaluate() -> this.get() -> Dep.target = 当前的watcher实例(computed[key]生成的) -> 执行this.getter(用户定义的computed[key]) -> 触发函数里面使用this.xxx(如:fullName () {return this.firstName + this.lastName})的get劫持函数和上面的watcher一样: firstName和lastName都会收集该watcher -> this.dirty = false(项目中多个地方用到了该computed[key],watcher.evaluate()只需要执行一次)
  • watcher.depend() -> watcher.dep[i].depend() computed[key]使用到的一个值(firstName lastName)就拥有一个dep,(deps包含了firstName和lastName的dep)->
    Dep.target.addDep(this) 上一步的时候Dep.target已经设置为上一个watcher了,即是渲染watcher ->
    这些dep也会收集渲染watcher
  • return watcher.value computed[key] (vm[key]或this[key])就是watcher.value
  1. 当computed 依赖的这些值(fistName或者lastName发生变化)发生变化时,触发set逻辑 -> dep.notify 通过id排序 确保computed watcher先执行,dep订阅的watcher遍历执行update -> this.dirty = true -> 执行渲染watcher.update -> 组件template重新渲染 -> 再执行第2步保持值为最新值。

注意:这个版本好像computed没有之前所谓的缓存,newVal oldVal不会比较了,依赖的值发生改变,重新求值。

而且vue的官方文档中也提到:

不同的是计算属性是基于它们的响应式依赖进行缓存的。只在相关响应式依赖发生改变时它们才会重新求值。这就意味着只要 message 还没有发生改变,多次访问 reversedMessage 计算属性会立即返回之前的计算结果,而不必再次执行函数。

之前的版本我记得是这样的, c () {return this.a + this.b},a = 1, b =2 -> a =2, b=1,最终的值不变就会缓存,现在不再说computed会比较新旧值了,而是说明依赖发生改变,computed就重新求值。

总结:

  1. watcher和dep相互收集,我们定义的data中的一个属性(基础类型,引用类型递归创建dep, 确保一个基础类型一个dep)拥有一个dep,dep会收集所有的watcher(渲染watcher 、computed watcher 、用户定义的watcher), watcher也会记录被哪些dep收集了,当然这个过程中会有一个去重处理, data.xxx发生变化会通知所有的watcher, dep是obj.xxx和watcher的一个桥梁。
  2. watch(用户watcher)和computed的异同点:
  • 相同点:computed[key]和user watcher都会生成一个watcher实例。

  • 不同点

    1. dep不同:watch监听的是vm[key]的变化,vm[key]的dep, vm[key]变化触发watch.get求值,触发watcher回调函数。computed中的dep是computed[key]执行过程中访问的dep,即用到了哪些值。
    2. dep收集的对象不同: 执行用户watcher.get的时候,watch的[key] (vm[key])的dep只会收集当然watcher,computed watcher中的dep会收集渲染watcher和computed watcher
    3. 执行时机不同: watcher immediate除外,computed[key]在我们使用到时会触发getter,触发watcher.get()执行computed[key], watch则只会在vm[key]改变触发this.cb即watch中定义的handler。

最后:

  • 如果有错误欢迎指出
  • 如有帮助欢迎点赞_
  • vue源码分析附上我总结的思维导图


    Vue源码分析.png
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容