做面試的不倒翁:淺談 Vue 中 computed 實現原理

創宇前端發表於2019-03-03

編者按:我們會不時邀請工程師談談有意思的技術細節,希望知其所以然能讓大家在面試有更出色表現。也給面試官提供更多思路。

雖然目前的技術棧已由 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 計算屬性的變化(其它還有 dataprops
  2. computed 本質是一個惰性求值的觀察者,具有快取性,只有當依賴變化後,第一次訪問 computed 屬性,才會計算新的值,而 watch 則是當資料發生變化便會呼叫執行函式
  3. 從使用場景上說,computed 適用一個資料被多個資料影響,而 watch 適用一個資料影響多個資料;

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

原理分析

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

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

做面試的不倒翁:淺談 Vue 中 computed 實現原理

當你把一個普通的 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 然後執行 wather.depend() 收集依賴和 watcher.evaluate() 計算求值。

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

  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 方法,完成響應更新。

文 / 亦然

一枚嚮往詩與遠方的 coder

編 / 熒聲

本文已由作者授權釋出,版權屬於創宇前端。歡迎註明出處轉載本文。本文連結:knownsec-fed.com/2018-09-12-…

想要訂閱更多來自知道創宇開發一線的分享,請搜尋關注我們的微信公眾號:創宇前端(KnownsecFED)。歡迎留言討論,我們會盡可能回覆。

做面試的不倒翁:淺談 Vue 中 computed 實現原理

感謝您的閱讀。

相關文章