[Vue.js進階]從原始碼角度剖析計算屬性的原理

yeyan1996發表於2019-05-06

image

前言

最近在學習Vue計算屬性的原始碼,發現和普通的響應式變數內部的實現還有一些不同,特地寫了這篇部落格,記錄下自己學習的成果

文中的原始碼截圖只保留核心邏輯 完整原始碼地址

可能需要了解一些Vue響應式的原理

Vue 版本:2.5.21

計算屬性的概念

一般的計算屬性值是一個函式,這個函式會返回一個值,並且其函式內部還可能會依賴別的變數

[Vue.js進階]從原始碼角度剖析計算屬性的原理

一般的計算屬性看起來和 method 很像,值都是一個函式,那他們有什麼區別呢

計算屬性和method的區別

將一個計算屬性的函式放在 methods 中同樣也能達到相同的效果

但是如果檢視中依賴了這個 method 的返回值,並且當另外一個其他的響應式變數的修改導致檢視被更新時, method 會重新執行一遍,即使這次的更新和 method 中依賴的變數沒有任何關係!

而對於計算屬性,只有當計算屬性依賴的變數改變後,才會重新執行一遍函式,並重新返回一個新的值

點我看示例

[Vue.js進階]從原始碼角度剖析計算屬性的原理

當 otherProp 變數被修改導致更新檢視的時候,methodFullName 每次都會執行,而 computedFullName 只會在頁面初始化的時候執行一次,Vue 推薦開發者將 method 和 compute 屬性區分開來,能夠有效的提升效能,避免執行一些不必要的程式碼

回顧過計算屬性的概念,接下來我們深入原始碼,來了解一下計算屬性到底是怎麼實現的,為什麼只有計算屬性的依賴項被改變了才會重新求值

從例子入手

這裡我寫了一個簡單的例子,幫助各位理解計算屬性的執行原理,下面的解析會圍繞這個例子進行解析

const App = {
    template: `
     <div id="app">
        <div>{{fullName}}</div>
        <button @click="handleChangeName">修改lastName</button>
  </div>
    `,
    data() {
        return {
            firstName: '尤',
            lastName: '雨溪',
        }
    },
    methods: {
        handleChangeName() {
            this.lastName = '大大'
        }
    },
    computed: {
        fullName() {
            return this.firstName + this.lastName
        }
    }
}

new Vue({
    el: '#app',
    components: {
        App
    },
    template: `
    <App></App>
    `
}).$mount()
複製程式碼

fullName 依賴了 firstName 和 lastName,點選 button 會修改 lastName, 同時 fullName 會重新計算,檢視變成"尤大大"

[Vue.js進階]從原始碼角度剖析計算屬性的原理

深入計算屬性的原始碼

在日常開發中書寫的計算屬性,實際上內部都會儲存一個 watcher, watcher 的作用是觀察某個響應式變數的改變然後執行相應的回撥,由 Watcher 類例項化而成, Vue 中定義了3個 watcher

  • render watcher: 模板依賴並且需要顯示在檢視上變數,其內部儲存了一個 render watcher
  • computed watcher: 計算屬性內部儲存了一個 computed watcher
  • user watcher: 使用 watch 屬性觀察的變數內部儲存了一個 user watcher

理解這3個 watcher 各自的作用非常重要,文字會著重圍繞 computed watcher 展開

一個計算屬性的初始化分為2部分

  1. 例項化一個 computed watcher
  2. 定義計算屬性的 getter 函式

生成computed watcher

在初始化當前元件時,會執行 initComputed 方法初始化計算屬性,會給每個計算屬性例項化一個 computed watcher

[Vue.js進階]從原始碼角度剖析計算屬性的原理

在例項化 watcher 時傳入不同的配置項就可以生成不同的 watcher 例項 ,當傳入{ lazy: true } 時,例項化的 watcher 即為 computed watcher

定義計算屬性的 getter 函式

在建立完 computed watcher 後,接著會定義計算屬性的 getter 函式,我們在執行計算屬性的函式時,實際上執行的是 computedGetter 這個函式

[Vue.js進階]從原始碼角度剖析計算屬性的原理

computedGetter程式碼很少,但是卻是計算屬性的核心,我們一步步來分析

dirty屬性

通過 key 獲取到第一步中定義的 computed watcher,隨後會判斷這個 computed watcher 的 dirty 屬性是否為 true,當 dirty 為 true 時, 會執行 evaluate 方法, evaluate 內部會執行計算屬性的函式,並且將 watcher 的 value 屬性等於函式執行後的結果也就是最終計算出來的值,具體我們放到後面講

dirty 屬性是一個用來檢測當前的 computed watcher是否需要重新執行的一個標誌,這也是計算屬性和普通method的區別,結合上圖可以發現,當 dirty 為 false 時,就不會去執行 evaluate 也就不會執行計算屬性的函式,可以看到最後直接就返回了 watcher.value 表示這次不會進行計算,會直接使用以前的 value 的值

當第一次觸發computedGetter 時,dirty 屬性的預設值是 true ,那是因為在初始化 computed watcher時候 Vue 將 dirty 屬性等於了 lazy 屬性,即為 true

[Vue.js進階]從原始碼角度剖析計算屬性的原理

知道 dirty 的預設值為 true,什麼時候為 false 呢?我們接著來看 evalutate 具體的實現

evaluate方法

evaluate 方法是 computed watcher 獨有的方法,程式碼也只有短短2行

[Vue.js進階]從原始碼角度剖析計算屬性的原理

get方法

第一行執行了 get 方法, get 方法是所有 watcher 用來求值的通用方法

[Vue.js進階]從原始碼角度剖析計算屬性的原理

get 主要就做了這三步

  1. 將當前這個 watcher 作為棧頂的 watcher 推入棧
  2. 執行getter方法
  3. 將這個 watcher 彈出棧

我們知道 Vue.js 會維護一個全域性的棧用來存放 watcher ,每當觸發響應式變數內部的 getter 時,就會收集這個全域性的棧的頂部的 watcher(即Dep.target),將這個 watcher 存入響應式變數內部儲存的dep中

第一步通過 pushTarget 將當前的 computed watcher 推入全域性的棧中,此時Dep.target就指向這個棧頂的 computed watcher

[Vue.js進階]從原始碼角度剖析計算屬性的原理

第二步執行 getter 方法, 對於 computed watcher,getter 方法就是計算屬性的函式,執行函式將返回的值賦值給 value 屬性,而當計算屬性的函式執行時,如果內部含有其他的響應式變數,會觸發它們內部的 getter ,將第一步放入作為當前棧頂的 computed watcher 存入響應式變數內部的dep物件中

注意響應式變數內部的 getter 和 getter 方法不是一個函式

第三步將這個 computed watcher 彈出全域性的棧

之所以將這個 computed watcher 推入又彈出,是為了讓第二步執行內部的 getter 時,能讓計算屬性函式內部依賴的響應式變數收集到這個 computed watcher

對於計算屬性來說,get 方法的作用就是進行求值

將dirty設為false

執行完 get 方法,即一旦計算屬性執行過一次求值,就會將 dirty 屬性設為 false,如果下次又觸發了這個計算屬性的 getter 會直接跳過求值階段

結合?

在例子中,因為檢視需要依賴 fullName 這個響應式變數,所以會觸發它的內部的 getter,同時它又是一個計算屬性,即會執行 computedGetter ,此時 dirty 屬性為預設值 true,執行 evaluate => get => pushTarget

pushTarget 中,由於是 computed watcher 執行的 get 方法,所以 this 指向這個 computed watcher, 將它推入全域性棧中作為 Dep.target,隨後執行計算屬性的函式

可以看到計算屬性 fullName 的函式依賴了 firstName 和 lastName 這2個響應式變數,Vue在內部通過閉包的形式各自儲存了一個 dep 物件,這個 dep 物件會收集當前棧頂的 watcher,即收集 fullName 這個計算屬性的 computed watcher,所以當計算屬性的函式執行完畢後,firstName 和 lastName 內部的 dep 物件中都會儲存一個 computed watcher

收集完畢後,將 computed watcher 彈出,讓棧恢復到之前的狀態

[Vue.js進階]從原始碼角度剖析計算屬性的原理

depend方法

計算屬性第二個特點就是它的 depend 方法,這個方法是 computed watcher 獨有的

當 Dep.target 存在,說明在上一步彈出了 computed watcher 後全域性的棧中仍有其他的 watcher。比如當檢視中依賴了當前的計算屬性,那當前棧頂的 watcher 就是 render watcher,亦或者另外一個計算屬性內部依賴了當前的計算屬性,那棧頂的 watcher 可能是另一個 computed watcher,不管怎麼說只要有地方使用到這個計算屬性,就會進入 depend 方法

watcher 的 depend 方法:

[Vue.js進階]從原始碼角度剖析計算屬性的原理

depend 方法也非常簡短,它會遍歷當前 computed watcher 的deps屬性,依次執行 dep 的 depend 方法

deps 又是什麼呢,前面說到 dep 是每個響應式變數內部儲存的一個物件,deps 可想而知就是所有響應式變數內部 dep 的集合,那具體是哪些響應式變數呢?其實瞭解過響應式原理的朋友應該知道,這個 deps 實際上儲存了所有收集了當前 watcher 的響應式變數內部的 dep 物件

這是一個互相依賴的關係,每個響應式變數內部的 dep 會儲存所有的 watchers,而每個 watcher 的 deps 屬性會儲存所有收集到這個 watcher 的響應式變數內部的 dep 物件

[Vue.js進階]從原始碼角度剖析計算屬性的原理

(Vue之所以在 watcher 中儲存 deps,一方面需要讓計算屬效能夠收集依賴,另一方面也可以在登出這個 watcher 時能知道哪些 dep 依賴了這個 watcher,只需要呼叫 dep 裡對應的登出方法即可)

接著就會遍歷每個 dep 執行 dep.depend 方法:

[Vue.js進階]從原始碼角度剖析計算屬性的原理

這個方法的作用是給當前的響應式變數內部的 dep 收集當前棧頂的 watcher ,在例子中,因為檢視中依賴了 fullName,所以當 get 方法執行結束 computed watcher 被彈出後,棧頂的 watcher 就變為原來的 render watcher

computed watcher 中的 deps 屬性儲存了2個 dep,一個是 firstName 的 dep,另一個是 lastName 的 dep,因為這2個變數在執行 get 方法第二步的時候收集了到這個 computed watcher

這時候執行 dep.depend 時會再次給這2個響應式變數收集棧頂的 watcher,即 render watcher,最終這2個變數內部的 dep 都儲存了2個變數,一個 computed watcher,一個 render watcher

[Vue.js進階]從原始碼角度剖析計算屬性的原理

最終返回 watcher.value 作為顯示在檢視中的值

修改計算屬性依賴的變數

前面說過,只有當計算屬性的依賴項被修改時,計算屬性才會重新進行計算,生成一個新的值,而檢視中其他變數被修改導致檢視更新時,計算屬性不會重新計算,這是怎麼做到的呢?

當計算屬性的依賴項,即 firstName 和 lastName 被修改時,會觸發內部的 setter,Vue 會遍歷響應式變數內部的 dep 儲存的 watcher,最終會執行每個 watcher 的 update 方法

[Vue.js進階]從原始碼角度剖析計算屬性的原理

可以看到 update 方法有3種情況:

  • lazy:只存在於 computed watcher
  • sync:只存在於 user watcher,當 user watcher 設定了 sync 會同步呼叫 watcher 不會延遲到 nextTick 後,基本不會用
  • 預設情況:一般的 user watcher 和 render watcher 都會執行 queueWatcher,將這些 watcher 放到 nextTick 後執行

通過前面的 evaluatedepend 方法,firstName 和 lastName 內部的 dep 中都會儲存2個 watcher,一個 computed watcher,一個 render watcher,當 lastName 被修改時,會觸發內部的 setter,遍歷 dep 儲存的所有 watchers,這裡會先執行 computed watcher 的 update 方法

同時前面說到在 computed watcher 求值結束後,會將 dirty 置為 false,之後再獲取計算屬性的值時都會跳過 evaluate 方法直接返回以前的 value,而執行 computed watcher 的 update 方法會將 dirty 再次變成 true,整個computed watcher 只做這一件事,即取消 computed watcher 使用以前的快取的標誌

這個操作是同步執行的,也就是說即使 render watcher 或 user watcher 在 watchers 陣列中比 computed watcher 靠前,但是由於前2個 watcher 一般是非同步執行的,所以最終執行的時候 computed watcher 會優先執行

真正的求值操作是在 render watcher 中進行的,當遍歷到第二個 render watcher 時,由於檢視依賴了 fullName,會觸發計算屬性的 getter,再次執行之前的 computedGetter,此時由於上一步將 dirty 變成 true了,所以就會進入 evalutate 重新計算,此時 fullName 就拿到了最新的值"尤大大"了

修改非計算屬性依賴的變數

回到一開始計算屬性和 method 區別的那個例子,因為檢視依賴了 otherProp 所以當這個響應式變數被修改時,會觸發它內部 dep 儲存的 render watcher 的 update 方法,它會重新收集依賴更新檢視

當收集到 methodFullName 時,因為是一個普通的 method,每次檢視更新 Vue 都會執行相應的方法,所以每次都會列印 "method",而當收集 computedFullName 時,會執行 computedGetter,但是因為 otherPorp 不是這個計算屬性依賴的變數,沒有觸發過 computed watcher 的 update,所以 dirty 屬性為 false,就會跳過evaluate 方法直接返回快取的結果,因此不會每次列印 "computed"

總結

只有當計算屬性依賴的響應式變數被修改時,才會使得計算屬性被重新計算,否則使用的都是第一次的快取值,原因是因為計算屬性內部的 computed watcher 的 dirty 屬性如果為 false 就會始終使用以前快取的值

而計算屬性依賴的響應式變數內部的 dep 都會儲存這個 computed watcher,當它們被修改時,會觸發 computed watcher 的 update 方法,將 dirty 標誌位置為 true,這樣下次有別的 watcher 依賴這個計算屬性時就會觸發重新計算

參考資料

Vue.js 技術揭祕

相關文章