Vue中computed的本質—lazy Watch

陳杰夫發表於2018-05-23

兩個月前我曾在掘金翻譯了一篇關於Vue中簡單介紹computed是如何工作的文章,翻譯的很一般所以我就不貼地址了。有位我非常敬佩的前輩對文章做了評價,內容就是本文的標題“感覺原文並沒有講清楚 computed 實現的本質- lazy watcher”。上週末正好研究一下Vue的原始碼,特意看了computed,把自己看的成果和大家分享出來。

Tips:如果你之前沒有看過Vue的原始碼或者不太瞭解Vue資料繫結的原理的話,推薦你看我之前的一篇文章簡單易懂的Vue資料繫結原始碼解讀,或者其他論壇部落格相關的文章都可以(這種文章網上非常多)。因為要看懂這篇文章,是需要這個知識點的。

一. initComputed 

首先,先假設傳入這樣的一組computed

//先假設有兩個data: data_one 和 data_two
computed:{
    isComputed:function(){
        return this.data_one + 1;
    },
    isMethods:function(){
        return this.data_two + this.data_one;
    }
}
複製程式碼

我們知道,在new Vue()的時候會做一系列初始化的操作,Vue中的data,props,methods,computed都是在這裡初始化的:

export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props) //初始化props
  if (opts.methods) initMethods(vm, opts.methods) //初始化methods
  if (opts.data) {
    initData(vm) //初始化data
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed) //初始化computed
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch) //初始化initWatch
  }
}
複製程式碼

我在資料繫結的那邊文章裡,詳細介紹了initData()這個函式,而這篇文章,我則重點深入initComputed()這個函式。

const computedWatcherOptions = { lazy: true } //用於傳入Watcher例項的一個物件

function initComputed (vm: Component, computed: Object) {
  //宣告一個watchers,同時掛載到Vue例項上
  const watchers = vm._computedWatchers = Object.create(null)
  //是否是伺服器渲染
  const isSSR = isServerRendering()

  //遍歷傳入的computed
  for (const key in computed) {
    //userDef是computed物件中的每一個方法
    const userDef = computed[key]
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    if (process.env.NODE_ENV !== 'production' && getter == null) {
      warn(
        `Getter is missing for computed property "${key}".`,
        vm
      )
    }
    
    //如果不是服務端渲染的,就建立一個Watcher例項
    if (!isSSR) {
      // create internal watcher for the computed property.
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      )
    }

    if (!(key in vm)) {
      //如果computed中的key沒有在vm中,通過defineComputed掛載上去
      defineComputed(vm, key, userDef)
    } else if (process.env.NODE_ENV !== 'production') {
      //後面都是警告computed中的key重名的
      if (key in vm.$data) {
        warn(`The computed property "${key}" is already defined in data.`, vm)
      } else if (vm.$options.props && key in vm.$options.props) {
        warn(`The computed property "${key}" is already defined as a prop.`, vm)
      }
    }
  }
}
複製程式碼

initComputed之前,我們看到宣告瞭一個computedWatcherOptions的物件,這個物件是實現"lazy Watcher"的關鍵。

接下來看initComputed,它先宣告瞭一個名為watchers的空物件,同時在vm上也掛載了這個空物件。之後遍歷計算屬性,並把每個屬性的方法賦給userDef,如果userDef是function的話就賦給getter,接著判斷是否是服務端渲染,如果不是的話就建立一個Watcher例項。Watcher例項我也在上一篇文章分析過,就不逐行分析了,不過需要注意的是,這裡新建的例項中我們傳入了第四個引數,也就是computedWatcherOptions,這時,Watcher中的邏輯就有變化了:

//這段程式碼在Watcher類中,檔案路徑為vue/src/core/observer/watcher.js
if (options) {
    this.deep = !!options.deep
    this.user = !!options.user
    this.lazy = !!options.lazy
    this.sync = !!options.sync
 } else {
    this.deep = this.user = this.lazy = this.sync = false
 }
複製程式碼

這裡的options指的就是computedWatcherOptions,當我們走initData的邏輯的時候,options並不存在,所以this.lazy = false,但當我們有了computedWatcherOptions後,this.lazy = true。同時,後面還有這樣一段程式碼:this.dirty = this.lazydirty的值也為true了。

this.value = this.lazy
      ? undefined
      : this.get()
複製程式碼

這段程式碼我們可以知道,當lazyfalse時,返回的是undefined而不是this.get()方法。也就是說,並不會執行computed中的兩個方法:(請看我開頭寫的computed示例)

function(){
  return this.data_one + 1;
}
function(){
  return this.data_two + this.data_one;
}
複製程式碼

這也就意味著,computed的值還並沒有更新。而這個邏輯也就暫時先告一段落。

二. defineProperty

讓我們再回到initComputed函式中來:

if (!(key in vm)) {
   //如果computed中的key沒有在vm中,通過defineComputed掛載上去
   defineComputed(vm, key, userDef)
} 複製程式碼

可以看到,當key值沒有掛載到vm上時,執行defineComputed函式:

//一個用來組裝defineProperty的物件
const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
}

export function defineComputed (
  target: any,
  key: string,
  userDef: Object | Function
) {
  //是否是服務端渲染,注意這個變數名 => shouldCache
  const shouldCache = !isServerRendering()
  if (typeof userDef === 'function') {
    //如果userDef是function,給sharedPropertyDefinition.get也就是當前key的getter
    //賦上createComputedGetter(key)
    sharedPropertyDefinition.get = shouldCache
      ? createComputedGetter(key)
      : userDef
    sharedPropertyDefinition.set = noop
  } else {
    //否則就使用userDef.get和userDef.set賦值
    sharedPropertyDefinition.get = userDef.get
      ? shouldCache && userDef.cache !== false
        ? createComputedGetter(key)
        : userDef.get
      : noop
    sharedPropertyDefinition.set = userDef.set
      ? userDef.set
      : noop
  }
  if (process.env.NODE_ENV !== 'production' &&
      sharedPropertyDefinition.set === noop) {
    sharedPropertyDefinition.set = function () {
      warn(
        `Computed property "${key}" was assigned to but it has no setter.`,
        this
      )
    }
  }
  //最後,我們把這個key掛載到vm上
  Object.defineProperty(target, key, sharedPropertyDefinition)
}
複製程式碼

defineComputed中,先判斷是否是服務端渲染,如果不是,說明計算屬性是需要快取的,即shouldCache是為true 。接下來,判斷userDef是否是函式,如果是就說明是我們常規computed的用法,將getter設為createComputedGetter(key)的返回值。如果不是函式,說明這個計算屬性是我們自定義的,需要使用userDef.getuserDef.set來為gettersetter賦值了,這個else部分我就不詳細說了,不會到自定義computed的朋友可以看文件計算屬性的setter。最後,將computed的這個key掛載到vm上,當你訪問這個計算屬性時就會呼叫getter。

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
    }
  }
}複製程式碼

最後我們來看createComputedGetter這個函式,他返回了一個函式computedGetter,此時如果watcher存在的情況下,判斷watcher.dirty是否存在,根據前面的分析,第一次新建Watcher例項的時候this.dirty是為true的,此時呼叫watcher.evaluate()

function evaluate () {
    this.value = this.get()
    this.dirty = false
}複製程式碼

this.get()實際上就是執行計算屬性的方法。之後將this.dirty設為false。另外,當我們執行this.get()時是會為Dep.target賦值的,所以還會執行watcher.depend(),將計算屬性的watcher新增到依賴中去。最後返回watcher.value,終於,我們獲取到了計算屬性的值,完成了computed的初始化。

三. 計算屬性的快取——lazy Watcher

不過,此時我們還並沒有解決本文的重點,也就是"lazy watcher"。還記得Vue官方文件是這樣形容computed的:

我們可以將同一函式定義為一個方法而不是一個計算屬性。兩種方式的最終結果確實是完全相同的。然而,不同的是計算屬性是基於它們的依賴進行快取的。計算屬性只有在它的相關依賴發生改變時才會重新求值。這就意味著只要 message 還沒有發生改變,多次訪問 reversedMessage 計算屬性會立即返回之前的計算結果,而不必再次執行函式。

回顧之前的程式碼,我們發現只要不更新計算屬性的中data屬性的值,在第一次獲取值後,watch.lazy始終為false,也就永遠不會執行watcher.evaluate(),所以這個計算屬性永遠不會重新求值,一直使用上一次獲得(也就是所謂的快取)的值。

一旦data屬性的值發生變化,根據我們知道會觸發update()導致頁面重新渲染(這部分內容有點跳,不清楚的朋友一定先弄懂data資料繫結的原理),重新initComputed,那麼this.dirty = this.lazy = true,計算屬性就會重新取值。

OK,關於computed的原理部分我就說完了,不過這篇文章還是留了個坑,在createComputedGetter函式中有這樣一行程式碼:

const watcher = this._computedWatchers && this._computedWatchers[key]複製程式碼

根據上下文我們可以推測出this._computedWatchers中肯定儲存著initComputed時建立的watcher例項,但什麼時候把這個例項放到this._computedWatchers中的呢?我還沒有找到,如果有知道的朋友請留言分享,大家一起討論,非常感謝!


相關文章