不少人提起 Vue 中的 computed,第一反應就是計算屬性會緩存,那麼它究竟是怎麼緩存的呢?緩存的究竟是什麼,何時緩存會失效,相信仍是有不少人對此很模糊。前端
本文以 Vue 2.6.11 版本爲基礎,就深刻原理,帶你來看看所謂的緩存究竟是什麼樣的。vue
本文假定你對 Vue 響應式原理已經有了基礎的瞭解,若是對於 Watcher
、Dep
和什麼是 渲染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
。閉包
進入正題,Vue 初次運行時會對 computed 屬性作一些初始化處理,首先咱們回顧一下 watcher 的概念,它的核心概念是 get
求值,和 update
更新。app
在求值的時候,它會先把自身也就是 watcher 自己賦值給 Dep.target
這個全局變量。ssh
而後求值的過程當中,會讀取到響應式屬性,那麼響應式屬性的 dep 就會收集到這個 watcher 做爲依賴。函數
下次響應式屬性更新了,就會從 dep 中找出它收集到的 watcher,觸發 watcher.update()
去更新。
因此最關鍵的就在於,這個 get
到底用來作什麼,這個 update
會觸發什麼樣的更新。
在基本的響應式更新視圖的流程中,以上概念的 get
求值就是指 Vue 的組件從新渲染的函數,而 update
的時候,其實就是從新調用組件的渲染函數去更新視圖。
而 Vue 中很巧妙的一點,就是這套流程也一樣適用於 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 } 複製代碼
這裏進入 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,targetStack 是 [ 渲染watcher,計算watcher ] 。
而後會執行到 value = this.getter.call(vm, vm)
,
其實 getter
函數,上一章的 watcher 形態裏已經說明了,就是用戶傳入的 sum
函數。
sum() { return this.count + 1 } 複製代碼
這裏在執行的時候,讀取到了 this.count
,注意它是一個響應式的屬性,因此冥冥之中它們開始創建了千絲萬縷的聯繫……
這裏會觸發 count
的 get
劫持,簡化一下
// 在閉包中,會保留對於 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)
去收集,又繞回到 計算watcher
的 addDep
函數上去了,這其實主要是 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,它們之間互相保留了彼此,相依爲命。
此時求值結束,回到 計算watcher
的 getter
函數:
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,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
方法,也就是
計算watcher
的 update渲染watcher
的 update拆解來看。
update () { if (this.lazy) { this.dirty = true } } 複製代碼
wtf,就這麼一句話…… 沒錯,就僅僅是把 計算watcher
的 dirty
屬性置爲 true,靜靜的等待下次讀取便可。
這裏其實就是調用 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 } } }) 複製代碼
因爲上一步中的響應式屬性更新,觸發了 計算 watcher
的 dirty
更新爲 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 版本計算屬性更新的路徑是這樣的:
count
更新computed watcher
和 渲染 watcher
更新computed watcher
把 dirty 設置爲 truecomputed watcher
從新求值。經過本篇文章,相信你能夠徹底理解計算屬性的緩存究竟是什麼概念,在什麼樣的狀況下才會生效了吧?
對於緩存和不緩存的狀況,分別是這樣的流程:
count
改變,先通知到 計算watcher
更新,設置 dirty = true
渲染watcher
更新,視圖從新渲染的時候去 計算watcher
中讀取值,發現 dirty
是 true,從新執行用戶傳入的函數求值。other
改變,直接通知 渲染watcher
更新。計算watcher
中讀取值,發現 dirty
爲 false,直接用緩存值 watcher.value
,不執行用戶傳入的函數求值。事實上這種經過 dirty
標誌位來實現計算屬性緩存的方式,和 Vue3 中的實現原理是一致的。這可能也說明在各類需求和社區反饋的千錘百煉下,尤大目前認爲這種方式是實現 computed 緩存的相對最優解了。
若是對 Vue3 的 computed 實現感興趣的同窗,還能夠看個人這篇文章,原理大同小異。只是收集的方式稍有變化。
1.若是本文對你有幫助,就點個贊支持下吧,你的「贊」是我創做的動力。
2.關注公衆號「前端從進階到入院」便可加我好友,我拉你進「前端進階交流羣」,你們一塊兒共同交流和進步。