Vue 的計算屬性真的會緩存嗎?(保姆級教學,原理深刻揭祕)

前言

不少人提起 Vue 中的 computed,第一反應就是計算屬性會緩存,那麼它究竟是怎麼緩存的呢?緩存的究竟是什麼,何時緩存會失效,相信仍是有不少人對此很模糊。前端

本文以 Vue 2.6.11 版本爲基礎,就深刻原理,帶你來看看所謂的緩存究竟是什麼樣的。vue

注意

本文假定你對 Vue 響應式原理已經有了基礎的瞭解,若是對於 WatcherDep和什麼是 渲染watcher 等概念還不是很熟悉的話,能夠先找一些基礎的響應式原理的文章或者教程看一下。視頻教程的話推薦黃軼老師的,若是想要看簡化實現,也能夠先看我寫的文章:node

手把手帶你實現一個最精簡的響應式系統來學習Vue的data、computed、watch源碼react

注意,這篇文章裏我也寫了 computed 的原理,可是這篇文章裏的 computed 是基於 Vue 2.5 版本的,和當前 2.6 版本的變化仍是很是大的,能夠僅作參考。緩存

示例

按照個人文章慣例,仍是以一個最簡的示例來演示。markdown

<div id="app">
  <span @click="change">{{sum}}</span>
</div>
<script src="./vue2.6.js"></script>
<script> new Vue({ el: "#app", data() { return { count: 1, } }, methods: { change() { this.count = 2 }, }, computed: { sum() { return this.count + 1 }, }, }) </script>
複製代碼

這個例子很簡單,剛開始頁面上顯示數字 2,點擊數字後變成 3閉包

解析

回顧 watcher 的流程

進入正題,Vue 初次運行時會對 computed 屬性作一些初始化處理,首先咱們回顧一下 watcher 的概念,它的核心概念是 get 求值,和 update 更新。app

  1. 在求值的時候,它會先把自身也就是 watcher 自己賦值給 Dep.target 這個全局變量。ssh

  2. 而後求值的過程當中,會讀取到響應式屬性,那麼響應式屬性的 dep 就會收集到這個 watcher 做爲依賴。函數

  3. 下次響應式屬性更新了,就會從 dep 中找出它收集到的 watcher,觸發 watcher.update() 去更新。

因此最關鍵的就在於,這個 get 到底用來作什麼,這個 update 會觸發什麼樣的更新。

在基本的響應式更新視圖的流程中,以上概念的 get 求值就是指 Vue 的組件從新渲染的函數,而 update 的時候,其實就是從新調用組件的渲染函數去更新視圖。

而 Vue 中很巧妙的一點,就是這套流程也一樣適用於 computed 的更新。

初始化 computed

這裏先提早劇透一下,Vue 會對 options 中的每一個 computed 屬性也用 watcher 去包裝起來,它的 get 函數顯然就是要執行用戶定義的求值函數,而 update 則是一個比較複雜的流程,接下來我會詳細講解。

首先在組件初始化的時候,會進入到初始化 computed 的函數

if (opts.computed) { initComputed(vm, opts.computed); }
複製代碼

進入 initComputed 看看

var watchers = vm._computedWatchers = Object.create(null);

// 依次爲每一個 computed 屬性定義
for (const key in computed) {
  const userDef = computed[key]
  watchers[key] = new Watcher(
      vm, // 實例
      getter, // 用戶傳入的求值函數 sum
      noop, // 回調函數 能夠先忽視
      { lazy: true } // 聲明 lazy 屬性 標記 computed watcher
  )

  // 用戶在調用 this.sum 的時候,會發生的事情
  defineComputed(vm, key, userDef)
}
複製代碼

首先定義了一個空的對象,用來存放全部計算屬性相關的 watcher,後文咱們會把它叫作 計算watcher

而後循環爲每一個 computed 屬性生成了一個 計算watcher

它的形態保留關鍵屬性簡化後是這樣的:

{
    deps: [],
    dirty: true,
    getter: ƒ sum(),
    lazy: true,
    value: undefined
}
複製代碼

能夠看到它的 value 剛開始是 undefined,lazy 是 true,說明它的值是惰性計算的,只有到真正在模板裏去讀取它的值後纔會計算。

這個 dirty 屬性實際上是緩存的關鍵,先記住它。

接下來看看比較關鍵的 defineComputed,它決定了用戶在讀取 this.sum 這個計算屬性的值後會發生什麼,繼續簡化,排除掉一些不影響流程的邏輯。

Object.defineProperty(vm, 'sum', { 
    get() {
        // 從剛剛說過的組件實例上拿到 computed watcher
        const watcher = this._computedWatchers && this._computedWatchers[key]
        if (watcher) {
          // ✨ 注意!這裏只有dirty了纔會從新求值
          if (watcher.dirty) {
            // 這裏會求值 調用 get
            watcher.evaluate()
          }
          // ✨ 這裏也是個關鍵 等會細講
          if (Dep.target) {
            watcher.depend()
          }
          // 最後返回計算出來的值
          return watcher.value
        }
    }
})
複製代碼

這個函數須要仔細看看,它作了好幾件事,咱們以初始化的流程來說解它:

首先 dirty 這個概念表明髒數據,說明這個數據須要從新調用用戶傳入的 sum 函數來求值了。咱們暫且無論更新時候的邏輯,第一次在模板中讀取到 {{sum}} 的時候它必定是 true,因此初始化就會經歷一次求值。

evaluate () {
  // 調用 get 函數求值
  this.value = this.get()
  // 把 dirty 標記爲 false
  this.dirty = false
}
複製代碼

這個函數其實很清晰,它先求值,而後把 dirty 置爲 false。

再回頭看看咱們剛剛那段 Object.defineProperty 的邏輯,

下次沒有特殊狀況再讀取到 sum 的時候,發現 dirty是false了,是否是直接就返回 watcher.value 這個值就能夠了,這其實就是計算屬性緩存的概念。

更新

初始化的流程講完了,相信你們也對 dirty緩存 有了個大概的概念(若是沒有,再仔細回頭看一看)。

接下來就講更新的流程,細化到本文的例子中,也就是 count 的更新究竟是怎麼觸發 sum 在頁面上的變動。

首先回到剛剛提到的 evalute 函數裏,也就是讀取 sum 時發現是髒數據的時候作的求值操做。

evaluate () {
  // 調用 get 函數求值
  this.value = this.get()
  // 把 dirty 標記爲 false
  this.dirty = false
}
複製代碼

Dep.target 變動爲 渲染watcher

這裏進入 this.get(),首先要明確一點,在模板中讀取 {{ sum }} 變量的時候,全局的 Dep.target 應該是 渲染watcher,這裏不理解的話能夠到我最開始提到的文章裏去理解下。

全局的 Dep.target 狀態是用一個棧 targetStack 來保存,便於前進和回退 Dep.target,至於何時會回退,接下來的函數裏就能夠看到。

此時的 Dep.target 是 渲染watcher,targetStack 是 [ 渲染watcher ] 。

get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } finally {
    popTarget()
  }
  return value
}
複製代碼

首先剛進去就 pushTarget,也就是把 計算watcher 自身置爲 Dep.target,等待收集依賴。

執行完 pushTarget(this) 後,

Dep.target 變動爲 計算watcher

此時的 Dep.target 是 計算watcher,targetStack 是 [ 渲染watcher,計算watcher ] 。

而後會執行到 value = this.getter.call(vm, vm)

其實 getter 函數,上一章的 watcher 形態裏已經說明了,就是用戶傳入的 sum 函數。

sum() {
    return this.count + 1
}
複製代碼

這裏在執行的時候,讀取到了 this.count,注意它是一個響應式的屬性,因此冥冥之中它們開始創建了千絲萬縷的聯繫……

這裏會觸發 countget 劫持,簡化一下

// 在閉包中,會保留對於 count 這個 key 所定義的 dep
const dep = new Dep()

// 閉包中也會保留上一次 set 函數所設置的 val
let val

Object.defineProperty(vm, 'count', {
  get: function reactiveGetter () {
    const value = val
    // Dep.target 此時就是計算watcher
    if (Dep.target) {
      // 收集依賴
      dep.depend()
    }
    return value
  },
})
複製代碼

那麼能夠看出,count 會收集 計算watcher 做爲依賴,具體怎麼收集呢

// dep.depend()
depend () {
  if (Dep.target) {
    Dep.target.addDep(this)
  }
}
複製代碼

其實這裏是調用 Dep.target.addDep(this) 去收集,又繞回到 計算watcheraddDep 函數上去了,這其實主要是 Vue 內部作了一些去重的優化。

// watcher 的 addDep函數
addDep (dep: Dep) {
  // 這裏作了一系列的去重操做 簡化掉 
  
  // 這裏會把 count 的 dep 也存在自身的 deps 上
  this.deps.push(dep)
  // 又帶着 watcher 自身做爲參數
  // 回到 dep 的 addSub 函數了
  dep.addSub(this)
}
複製代碼

又回到 dep 上去了。

class Dep {
  subs = []

  addSub (sub: Watcher) {
    this.subs.push(sub)
  }
}
複製代碼

這樣就保存了 計算watcher 做爲 count 的 dep 裏的依賴了。

經歷了這樣的一個收集的流程後,此時的一些狀態:

sum 的計算watcher

{
    deps: [ count的dep ],
    dirty: false, // 求值完了 因此是false
    value: 2, // 1 + 1 = 2
    getter: ƒ sum(),
    lazy: true
}
複製代碼

count的dep:

{
    subs: [ sum的計算watcher ]
}
複製代碼

能夠看出,計算屬性的 watcher 和它所依賴的響應式值的 dep,它們之間互相保留了彼此,相依爲命。

此時求值結束,回到 計算watchergetter 函數:

get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } finally {
    // 此時執行到這裏了
    popTarget()
  }
  return value
}
複製代碼

執行到了 popTarget計算watcher 出棧。

Dep.target 變動爲 渲染watcher

此時的 Dep.target 是 渲染watcher,targetStack 是 [ 渲染watcher ] 。

而後函數執行完畢,返回了 2 這個 value,此時對於 sum 屬性的 get 訪問還沒結束。

Object.defineProperty(vm, 'sum', { 
    get() {
          // 此時函數執行到了這裏
          if (Dep.target) {
            watcher.depend()
          }
          return watcher.value
        }
    }
})
複製代碼

此時的 Dep.target 固然是有值的,就是 渲染watcher,因此進入了 watcher.depend() 的邏輯,這一步至關關鍵

// watcher.depend
depend () {
  let i = this.deps.length
  while (i--) {
    this.deps[i].depend()
  }
}
複製代碼

還記得剛剛的 計算watcher 的形態嗎?它的 deps 裏保存了 count 的 dep。

也就是說,又會調用 count 上的 dep.depend()

class Dep {
  subs = []
  
  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
}
複製代碼

此次的 Dep.target 已是 渲染watcher 了,因此這個 count 的 dep 又會把 渲染watcher 存放進自身的 subs 中。

count的dep:

{
    subs: [ sum的計算watcher,渲染watcher ]
}
複製代碼

那麼來到了此題的重點,這時候 count 更新了,是如何去觸發視圖更新的呢?

再回到 count 的響應式劫持邏輯裏去:

// 在閉包中,會保留對於 count 這個 key 所定義的 dep
const dep = new Dep()

// 閉包中也會保留上一次 set 函數所設置的 val
let val

Object.defineProperty(vm, 'count', {
  set: function reactiveSetter (newVal) {
      val = newVal
      // 觸發 count 的 dep 的 notify
      dep.notify()
    }
  })
})
複製代碼

好,這裏觸發了咱們剛剛精心準備的 count 的 dep 的 notify 函數,感受離成功愈來愈近了。

class Dep {
  subs = []
  
  notify () {
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

複製代碼

這裏的邏輯就很簡單了,把 subs 裏保存的 watcher 依次去調用它們的 update 方法,也就是

  1. 調用 計算watcher 的 update
  2. 調用 渲染watcher 的 update

拆解來看。

計算watcher 的 update

update () {
  if (this.lazy) {
    this.dirty = true
  }
}
複製代碼

wtf,就這麼一句話…… 沒錯,就僅僅是把 計算watcherdirty 屬性置爲 true,靜靜的等待下次讀取便可。

渲染watcher 的 update

這裏其實就是調用 vm._update(vm._render()) 這個函數,從新根據 render 函數生成的 vnode 去渲染視圖了。

而在 render 的過程當中,必定會訪問到 sum 這個值,那麼又回回到 sum 定義的 get 上:

Object.defineProperty(vm, 'sum', { 
    get() {
        const watcher = this._computedWatchers && this._computedWatchers[key]
        if (watcher) {
          // ✨上一步中 dirty 已經置爲 true, 因此會從新求值
          if (watcher.dirty) {
            watcher.evaluate()
          }
          if (Dep.target) {
            watcher.depend()
          }
          // 最後返回計算出來的值
          return watcher.value
        }
    }
})
複製代碼

因爲上一步中的響應式屬性更新,觸發了 計算 watcherdirty 更新爲 true。 因此又會從新調用用戶傳入的 sum 函數計算出最新的值,頁面上天然也就顯示出了最新的值。

至此爲止,整個計算屬性更新的流程就結束了。

緩存生效的狀況

根據上面的總結,只有計算屬性依賴的響應式值發生更新的時候,纔會把 dirty 重置爲 true,這樣下次讀取的時候纔會發生真正的計算。

這樣的話,假設 sum 函數是一個用戶定義的一個比較耗費時間的操做,優化就比較明顯了。

<div id="app">
  <span @click="change">{{sum}}</span>
  <span @click="changeOther">{{other}}</span>
</div>
<script src="./vue2.6.js"></script>
<script> new Vue({ el: "#app", data() { return { count: 1, other: 'Hello' } }, methods: { change() { this.count = 2 }, changeOther() { this.other = 'ssh' } }, computed: { // 很是耗時的計算屬性 sum() { let i = 9999999999999999 while(i > 0) { i-- } return this.count + 1 }, }, }) </script>
複製代碼

在這個例子中,other 的值和計算屬性沒有任何關係,若是 other 的值觸發更新的話,就會從新渲染視圖,那麼會讀取到 sum,若是計算屬性不作緩存的話,每次都要發生一次很耗費性能的沒有必要的計算。

因此,只有在 count 發生變化的時候,sum 纔會從新計算,這是一個很巧妙的優化。

總結

2.6 版本計算屬性更新的路徑是這樣的:

  1. 響應式的值 count 更新
  2. 同時通知 computed watcher渲染 watcher 更新
  3. computed watcher 把 dirty 設置爲 true
  4. 視圖渲染讀取到 computed 的值,因爲 dirty 因此 computed watcher 從新求值。

經過本篇文章,相信你能夠徹底理解計算屬性的緩存究竟是什麼概念,在什麼樣的狀況下才會生效了吧?

對於緩存和不緩存的狀況,分別是這樣的流程:

不緩存:

  1. count 改變,先通知到 計算watcher 更新,設置 dirty = true
  2. 再通知到 渲染watcher 更新,視圖從新渲染的時候去 計算watcher 中讀取值,發現 dirty 是 true,從新執行用戶傳入的函數求值。

緩存:

  1. other 改變,直接通知 渲染watcher 更新。
  2. 視圖從新渲染的時候去 計算watcher 中讀取值,發現 dirty 爲 false,直接用緩存值 watcher.value,不執行用戶傳入的函數求值。

展望

事實上這種經過 dirty 標誌位來實現計算屬性緩存的方式,和 Vue3 中的實現原理是一致的。這可能也說明在各類需求和社區反饋的千錘百煉下,尤大目前認爲這種方式是實現 computed 緩存的相對最優解了。

若是對 Vue3 的 computed 實現感興趣的同窗,還能夠看個人這篇文章,原理大同小異。只是收集的方式稍有變化。

深度解析:Vue3如何巧妙的實現強大的computed

❤️感謝你們

1.若是本文對你有幫助,就點個贊支持下吧,你的「贊」是我創做的動力。

2.關注公衆號「前端從進階到入院」便可加我好友,我拉你進「前端進階交流羣」,你們一塊兒共同交流和進步。

相關文章
相關標籤/搜索