1. 程式人生 > >淺談 Vue 中 computed 實現原理

淺談 Vue 中 computed 實現原理

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

基本介紹

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

{{fullName}}

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)或高消耗效能的操作,限制我們執行該操作的頻率,並在我們得到最終結果前,設定中間狀態,而這些都是計算屬性無法做到的。

下面還另外總結了幾點關於 computed 和 watch 的差異:

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

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

原理分析

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

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

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

Vue 響應系統,其核心有三點:observe、watcher、dep:

  • observe:遍歷 data 中的屬性,使用 Object.defineProperty 的 get/set 方法對其進行資料劫持;
  • dep:每個屬性擁有自己的訊息訂閱器 dep,用於存放所有訂閱了該屬性的觀察者物件;
  • 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 函式(其前後也分別初始化了 initData 和 initWatch )並傳入兩個引數 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)
      }
    }
  }
}

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

獲取計算屬性的定義 userDef 和 getter 求值函式

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

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

計算屬性的觀察者 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()
    }
  }
}

為了簡潔突出重點,這裡我手動去掉了我們暫時不需要關心的程式碼片段。 觀察 Watcher 的 constructor ,結合剛才講到的 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 同樣精簡了部分程式碼,我們觀察 Watcher 和 Dep 的關係,用一句話總結

watcher 中例項化了 dep 並向 dep.subs 中添加了訂閱者,dep 通過 notify 遍歷了 dep.subs 通知每個 watcher 更新。

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 前面的程式碼可以看到 sharedPropertyDefinition 的 get/set 方法在經過 userDef 和 shouldCache 等多重判斷後被重寫,當非服務端渲染時,sharedPropertyDefinition 的 get 函式也就是 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 然後執行 wather.depend() 收集依賴和 watcher.evaluate() 計算求值。

分析完所有步驟,我們再來總結下整個流程:

  • 當元件初始化的時候,computed 和 data 會分別建立各自的響應系統,Observer遍歷 data 中每個屬性設定 get/set 資料攔截
  • 初始化 computed 會呼叫 initComputed 函式
    • 註冊一個 watcher 例項,並在內例項化一個 Dep 訊息訂閱器用作後續收集依賴(比如渲染函式的 watcher 或者其他觀察該計算屬性變化的 watcher )
    • 呼叫計算屬性時會觸發其Object.defineProperty的get訪問器函式
    • 呼叫 watcher.depend() 方法向自身的訊息訂閱器 dep 的 subs 中新增其他屬性的 watcher
    • 呼叫 watcher 的 evaluate 方法(進而呼叫 watcher 的 get 方法)讓自身成為其他 watcher 的訊息訂閱器的訂閱者,首先將 watcher 賦給 Dep.target,然後執行 getter 求值函式,當訪問求值函式裡面的屬性(比如來自 data、props 或其他 computed)時,會同樣觸發它們的 get訪問器函式從而將該計算屬性的 watcher 新增到求值函式中屬性的 watcher 的訊息訂閱器 dep 中,當這些操作完成,最後關閉Dep.target 賦為 null 並返回求值函式結果。
  • 當某個屬性發生變化,觸發 set 攔截函式,然後呼叫自身訊息訂閱器 dep 的 notify 方法,遍歷當前 dep 中儲存著所有訂閱者wathcer 的 subs 陣列,並逐個呼叫 watcher 的 update 方法,完成響應更新。