面試官問你Vue的計算屬性時,怎么回答才能證明你會?

前言

Vue 中的 computed 是一個日常開發(fā)中常用到的屬性,也是面試中經(jīng)常被問到的一個知識點,你幾乎能在任何一個和 Vue 相關(guān)的面試題集錦里找到這樣一個題目:methods 和 computed 有什么不同?你可能會毫不猶豫地回答:"methods 不會被緩存,computed 會對計算結(jié)果進行緩存"。確實,這個緩存是一個主要的的特點,但是,這個緩存指的是什么?緩存是怎么實現(xiàn)的?哪種情況下不會被緩存?這個緩存什么時候會被重新求值?緩存有什么好處?除了緩存我們還可以問:怎樣在計算屬性中使用 setter?計算屬性是否能依賴其他計算屬性,內(nèi)部的原理是什么?對于這些問題,可能很多人都不是很了解,不過沒關(guān)系,這篇文章就帶你來深入理解這個計算屬性,任面試官怎么問都不怕。

本文使用的 Vue 源碼版本是 2.6.11

DEMO

我們先來看一個簡單的例子,本文將會針對這個例子進行分析:

<div id="app">
  <div @click="add">doubleCount:{{doubleCount}}</div>
</div>
<script>
  new Vue({
    el: '#app',
    name: 'root',
    data() {
      return {
        count: 1
      }
    },
    computed: {
      doubleCount() {
        return this.count * 2
      }
    },
    methods: {
      add() {
        this.count += 1
      }
    }
  })
</script>

這里使用了一個doubleCount計算屬性,它的值是count的兩倍,每次點擊會使count的值加一,doubleCount也隨之改變。

原理分析

首先你要對 Vue 的響應(yīng)式系統(tǒng)原理有所了解,不了解的話可以先去網(wǎng)上搜一下這方面的文章。

本文貼的 Vue 源碼并不是原版的源碼,為了便于分析講解,對原版的源碼做了簡化,去除了不重要的邏輯和邊界情況的處理。

直接看源碼

初始化過程

組件初始化時會執(zhí)行initState函數(shù):

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 */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

這里進行了 props,data 等屬性的初始化,在初始化完 data 后執(zhí)行initComputed進行計算屬性的初始化,這也是為什么我們可以在計算屬性中直接訪問 props,data,methods,就是因為它的初始化發(fā)生在這三者之后,下面來看下initComputed的邏輯:

// vm是組件實例,computed是我們在options中定義的對象。
function initComputed(vm: Component, computed: Object) {
  // 先創(chuàng)建一個watchers,是一個空對象
  const watchers = (vm._computedWatchers = Object.create(null))
  for (const key in computed) {
    // 獲取這個計算屬性的定義,對于剛才的例子,這個userDef就是doubleCount這個函數(shù)
    const userDef = computed[key]
    // 由于doubleCount是個函數(shù),所以這里的getter還是doubleCount這個函數(shù)
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    // 創(chuàng)建一個Watcher,存入watchers中
    watchers[key] = new Watcher(
      vm,
      getter || noop,
      noop,
      computedWatcherOptions
    )
    // 待會講
    defineComputed(vm, key, userDef)
  }
}

這個函數(shù)就是遍歷定義的 computed,對每個計算屬性都創(chuàng)建一個 Watcher,然后保存在 watchers 中,注意這個 watchers 是在 vm 的_computedWatchers屬性上的,創(chuàng)建 watcher 的時候傳入了一個computedWatcherOptions,是一個只有 lazy 屬性的對象:

const computedWatcherOptions = { lazy: true }

下面來簡單看下 Watcher:

class Watcher {
  constructor(
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm
    // options就是剛才的computedWatcherOptions,所以lazy為true
    this.lazy = !!options.lazy
    // 用來控制緩存,稍后講
    this.dirty = this.lazy // true
    this.deps = [] // 收集的Dep
    // 求值方法,對于我們的例子而言,就是doubleCount函數(shù)
    this.getter = expOrFn
    // 初始化value,由于lazy為true,所以什么也不會執(zhí)行,這里的value為undefined
    this.value = this.lazy ? undefined : this.get()
  }
}

這里關(guān)鍵的地方是lazy屬性和dirty屬性,lazy的作用為惰性求值,在初始化 value 時由于lazy為 true,所以并不會求值。dirty的作用我們稍后再說,接下來接著看 computed 的初始化。

在創(chuàng)建了 watcher 之后,還執(zhí)行了defineComputed(vm, key, userDef):

export function defineComputed(
  target: any, // vm
  key: string, // computed的key:'doubleCount'
  userDef: Object | Function // 計算屬性的值,doubleCount函數(shù)
) {
  // 使用defineProperty設(shè)置getter和setter
  Object.defineProperty(target, key, {
    enumerable: true,
    configurable: true,
    get: function computedGetter() {
      // 拿到initComputed中創(chuàng)建的Watcher
      const watcher = this._computedWatchers && this._computedWatchers[key]
      if (watcher) {
        // !!dirty為true時才會執(zhí)行evaluate
        if (watcher.dirty) {
          // evaluate會對watcher進行求值,并將dirty置為false
          watcher.evaluate()
        }
        // 待會講
        if (Dep.target) {
          watcher.depend()
        }
        // 返回watcher的值
        return watcher.value
      }
    }
  })
}

defineComputed主要是通過defineProperty設(shè)置了代理,通過實例訪問計算屬性時就會執(zhí)行這個 get 函數(shù)。

緩存的實現(xiàn)

設(shè)想初次渲染的場景,count值為 1,在模板中訪問doubleCount屬性,就會執(zhí)行defineComputed中定義的 get 函數(shù),這個 get 函數(shù)首先會拿到剛才在initComputed中定義的watcher,然后判斷watcher.dirty,剛才創(chuàng)建的 watcher 的 dirty 為 true,所以會執(zhí)行watcher.evaluate(),我們來看下這個evaluate方法

class Watcher {
  constructor() {
    // ...
  }
  evaluate() {
    this.value = this.get() // get會對watcher求值,稍后細講
    this.dirty = false // 重新將dirty置為false
  }
}

這里會執(zhí)行 get 函數(shù),get 函數(shù)會執(zhí)行 watcher 的 getter,對于我們的例子而言就是執(zhí)行doubleCount函數(shù):return this.count * 2,由于初始的 count 為 1,所以這里會返回 2,然后將結(jié)果賦值給 value,再把 dirty 置為 false,這個時候 watcher 就有值了,再回到defineComputed的 get 中,最后執(zhí)行return watcher.value返回了 watcher 的值,這樣模板中就渲染出了doubleCount:2,下次我們再訪問doubleCount的時候,比如在mountedconsole.log(this.doubleCount),就又會走到defineComputed的 get,這個時候由于watcher.dirtyfalse,所以就不會執(zhí)行watcher.evaluate()了,也就不會執(zhí)行doubleCount函數(shù)了,它將會直接返回watcher.value,也就是 2,這樣就實現(xiàn)了緩存。

如果將count從 1 變成 2,那么我們下次訪問doubleCount時,應(yīng)該拿到 4 才對,那這個緩存是什么時候更新的,是怎么更新的呢?別急,我們接著來分析。

緩存更新

首先我們先來回顧下 Vue 響應(yīng)式系統(tǒng)的流程,Vue 的響應(yīng)式系統(tǒng)主要是通過 Watcher、Dep 以及Object.defineProperty實現(xiàn)的,初始化 data 時,通過Object.defineProperty設(shè)置屬性的 getter 和 setter,使屬性變?yōu)轫憫?yīng)式,然后在執(zhí)行某些操作(渲染操作,計算屬性,自定義 watcher 等)時,創(chuàng)建一個 watcher,這個 watcher 在執(zhí)行求值操作之前會將一個全局變量Dep.target指向自身,然后在求值操作過程中如果訪問了響應(yīng)式屬性,就會把當前的Dep.target也就是 watcher 添加到屬性的 dep 中,然后在下次更新響應(yīng)式屬性時,就會從 dep 中找出收集的 watcher,然后執(zhí)行watcher.update,執(zhí)行更新操作。

概括的比較簡略,如果你不明白的話,建議去網(wǎng)上搜一下這方面的文章

了解了響應(yīng)式系統(tǒng)后,我們再來分析上文的初次渲染場景,在首次渲染時,訪問doubleCount時執(zhí)行了watcher.evaluate()函數(shù),里面有一個求值操作this.value = this.get(),我們來看下this.get這個函數(shù)

class Watcher {
  constructor() {
    // ...
  }
  get() {
    // targetStack保存了當前的watcher棧
    // 因為可能在watcher求值過程中又創(chuàng)建了其他watcher
    targetStack.push(this)
    // 將Dep.target指向自身
    Dep.target = this

    let value
    const vm = this.vm
    // 執(zhí)行g(shù)etter函數(shù),對于我們的例子而言,getter就是doubleCount函數(shù)
    value = this.getter.call(vm, vm)

    // 當前watcher出棧
    targetStack.pop()
    // 恢復(fù)到上一個watcher
    Dep.target = targetStack[targetStack.length - 1]

    return value
  }
}

這里主要做的就是設(shè)置Dep.target,然后執(zhí)行 getter,因為doubleCount函數(shù)中訪問了count屬性,所以會執(zhí)行到count的 getter 中:

function defineReactive(obk, key, val) {
  const dep = new Dep()
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      const value = val
      // 剛才定義的Dep.target,也就是計算屬性的watcher
      if (Dep.target) {
        // 執(zhí)行depend收集依賴
        dep.depend()
      }
      return value
    },
    set: function reactiveSetter(newVal) {
      // ...稍后講
    }
  })
}

這個 get 主要就是執(zhí)行dep.depend()收集依賴:

class Dep {
  depend() {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
}

這里執(zhí)行了 watcher 的 addDep,將自身作為參數(shù)傳入:

class Watcher {
  addDep(dep) {
    dep.addSub(this)
    this.deps.push(dep)
  }
}

將 dep 添加到 watcher 自身的 deps 中,這里執(zhí)行了dep.addSub(this),參數(shù)是自身,又回到了 dep:

class Dep {
  addSub(sub) {
    this.subs.push(sub)
  }
}

這個函數(shù)就是把 watcher 添加到自身的 subs 中,看似很繞,其實很好理解,就是分別去 dep 和 watcher 中將對方添加到自身的某個屬性中,這樣執(zhí)行完之后,dep.subs中會是[計算屬性watcher],而watcher.deps會是[count的dep],兩者中都有對方的引用,這里可以得出一個結(jié)論就是 調(diào)用某一個 dep 的 depend 方法時,會把 Dep.target 添加到自身的 subs 中(稍后會用到) ,這是在初始化取值時做的操作,當設(shè)置了count為 2 時,就會走到 count 的 setter 邏輯中:

function defineReactive(obk, key, val) {
  const dep = new Dep()
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      //...
    },
    set: function reactiveSetter(newVal) {
      val = newVal
      const subs = dep.subs.slice()
      // 遍歷subs,執(zhí)行update函數(shù)
      for (let i = 0, l = subs.length; i < l; i++) {
        subs[i].update()
      }
    }
  })
}

這里將之前存的 watcher 取出,遍歷并執(zhí)行watcher.update

class Watcher {
  update() {
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
}

這里是關(guān)鍵的邏輯,由于計算屬性的 lazy 為 true,所以這里會執(zhí)行this.dirty = true的邏輯,到這里就完了。這里可能有小伙伴會很疑惑:這個邏輯如果到這里就完了,那么計算屬性在哪里重新求值呢?視圖在哪里重新渲染呢?如果照著這個邏輯的話,計算屬性根本不會更新,視圖也會不會重新渲染,那么問題出在哪里呢?

視圖如何更新

其實我們一直忽略了一個東西,那就是渲染 watcher,在渲染時是先執(zhí)行渲染 watcher 的,然后渲染 watcher 中執(zhí)行渲染函數(shù),這時候在渲染函數(shù)會訪問到doubleCount,然后執(zhí)行defineComputed中定義的 getter,getter 中又執(zhí)行了我們剛才說的watcher.evaluate()、watcher.get()等邏輯,那么我們再來分析下這個watcher.get()

class Watcher {
  constructor() {
    // ...
  }
  get() {
    // doubleCount的訪問是發(fā)生在渲染watcher中的
    // 所以在執(zhí)行下面這行代碼之前,targetStack里面是:[渲染watcher]
    targetStack.push(this) // 執(zhí)行這段代碼后,targetStack里面是:[渲染watcher,計算watcher]
    Dep.target = this

    let value
    const vm = this.vm
    // 還是之前的邏輯,收集依賴
    // count的dep.subs中會是[計算屬性watcher],計算watcher的deps會是[count的dep]
    value = this.getter.call(vm, vm)

    // 當前watcher出棧
    targetStack.pop() // 執(zhí)行完這段代碼后,targetStack里面是:[渲染watcher]
    // 恢復(fù)到上一個watcher
    Dep.target = targetStack[targetStack.length - 1] // Dep.target是:渲染watcher

    return value
  }
}

執(zhí)行完這個 get 后返回到defineComputed的 getter 中:

get: function computedGetter() {
  const watcher = this._computedWatchers && this._computedWatchers[key]
  if (watcher) {
    if (watcher.dirty) {
      // 對watcher求值
      watcher.evaluate()
    }
    // watcher執(zhí)行完求值后,Dep.target是渲染watcher,所以這里是有值的
    if (Dep.target) {
      // 執(zhí)行watcher的收集依賴操作
      watcher.depend()
    }
    return watcher.value
  }
}

由于Dep.target有值,所以會執(zhí)行watcher.depend(),來看下這個 depend:

class Watcher {
  constructor() {
    // ...
  }
  depend() {
    // 上文已經(jīng)分析過,計算watcher的deps是:[count的dep]
    let i = this.deps.length
    while (i--) {
      this.deps[i].depend()
    }
  }
}

這里遍歷 deps,并執(zhí)行 dep 的 depend 方法,還記得這個方法和那個結(jié)論嗎?調(diào)用某一個 dep 的 depend 方法時,會把 Dep.target 添加到自身的 subs 中,上文已經(jīng)分析過 count 的 dep.subs 中是[計算屬性watcher],此時的Dep.target是渲染 watcher,那執(zhí)行完這個 depend 后,count 的 dep.subs 中就是[計算屬性watcher, 渲染watcher]。

到這里可能大家就明白了,更新響應(yīng)式屬性時,在 count 的 setter 中,遍歷了 dep 的 subs 并執(zhí)行 update 方法,這時候的 subs 里不只有計算屬性的 watcher,還有渲染 watcher,我們再來看 update 方法:

class Watcher {
  update() {
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
}

會先執(zhí)行計算屬性的 update,將dirty置為 true,然后執(zhí)行渲染 watcher 的 update,渲染 watcher 的lazysync都為 false,所以會執(zhí)行queueWatcher(this),這個queueWatcher方法你可以不用關(guān)心它的作用,其實最終它會執(zhí)行渲染 watcher 中的渲染函數(shù),那在執(zhí)行渲染函數(shù)時,又訪問到了doubleCount

get: function computedGetter() {
  const watcher = this._computedWatchers && this._computedWatchers[key]
  if (watcher) {
    // 這個時候dirty已經(jīng)是true了,表示它需要更新
    if (watcher.dirty) {
      // 對watcher求值,執(zhí)行doubleCount函數(shù)
      // 執(zhí)行完之后,watcher.value就會從2變?yōu)?
      watcher.evaluate()
    }
    if (Dep.target) {
      watcher.depend()
    }
    return watcher.value // 返回4
  }
}

由于我們已經(jīng)在 update 階段把 dirty 變?yōu)?true 了,所以此時會執(zhí)行watcher.evaluate(),這樣doubleCount就更新了,就會在頁面上渲染出 4 了,如果我們再修改 count 的值,就會重新執(zhí)行上文的邏輯。

如果不在模板中使用doubleCount,只通過 watch 監(jiān)聽計算屬性,也是相似的邏輯,只不過是把渲染 watcher 換成 user watcher,你也可以自行打個斷點分析下整個流程。

另外如果是多層嵌套計算屬性的情況,可能比較復(fù)雜,不過思路還是上文的思路,最終 count 的 dep.subs 就是類似于這樣的:[AAA計算watcher,AA計算watcher,A計算watcher,渲染watcher]

總結(jié)

通過本文可以總結(jié)出以下兩點:

  1. 計算屬性watcher的lazy為true,當修改響應(yīng)式屬性執(zhí)行watcher.update時,并不會對watcher求值,而是將watcher.dirty置為true,當下次訪問這個計算屬性時,發(fā)現(xiàn)dirty為true,這時候才會對watcher求值。
  2. 如果計算屬性的依賴沒有發(fā)生改變,那么無論我們訪問多少次都不會重新求值,會直接從watcher.value返回我們需要的值。

很多Vue性能優(yōu)化的文章里都會提到:將一些需要進行大量計算的操作或者需要頻繁執(zhí)行的操作放在計算屬性里。其利用的就是計算屬性緩存的特點,減少無意義的計算。

除了本文講的內(nèi)容,計算屬性還支持自定義setter,以及傳入其他option,不過比較簡單,你可以自行看源碼分析,如果你徹底理解了本文內(nèi)容,那么以后無論是面試還是日常開發(fā),相信你定能游刃有余。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 基本介紹 話不多說,一個最基本的例子如下: Vue中我們不需要在template里面直接計算{{this.firs...
    指尖跳動閱讀 3,242評論 0 1
  • 計算屬性適合用在模版渲染當中,某個值是依賴了其他響應(yīng)式對象甚至是計算屬性計算而來的。 偵聽屬性適用在觀測某個值的變...
    LoveBugs_King閱讀 1,722評論 0 0
  • Vue生命周期函數(shù) Vue實例有一個完整的生命周期,也就是從開始創(chuàng)建、初始化數(shù)據(jù)、編譯模板、掛載Dom、渲染→更新...
    小王加油閱讀 1,377評論 0 1
  • computed 計算屬性的初始化是發(fā)生在 Vue 實例初始化階段的 initState 函數(shù)中initCom...
    _1633_閱讀 858評論 0 1
  • 前言 Vue.js 的核心包括一套“響應(yīng)式系統(tǒng)”。 “響應(yīng)式”,是指當數(shù)據(jù)改變后,Vue 會通知到使用該數(shù)據(jù)的代碼...
    world_7735閱讀 1,004評論 0 2

友情鏈接更多精彩內(nèi)容