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

上一篇:Vue原理解析(八):一塊兒搞明白使人頭疼的diff算法html

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

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

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

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

全局APIVue.extendVue.nextTickVue.setVue.deleteVue.componentVue.useVue.mixinVue.compileVue.versionVue.directiveVue.filter數組

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

  • this.$watch

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

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)
  }
}
複製代碼

監聽屬性的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";
    }
  }
};
複製代碼

watch總結:爲何計算屬性有緩存功能?由於當計算屬性通過計算後,內部的標誌位會代表已經計算過了,再次訪問時會直接讀取計算後的值;爲何計算屬性內的響應式數據發生變動後,計算屬性會從新計算?由於內部的響應式數據會收集computed-watcher,變動後通知計算屬性要進行計算,也會通知頁面從新渲染,渲染時會讀取到從新計算後的值。

最後按照慣例咱們仍是以一道vue可能會被問到的面試題做爲本章的結束~

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

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

懟回去:

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

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

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

參考:

Vue.js源碼全方位深刻解析

Vue.js深刻淺出

Vue.js組件精講

剖析 Vue.js 內部運行機制

相關文章
相關標籤/搜索