Vue原理解析(九):搞懂computed和watch原理,減少使用場景思考時間

飛躍瘋人院發表於2019-08-28

上一篇:Vue原理解析(八):一起搞明白令人頭疼的diff演算法

之前的章節,我們按照流程介紹了vue的初始化、虛擬Dom生成、虛擬Dom轉為真實Dom、深入理解響應式以及diff演算法等這些核心概念,對它內部的實現做了分析,這些都是偏底層的原理。接下來我們將介紹日常開發中經常使用的API的原理,進一步豐富對vue的認識,它們主要包括以下:

響應式相關APIthis.$watchthis.$setthis.$delete

事件相關APIthis.$onthis.$offthis.$oncethis.$emit

生命週期相關APIthis.$mountthis.$forceUpdatethis.$destroy

全域性APIVue.extendVue.nextTickVue.setVue.deleteVue.componentVue.useVue.mixinVue.compileVue.versionVue.directiveVue.filter

這一章節主要分析computedwatch屬性,對於接觸vue不久的朋友可能會對computedwatch有疑惑,什麼時候使用哪個屬性留有存疑,接下來我們將從內部實現的角度出發,徹底搞懂它們分別適用的場景。

  • this.$watch

這個API是我們之前介紹響應式時的Watcher類的一種封裝,也就是三種watcher中的user-watcher,監聽屬性經常會被這樣使用到:

export default {
  watch: {
    name(newName) {...}
  }
}
複製程式碼

其實它只是this.$watch這個API的一種封裝:

export default {
  created() {
    this.$watch('name', newName => {...})
  }
}
複製程式碼

監聽屬性初始化

為什麼這麼說,我們首先來看下初始化時watch屬性都做了什麼:

function initState(vm) {  // 初始化所有狀態時
  vm._watchers = []  // 當前例項watcher集合
  const opts = vm.$options  // 合併後的屬性
  
  ... // 其他狀態初始化
  
  if(opts.watch) {  // 如果有定義watch屬性
    initWatch(vm, opts.watch)  // 執行初始化方法
  }
}

---------------------------------------------------------

function initWatch (vm, watch) {  // 初始化方法
  for (const key in watch) {  // 遍歷watch內多個監聽屬性
    const handler = watch[key]  // 每一個監聽屬性的值
    if (Array.isArray(handler)) {  // 如果該項的值為陣列
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])  // 將每一項使用watcher包裝
      }
    } else {
      createWatcher(vm, key, handler) // 不是陣列直接使用watcher
    }
  }
}

---------------------------------------------------------

function createWatcher (vm, expOrFn, handler, options) {
  if (isPlainObject(handler)) { // 如果是物件,引數移位
    options = handler  
    handler = handler.handler
  }
  if (typeof handler === 'string') {  // 如果是字串,表示為方法名
    handler = vm[handler]  // 獲取methods內的方法
  }
  return vm.$watch(expOrFn, handler, options)  // 封裝
}
複製程式碼

以上對監聽屬性的多種不同的使用方式,都做了處理。使用示例在官網上均可找到:watch示例,這裡就不做過多的介紹了。可以看到最後是呼叫了vm.$watch方法。

監聽屬性實現原理

所以我們來看下$watch的內部實現:

Vue.prototype.$watch = function(expOrFn, cb, options = {}) {
  const vm = this
  if (isPlainObject(cb)) {  // 如果cb是物件,當手動建立監聽屬性時
    return createWatcher(vm, expOrFn, cb, options)
  }
  
  options.user = true  // user-watcher的標誌位,傳入Watcher類中
  const watcher = new Watcher(vm, expOrFn, cb, options)  // 例項化user-watcher
  
  if (options.immediate) {  // 立即執行
    cb.call(vm, watcher.value)  // 以當前值立即執行一次回撥函式
  }  // watcher.value為例項化後返回的值
  
  return function unwatchFn () {  // 返回一個函式,執行取消監聽
    watcher.teardown()
  }
}

---------------------------------------------------------------

export default {
  data() {
    return {
      name: 'cc'
    }  
  },
  created() {
    this.unwatch = this.$watch('name', newName => {...})
    this.unwatch()  // 取消監聽
  }
}
複製程式碼

雖然watch內部是使用this.$watch,但是我們也是可以手動呼叫this.$watch來建立監聽屬性的,所以第二個引數cb會出現是物件的情況。接下來設定一個標記位options.usertrue,表明這是一個user-watcher再給watch設定了immediate屬性後,會將例項化後得到的值傳入回撥,並立即執行一次回撥函式,這也是immediate的實現原理。最後的返回值是一個方法,執行後可以取消對該監聽屬性的監聽。接下來我們看看user-watcher是如何定義的:

class Watcher {
  constructor(vm, expOrFn, cb, options) {
    this.vm = vm
    vm._watchers.push(this)  // 新增到當前例項的watchers內
    
    if(options) {
      this.deep = !!options.deep  // 是否深度監聽
      this.user = !!options.user  // 是否是user-wathcer
      this.sync = !!options.sync  // 是否同步更新
    }
    
    this.active = true  // // 派發更新的標誌位
    this.cb = cb  // 回撥函式
    
    if (typeof expOrFn === 'function') {  // 如果expOrFn是函式
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)  // 如果是字串物件路徑形式,返回閉包函式
    }
    
    ...
    
  }
}
複製程式碼

當是user-watcher時,Watcher內部是以上方式例項化的,通常情況下我們是使用字串的形式建立監聽屬性,所以首先來看下parsePath方法是幹什麼的:

const bailRE = /[^\w.$]/  // 得是物件路徑形式,如info.name

function parsePath (path) {
  if (bailRE.test(path)) return // 不匹配物件路徑形式,再見
  
  const segments = path.split('.')  // 按照點分割為陣列
  
  return function (obj) {  // 閉包返回一個函式
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]  // 依次讀取到例項下物件末端的值
    }
    return obj
  }
}
複製程式碼

parsePath方法最終返回一個閉包方法,此時Watcher類中的this.getter就是一個函式了,再執行this.get()方法時會將this.vm傳入到閉包內,補全Watcher其他的邏輯:

class Watcher {
  constructor(vm, expOrFn, cb, options) {
    
    ...
    this.getter = parsePath(expOrFn)  // 返回的方法
    
    this.value = this.get()  // 執行get
  }
  
  get() {
    pushTarget(this)  // 將當前user-watcher例項賦值給Dep.target,讀取時收集它
    
    let value = this.getter.call(this.vm, this.vm)  // 將vm例項傳給閉包,進行讀取操作
    
    if (this.deep) {  // 如果有定義deep屬性
      traverse(value)  // 進行深度監聽
    }
    
    popTarget()
    return value  // 返回閉包讀取到的值,引數immediate使用的就是這裡的值
  }
  
  ...
  
}
複製程式碼

因為之前初始化已經將狀態已經全部都代理到了this下,所以讀取this下的屬性即可,比如:

export default {
  data() {  // data的初始化先與watch
    return {
      info: {
        name: 'cc'
      }
    }
  },
  created() {
    this.$watch('info.name', newName => {...})  // 何況手動建立
  }
}
複製程式碼

首先讀取this下的info屬性,然後讀取info下的name屬性。大家注意,這裡我們使用了讀取這個動詞,所以會執行之前包裝data響應式資料的get方法進行依賴收集,將依賴收集到讀取到的屬性的dep裡,不過收集的是user-watcherget方法最後返回閉包讀取到的值。

之後就是當info.name屬性被重新賦值時,走派發更新的流程,我們這裡把和render-watcher不同之處做單獨的說明,派發更新會執行Watcher內的update方法內:

class Watcher {
  constructor(vm, expOrFn, cb, options) {
    ...
  }
  
  update() {  // 執行派發更新
    if(this.sync) {  // 如果有設定sync為true
      this.run()  // 不走nextTick佇列,直接執行
    } else {
      queueWatcher(this)  // 否則加入佇列,非同步執行run()
    }
  }
  
  run() {
    if (this.active) {
      this.getAndInvoke(this.cb)  // 傳入回撥函式
    }
  }
  
  getAndInvoke(cb) {
    const value = this.get()  // 重新求值
    
    if(value !== this.value || isObject(value) || this.deep) {
      const oldValue = this.value  // 快取之前的值
      this.value = value  // 新值
      if(this.user) {  // 如果是user-watcher
        cb.call(this.vm, value, oldValue)  // 在回撥內傳入新值和舊值
      }
    }
  }
}
複製程式碼

其實這裡的sync屬性已經沒在官網做說明了,不過我們看到原始碼中還是保留了相關程式碼。接下來我們看到為什麼watch的回撥內可以得到新值和舊值的原理,因為cb.call(this.vm, value, oldValue)這句程式碼的原因,內部將新值和舊值傳給了回撥函式。

watch監聽屬性示例:
<template>  
  <div>{{name}}</div>
</template>

export default {  // App元件
  data() {
    return {
      name: 'cc'
    }
  },
  watch: {
    name(newName, oldName) {...}  // 派發新值和舊值給回撥
  },
  mounted() {
    setTimeout(() => {  
      this.name = 'ww'  // 觸發name的set
    }, 1000)
  }
}
複製程式碼

Vue原理解析(九):搞懂computed和watch原理,減少使用場景思考時間

監聽屬性的deep深度監聽原理

之前的get方法內有說明,如果有deep屬性,則執行traverse方法:

const seenObjects = new Set()  // 不重複新增

function traverse (val) {
  _traverse(val, seenObjects)
  seenObjects.clear()
}

function _traverse (val, seen) {
  let i, keys
  const isA = Array.isArray(val)  // val是否是陣列
  
  if ((!isA && !isObject(val))  // 如果不是array和object
        || Object.isFrozen(val)  // 或者是已經凍結物件
        || val instanceof VNode) {  // 或者是VNode例項
    return  // 再見
  }
  
  if (val.__ob__) {  // 只有object和array才有__ob__屬性
    const depId = val.__ob__.dep.id  // 手動依賴收集器的id
    if (seen.has(depId)) {  // 已經有收集過
      return  // 再見
    }
    seen.add(depId)  // 沒有被收集,新增
  }
  
  if (isA) {  // 是array
    i = val.length
    while (i--) {
      _traverse(val[i], seen)  // 遞迴觸發每一項的get進行依賴收集
    }
  } 
  
  else {  // 是object
    keys = Object.keys(val)
    i = keys.length
    while (i--) {
      _traverse(val[keys[i]], seen)  // 遞迴觸發子屬性的get進行依賴收集
    }
  }
}
複製程式碼

看著還挺複雜,簡單來說deep的實現原理就是遞迴的觸發陣列或物件的get進行依賴收集,因為只有陣列和物件才有__ob__屬性,也就是我們第七章說明的手動依賴管理器,將它們的依賴收集到Observer類裡的dep內,完成deep深度監聽。

watch總結:這裡說明了為什麼watchthis.$watch的實現是一致的,以及簡單解釋它的原理就是為需要觀察的資料建立並收集user-watcher,當資料改變時通知到user-watcher將新值和舊值傳遞給使用者自己定義的回撥函式。最後分析了定義watch時會被使用到的三個引數:syncimmediatedeep它們的實現原理。簡單說明它們的實現原理就是:sync是不將watcher加入到nextTick佇列而同步的更新、immediate是立即以得到的值執行一次回撥函式、deep是遞迴的對它的子值進行依賴收集。

  • this.$set

這個API已經在第七章的最後做了具體分析,大家可以前往this.$set實現原理查閱。

  • this.$delete

這個API也已經在第七章的最後做了具體分析,大家可以前往this.$delete實現原理查閱。

  • computed計算屬性

計算屬性不是API,但它是Watcher類的最後也是最複雜的一種例項化的使用,還是很有必要分析的。(vue版本2.6.10)其實主要就是分析計算屬性為何可以做到當它的依賴項發生改變時才會進行重新的計算,否則當前資料是被快取的。計算屬性的值可以是物件,這個物件需要傳入getset方法,這種並不常用,所以這裡的分析還是介紹常用的函式形式,它們之間是大同小異的,不過可以減少認知負擔,聚焦核心原理實現。

export default {
  computed: {
    newName: {  // 不分析這種了~
      get() {...},  // 內部會採用get屬性為計算屬性的值
      set() {...}
    }
  }
}
複製程式碼

計算屬性初始化

function initState(vm) {  // 初始化所有狀態時
  vm._watchers = []  // 當前例項watcher集合
  const opts = vm.$options  // 合併後的屬性
  
  ... // 其他狀態初始化
  
  if(opts.computed) {  // 如果有定義計算屬性
    initComputed(vm, opts.computed)  // 進行初始化
  }
  ...
}

---------------------------------------------------------------------------

function initComputed(vm, computed) {
  const watchers = vm._computedWatchers = Object.create(null) // 建立一個純淨物件
  
  for(const key in computed) {
    const getter = computed[key]  // computed每項對應的回撥函式
    
    watchers[key] = new Watcher(vm, getter, noop, {lazy: true})  // 例項化computed-watcher
    
    ...
    
  }
}
複製程式碼

計算屬性實現原理

這裡還是按照慣例,將定義的computed屬性的每一項使用Watcher類進行例項化,不過這裡是按照computed-watcher的形式,來看下如何例項化的:

class Watcher{
  constructor(vm, expOrFn, cb, options) {
    this.vm = vm
    this._watchers.push(this)
    
    if(options) {
      this.lazy = !!options.lazy  // 表示是computed
    }
    
    this.dirty = this.lazy  // dirty為標記位,表示是否對computed計算
    
    this.getter = expOrFn  // computed的回撥函式
    
    this.value = undefined
  }
}
複製程式碼

這裡就點到為止,例項化已經結束了。並沒有和之前render-watcher以及user-watcher那般,執行get方法,這是為什麼?我們接著分析為何如此,補全之前初始化computed的方法:

function initComputed(vm, computed) {
  ...
  
  for(const key in computed) {
    const getter = computed[key]  // // computed每項對應的回撥函式
    ...
    
    if (!(key in vm)) {
      defineComputed(vm, key, getter)
    }
    
    ... key不能和data裡的屬性重名
    ... key不能和props裡的屬性重名
  }
}
複製程式碼

這裡的App元件在執行extend建立子元件的建構函式時,已經將key掛載到vm的原型中了,不過之前也是執行的defineComputed方法,所以不妨礙我們看它做了什麼:

function defineComputed(target, key) {
  ...
  Object.defineProperty(target, key, {
    enumerable: true,
    configurable: true,
    get: createComputedGetter(key),
    set: noop
  })
}
複製程式碼

這個方法的作用就是讓computed成為一個響應式資料,並定義它的get屬性,也就是說當頁面執行渲染訪問到computed時,才會觸發get然後執行createComputedGetter方法,所以之前的點到為止再這裡會續上,看下get方法是怎麼定義的:

function createComputedGetter (key) { // 高階函式
  return function () {  // 返回函式
    const watcher = this._computedWatchers && this._computedWatchers[key]
    // 原來this還可以這樣用,得到key對應的computed-watcher
    
    if (watcher) {
      if (watcher.dirty) {  // 在例項化watcher時為true,表示需要計算
        watcher.evaluate()  // 進行計算屬性的求值
      }
      if (Dep.target) {  // 當前的watcher,這裡是頁面渲染觸發的這個方法,所以為render-watcher
        watcher.depend()  // 收集當前watcher
      }
      return watcher.value  // 返回求到的值或之前快取的值
    }
  }
}

------------------------------------------------------------------------------------

class Watcher {
  ...
  
  evaluate () {
    this.value = this.get()  //  計算屬性求值
    this.dirty = false  // 表示計算屬性已經計算,不需要再計算
  }
  
  depend () {
    let i = this.deps.length  // deps內是計算屬性內能訪問到的響應式資料的dep的陣列集合
    while (i--) {
      this.deps[i].depend()  // 讓每個dep收集當前的render-watcher
    }
  }
}
複製程式碼

這裡的變數watcher就是之前computed對應的computed-watcher例項,接下來會執行Watcher類專門為計算屬性定義的兩個方法,在執行evaluate方法進行求值的過程中又會觸發computed內可以訪問到的響應式資料的get,它們會將當前的computed-watcher作為依賴收集到自己的dep裡,計算完畢之後將dirty置為false,表示已經計算過了。

然後執行depend讓計算屬性內的響應式資料訂閱當前的render-watcher,所以computed內的響應式資料會收集computed-watcherrender-watcher兩個watcher,當computed內的狀態發生變更觸發set後,首先通知computed需要進行重新計算,然後通知到檢視執行渲染,再渲染中會訪問到computed計算後的值,最後渲染到頁面。

Ps: 計算屬性內的值須是響應式資料才能觸發重新計算。

computed內的響應式資料變更後觸發的通知:

class Watcher {
  ...
  update() {  // 當computed內的響應式資料觸發setif(this.lazy) {
      this.diray = true  // 通知computed需要重新計算了
    }
    ...
  }
}
複製程式碼

最後還是以一個示例結合流程圖來幫大家理清楚這裡的邏輯:

export default {
  data() {
    return {
      manName: "cc",
      womanName: "ww"
    };
  },
  computed: {
    newName() {
      return this.manName + ":" + this.womanName;
    }
  },
  methods: {
    changeName() {
      this.manName = "ss";
    }
  }
};
複製程式碼

Vue原理解析(九):搞懂computed和watch原理,減少使用場景思考時間

watch總結:為什麼計算屬性有快取功能?因為當計算屬性經過計算後,內部的標誌位會表明已經計算過了,再次訪問時會直接讀取計算後的值;為什麼計算屬性內的響應式資料發生變更後,計算屬性會重新計算?因為內部的響應式資料會收集computed-watcher,變更後通知計算屬性要進行計算,也會通知頁面重新渲染,渲染時會讀取到重新計算後的值。

最後按照慣例我們還是以一道vue可能會被問到的面試題作為本章的結束~

面試官微笑而又不失禮貌的問道:

  • 請問computed屬性和watch屬性分別什麼場景使用?

懟回去:

  • 當模板中的某個值需要通過一個或多個資料計算得到時,就可以使用計算屬性,還有計算屬性的函式不接受引數;監聽屬性主要是監聽某個值發生變化後,對新值去進行邏輯處理。

下一篇: Vue原理解析(十):搞懂事件API原理及在元件庫中的妙用

順手點個贊或關注唄,找起來也方便~

參考:

Vue.js原始碼全方位深入解析

Vue.js深入淺出

分享一個元件庫給大家,可能會用的上 ~ ↓

你可能會用的上的一個vue功能元件庫,持續完善中...

相關文章