1. 程式人生 > 實用技巧 >淺談Vue中計算屬性computed的實現原理

淺談Vue中計算屬性computed的實現原理

雖然目前的技術棧已由Vue轉到了React,但從之前使用Vue開發的多個專案實際經歷來看還是非常愉悅的,Vue文件清晰規範,api設計簡潔高效,對前端開發人員友好,上手快,甚至個人認為在很多場景使用Vue比React開發效率更高,之前也有斷斷續續研讀過Vue的原始碼,但一直沒有梳理總結,所以在此做一些技術歸納同時也加深自己對Vue的理解,那麼今天要寫的便是Vue中最常用到的API之一computed的實現原理。

基本介紹

話不多說,一個最基本的例子如下:

<div id="app">
    <p>{{fullName}}</p>
</div>
new Vue({
    data: {
        firstName: 'Xiao',
        lastName: 'Ming'
    },
    computed: {
        fullName: function () {
            return this.firstName + ' ' + this.lastName
        }
    }
})

Vue中我們不需要在template裡面直接計算{{this.firstName + ' ' + this.lastName}},因為在模版中放入太多宣告式的邏輯會讓模板本身過重,尤其當在頁面中使用大量複雜的邏輯表示式處理資料時,會對頁面的可維護性造成很大的影響,而computed的設計初衷也正是用於解決此類問題。

對比偵聽器watch

當然很多時候我們使用computed時往往會與Vue中另一個API也就是偵聽器watch相比較,因為在某些方面它們是一致的,都是以Vue的依賴追蹤機制為基礎,當某個依賴資料發生變化時,所有依賴這個資料的相關資料或函式都會自動發生變化或呼叫。

雖然計算屬性在大多數情況下更合適,但有時也需要一個自定義的偵聽器。這就是為什麼 Vue 通過watch
選項提供了一個更通用的方法來響應資料的變化。當需要在資料變化時執行非同步或開銷較大的操作時,這個方式是最有用的。

從vue官方文件對watch的解釋我們可以瞭解到,使用watch選項允許我們執行非同步操作 (訪問一個API)或高消耗效能的操作,限制我們執行該操作的頻率,並在我們得到最終結果前,設定中間狀態,而這些都是計算屬性無法做到的。

下面還另外總結了幾點關於computedwatch的差異:
  1. computed是計算一個新的屬性,並將該屬性掛載到vm(Vue例項)上,而watch是監聽已經存在且已掛載到vm上的資料,所以用watch同樣可以監聽computed計算屬性的變化(其它還有data
    props
  2. computed本質是一個惰性求值的觀察者,具有快取性,只有當依賴變化後,第一次訪問computed屬性,才會計算新的值,而watch則是當資料發生變化便會呼叫執行函式
  3. 從使用場景上說,computed適用一個數據被多個數據影響,而watch適用一個數據影響多個數據;

以上我們瞭解了computedwatch之間的一些差異和使用場景的區別,當然某些時候兩者並沒有那麼明確嚴格的限制,最後還是要具體到不同的業務進行分析。

原理分析

言歸正傳,回到文章的主題computed身上,為了更深層次地瞭解計算屬性的內在機制,接下來就讓我們一步步探索Vue原始碼中關於它的實現原理吧。

在分析computed原始碼之前我們先得對Vue的響應式系統有一個基本的瞭解,Vue稱其為非侵入性的響應式系統,資料模型僅僅是普通的JavaScript物件,而當你修改它們時,檢視便會進行自動更新。

當你把一個普通的 JavaScript 物件傳給 Vue 例項的data選項時,Vue 將遍歷此物件所有的屬性,並使用Object.defineProperty把這些屬性全部轉為getter/setter,這些getter/setter對使用者來說是不可見的,但是在內部它們讓 Vue 追蹤依賴,在屬性被訪問和修改時通知變化,每個元件例項都有相應的watcher例項物件,它會在元件渲染的過程中把屬性記錄為依賴,之後當依賴項的setter被呼叫時,會通知watcher重新計算,從而致使它關聯的元件得以更新。

Vue響應系統,其核心有三點:observewatcherdep

  1. observe:遍歷data中的屬性,使用Object.definePropertyget/set方法對其進行資料劫持
  2. dep:每個屬性擁有自己的訊息訂閱器dep,用於存放所有訂閱了該屬性的觀察者物件
  3. watcher:觀察者(物件),通過dep實現對響應屬性的監聽,監聽到結果後,主動觸發自己的回撥進行響應

對響應式系統有一個初步瞭解後,我們再來分析計算屬性。
首先我們找到計算屬性的初始化是在src/core/instance/state.js檔案中的initState函式中完成的

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

呼叫了initComputed函式(其前後也分別初始化了initDatainitWatch)並傳入兩個引數vm例項和opt.computed開發者定義的computed選項,轉到initComputed函式:

const computedWatcherOptions = { computed: true }

function initComputed (vm: Component, computed: Object) {
  // $flow-disable-line
  const watchers = vm._computedWatchers = Object.create(null)
  // computed properties are just getters during SSR
  const isSSR = isServerRendering()

  for (const key in 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
      )
    }

    if (!isSSR) {
      // create internal watcher for the computed property.
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      )
    }

    // component-defined computed properties are already defined on the
    // component prototype. We only need to define computed properties defined
    // at instantiation here.
    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    } else if (process.env.NODE_ENV !== 'production') {
      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)
      }
    }
  }
}

從這段程式碼開始我們觀察這幾部分:

  1. 獲取計算屬性的定義userDefgetter求值函式

    const userDef = computed[key]
    const getter = typeof userDef === 'function' ? userDef : userDef.get

    定義一個計算屬性有兩種寫法,一種是直接跟一個函式,另一種是新增setget方法的物件形式,所以這裡首先獲取計算屬性的定義userDef,再根據userDef的型別獲取相應的getter求值函式。

  2. 計算屬性的觀察者watcher和訊息訂閱器dep

    watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
    )

    這裡的watchers也就是vm._computedWatchers物件的引用,存放了每個計算屬性的觀察者watcher例項(注:後文中提到的“計算屬性的觀察者”、“訂閱者”和watcher均指代同一個意思但注意和Watcher建構函式區分),Watcher建構函式在例項化時傳入了4個引數:vm例項、getter求值函式、noop空函式、computedWatcherOptions常量物件(在這裡提供給Watcher一個標識{computed:true}項,表明這是一個計算屬性而不是非計算屬性的觀察者,我們來到Watcher建構函式的定義:

    class Watcher {
      constructor (
        vm: Component,
        expOrFn: string | Function,
        cb: Function,
        options?: ?Object,
        isRenderWatcher?: boolean
      ) {
        if (options) {
          this.computed = !!options.computed
        } 
    
        if (this.computed) {
          this.value = undefined
          this.dep = new Dep()
        } else {
          this.value = this.get()
        }
      }
      
      get () {
        pushTarget(this)
        let value
        const vm = this.vm
        try {
          value = this.getter.call(vm, vm)
        } catch (e) {
          
        } finally {
          popTarget()
        }
        return value
      }
      
      update () {
        if (this.computed) {
          if (this.dep.subs.length === 0) {
            this.dirty = true
          } else {
            this.getAndInvoke(() => {
              this.dep.notify()
            })
          }
        } else if (this.sync) {
          this.run()
        } else {
          queueWatcher(this)
        }
      }
    
      evaluate () {
        if (this.dirty) {
          this.value = this.get()
          this.dirty = false
        }
        return this.value
      }
    
      depend () {
        if (this.dep && Dep.target) {
          this.dep.depend()
        }
      }
    }

    為了簡潔突出重點,這裡我手動去掉了我們暫時不需要關心的程式碼片段。
    觀察Watcherconstructor,結合剛才講到的new Watcher傳入的第四個引數{computed:true}知道,對於計算屬性而言watcher會執行if條件成立的程式碼this.dep = new Dep(),dep也就是建立了該屬性的訊息訂閱器。

    export default class Dep {
      static target: ?Watcher;
      subs: Array<Watcher>;
    
      constructor () {
        this.id = uid++
        this.subs = []
      }
    
      addSub (sub: Watcher) {
        this.subs.push(sub)
      }
    
      depend () {
        if (Dep.target) {
          Dep.target.addDep(this)
        }
      }
    
      notify () {
        const subs = this.subs.slice()
        for (let i = 0, l = subs.length; i < l; i++) {
          subs[i].update()
        }
      }
    }
    
    Dep.target = null
     

    dep同樣精簡了部分程式碼,我們觀察Watcherdep的關係,用一句話總結

    watcher中例項化了dep並向dep.subs中添加了訂閱者,dep通過notify遍歷了dep.subs通知每個watcher更新。
  3. defineComputed定義計算屬性

    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    } else if (process.env.NODE_ENV !== 'production') {
      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)
      }
    }

    因為computed屬性是直接掛載到例項物件中的,所以在定義之前需要判斷物件中是否已經存在重名的屬性,defineComputed傳入了三個引數:vm例項、計算屬性的key以及userDef計算屬性的定義(物件或函式)。
    然後繼續找到defineComputed定義處:

    export function defineComputed (
      target: any,
      key: string,
      userDef: Object | Function
    ) {
      const shouldCache = !isServerRendering()
      if (typeof userDef === 'function') {
        sharedPropertyDefinition.get = shouldCache
          ? createComputedGetter(key)
          : userDef
        sharedPropertyDefinition.set = noop
      } else {
        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
          )
        }
      }
      Object.defineProperty(target, key, sharedPropertyDefinition)
    }

    在這段程式碼的最後呼叫了原生Object.defineProperty方法,其中傳入的第三個引數是屬性描述符sharedPropertyDefinition,初始化為:

    const sharedPropertyDefinition = {
      enumerable: true,
      configurable: true,
      get: noop,
      set: noop
    }

    隨後根據Object.defineProperty前面的程式碼可以看到sharedPropertyDefinitionget/set方法在經過userDefshouldCache等多重判斷後被重寫,當非服務端渲染時,sharedPropertyDefinitionget函式也就是createComputedGetter(key)的結果,我們找到createComputedGetter函式呼叫結果並最終改寫sharedPropertyDefinition大致呈現如下:

    sharedPropertyDefinition = {
        enumerable: true,
        configurable: true,
        get: function computedGetter () {
            const watcher = this._computedWatchers && this._computedWatchers[key]
            if (watcher) {
                watcher.depend()
                return watcher.evaluate()
            }
        },
        set: userDef.set || noop
    }

    當計算屬性被呼叫時便會執行get訪問函式,從而關聯上觀察者物件watcher


分析完以上步驟,我們再來梳理下整個流程:

  1. 當元件初始化的時候,computeddata會分別建立各自的響應系統,Observer遍歷data中每個屬性設定get/set資料攔截
  2. 初始化computed會呼叫initComputed函式

    1. 註冊一個watcher例項,並在內例項化一個Dep訊息訂閱器用作後續收集依賴(比如渲染函式的watcher或者其他觀察該計算屬性變化的watcher
    2. 呼叫計算屬性時會觸發其Object.definePropertyget訪問器函式
    3. 呼叫watcher.depend()方法向自身的訊息訂閱器depsubs中新增其他屬性的watcher
    4. 呼叫watcherevaluate方法(進而呼叫watcherget方法)讓自身成為其他watcher的訊息訂閱器的訂閱者,首先將watcher賦給Dep.target,然後執行getter求值函式,當訪問求值函式裡面的屬性(比如來自dataprops或其他computed)時,會同樣觸發它們的get訪問器函式從而將該計算屬性的watcher新增到求值函式中屬性的watcher的訊息訂閱器dep中,當這些操作完成,最後關閉Dep.target賦為null並返回求值函式結果。
  3. 當某個屬性發生變化,觸發set攔截函式,然後呼叫自身訊息訂閱器depnotify方法,遍歷當前dep中儲存著所有訂閱者wathcersubs陣列,並逐個呼叫watcherupdate方法,完成響應更新。