[譯] 監測與除錯 Vue.js 的響應式系統:計算屬性樹(Computed Tree)

SHERlocked93發表於2019-03-28

關於 Vue 的下一個主版本,公佈的很多新特性引發了激烈的討論,但其中有一個特性引起了我的注意:

更良好的可除錯能力:我們可以精確地追蹤到一個元件發生重渲染的觸發時機和完成時機,及其原因

在本文中,我們將討論在 Vue2.x 中如何監測響應式機制,並且將演示一些和效能調優相關的程式碼段。

為什麼響應式系統相關程式碼需要調優

如果你的專案比較大,那麼你很有可能在用 Vuex。你會將 store 分割為模組,並且為了關聯資料的訪問一致性你甚至需要將你的狀態正規化化

你可能使用 Vuex 的 getter 來派生狀態,事實上,你還會使用複合的派生資料,即一個 getter 會引用另一個 getter 派生的資料。

在 Vue 元件中,你會使用各種分層的模式,當然也包括經常用的 slots。在這樣的元件樹中,肯定會有計算屬性(派生出來的資料)。

當這些發生的時候,從 store 中的狀態到渲染的元件之間的響應式依賴關係將很難理清楚。

這就是計算屬性樹了,如果不把它弄清楚的話,那麼翻轉一個看似不起眼的布林值可能會觸發一百個元件的更新。

基礎知識

我們將學習一些響應式機制的內部工作原理。如果你還沒有(比較深地)理解 Dependency 類(譯者注:Dep — 為與原始碼一致,後文都採用 Dep)與 Watcher 類之間的關係,可以考慮學習一下內容豐富、條例清晰的高階 Vue 課程:建立一個響應式系統

在瀏覽器開發工具中除錯過程中見過 __ob__ 麼?

承認吧,當時是不是有點好奇,__ob__ 看起來是不是像這樣?

[譯] 監測與除錯 Vue.js 的響應式系統:計算屬性樹(Computed Tree)

這些在 subs 中的 Watcher 將會在這個響應式資料發生改變的時候更新。

有時候你會在開發者工具中瀏覽一下這些物件,並且找到一些有用的資訊,有時候找不到。有時候你會發現 Watcher 遠不止 5 個。

舉個例子

我們用一些簡單的程式碼說明一下:JSFiddle

這個例子的 store 中的狀態有雜湊陣列 userscurrentUserId 兩個屬性。還有一個 getter 用來返回當前使用者的資訊。另外還有一個 getter 只返回狀態為活躍的使用者陣列。

然後這裡有兩個元件,其中有三個計算屬性:

  • validCurrentUser — 若當前使用者是有效使用者則為 true
  • total — 引用反映當前所有活躍使用者的 getter,將返回活躍使用者數
  • upperCaseName — 將使用者的姓名對映為大寫形式

希望舉的這個特別的例子,對理解我們討論的內容有所幫助。

計算屬性的響應式機制是如何運轉的?

通常,當從一個 Dep 類例項獲取到更新的通知時,響應機制將會觸發對應的 Watcher 函式。當我變更一個被元件渲染所依賴的響應式資料時,將觸發重渲染。

但我們看看派生的資料,它的情況有點複雜。首先,計算屬性的值是被快取起來的,以便在它計算出來之後就一直可用計算後的值,只有當它的快取失效才會被重新計算,換句話說,只在其依賴的資料發生改變時它們才會重新求值。

我們再來看看之前的例子currentUserId 狀態被 currentUser 這個 getter 引用了,然後在 validCurrentUser 計算屬性引用了 currentUservalidCurrentUser 又是根元件 render 函式的 v-if 表示式的一部分。這條引用鏈看起來不錯。

實際上,響應資料的儲存是通過一個 Watcher 的配置選項來處理的。當我們使用元件中的 Watcher 時,API 文件中介紹了兩個可選選項(deepimmediate),但其實還有一些沒被文件記錄的選項,我並不推介你使用這些沒被記錄的選項,但理解他們卻很有益處。其中一個選項是 lazy,配置它之後 Watcher 將會維護一個 dirty 標誌,如果依賴的響應資料已經更改但這個 Watcher 還未執行時它將為 true,也就是說,此時快取已過時。

在我們的例子中,如果 currentUserId 被改成 3。任何依賴於它且被設定了 lazy 的 Watcher 都會被標記為 dirty,但 Watcher 並沒有執行。currentUservalidCurrentUser 都是這個狀態的 lazy Watcher。根渲染函式同樣會依賴於這個狀態,渲染將在下一個 tick 時被觸發。當渲染函式執行時,將會訪問已經被標記為 dirty 的 validCurrentUser,它將重新執行它的 getter 函式,進而訪問同樣需要更新的 currentUser。至此,這個元件將會被正確重渲染,並且相關快取將被更新。

等等,我似乎聽見你在問,為什麼所有 3 個 Watcher 都是依賴於這個狀態的呢?

難道他們不是相互依賴的麼?計算屬性 watcher 有一個特性就是不僅它自身的值是響應式的,而且當計算屬性的 getter 被呼叫時,如果當前有 Wathcer 在讀取這個計算屬性的話(即 Dep.target 中有值--譯者),所有這個計算屬性的依賴也將會被這個 Wathcer 收集起來。這種依賴收集關係鏈的扁平化對效能表現更優,而且也是個比較簡單的解決方案。

這意味著一個元件將發生更新,即使它所依賴的計算屬性在重新計算後的值並沒有發生變化,這種更新顯然沒有什麼意義。

其中一些邏輯可以閱讀一下 watcher 類原始碼的優雅實現,程式碼量 240 行左右。

那麼從 __ob__ 中我們可以得到哪些關於計算屬性響應式機制的資訊呢

我們可以看到有哪些 Watcher 訂閱(subs)了響應式資料的更新。記住,響應式機制在下面這些情景下起作用:

  • 物件
  • 陣列
  • 物件的屬性

最後一個情景很有可能被忽略,因為在開發者工具中是無法瀏覽它的 Dep 類例項(譯者注:__ob__)。因為 Dep 類是在最初響應式化的時候就被例項化的,但是並沒有在這個物件中的什麼地方把它記錄下來。稍後我們將回頭討論這個問題,因為我將用一個小技巧來間接拿到它。

然而通過觀察物件和陣列的 Watcher 也可以讓我們收穫良多,下面是一個簡單的 Watcher:

[譯] 監測與除錯 Vue.js 的響應式系統:計算屬性樹(Computed Tree)

示例跑起來之後開啟開發者工具,它應該在頁面全部渲染完成之後暫停執行。你可以輸入下面的表示式,就能看到跟上面這個圖一樣的情況了:

this.$store.state.users[2].__ob__.dep.subs[5]
複製程式碼

這是一個元件的渲染 Watcher,也是一個物件引用。能看到 dirtylazy 這兩個我之前提到過的標誌位。同時,我們還可以知道它不是一個使用者建立的 Watcher(譯者注:user 為 false)。

有時,試圖找出這個 Watcher 是哪個元件的渲染 Watcher 是困難的,因為如果這個元件沒有全域性註冊,或者這個元件沒有設定 name 屬性,那麼基本可以說它是匿名的。然而如果你從另一個元件引用了這個匿名元件的時候,它的 $vnode.tag 屬性通常包含它被引用時所用的名稱。

[譯] 監測與除錯 Vue.js 的響應式系統:計算屬性樹(Computed Tree)

上面的這個 Watcher 來自於被其父元件定義為 Comp 的子元件。它與 upperCaseName 計算屬性相關。計算屬性通常有一個在 getter 函式上指明的有意義的名稱,這是因為計算屬性通常被定義為物件屬性。

Vuex 的 getter

通常計算屬性會給出他們的名稱及其所屬的元件,但是 Vuex 的 getter 卻並不如此。currentUser 這個 Watcher 看起來長這樣:

[譯] 監測與除錯 Vue.js 的響應式系統:計算屬性樹(Computed Tree)

唯一能證明它是 Vuex 中的 getter 的線索是:它的函式體定義在 vuex.min.js 中(譯者注:[[FunctionLocation]])。

所以我們應該怎樣獲取 getter 的名稱呢?在開發者工具中你通常可以訪問 [[Scopes]],你可以在 [[Scopes]] 中找到它的名稱,然而這並不是通過程式設計的方式來獲取的。

下面是我的一個解決方法,在建立 Vuex 的 store 之後執行:

const watchers = store._vm._computedWatchers;
Object.keys(watchers).forEach(key => {
  watchers[key].watcherName = key;
});
複製程式碼

第一行可能看起來有點奇怪,但其實 Vuex 的 store 中會維護一個 Vue 的例項,來幫助實現 getter 的功能,實際上,getter 就是一個偽裝起來的計算屬性!

現在,當我們檢視 subs 陣列中的 Watcher 時,我們可以通過獲取 watcherName 來獲取 Vuex 的 getter 的名稱。

物件屬性的 Dep 類例項

上面我提到除錯響應式資料時你是看不到物件屬性的 Dep 類例項。

示例中,每個 user 物件都有一個 name 屬性,每個屬性都包含各自的 Watcher,這些 Watcher 將會在屬性發生變更時收到更新通知。

儘管 Dep 例項並不能直接訪問到,但是可以被監聽他們的 Watcher 訪問到。Watcher 保留有一份它所依賴的所有依賴項的陣列。

我的小技巧是給屬性增加一個 Watcher,然後拿到這個 Watcher 的依賴項

但是這並不簡單,我可以通過 Vue 的 $watch 介面來新增一個 Watcher,但是返回的並不是 Watcher 例項。因此我需要從 Vue 例項的內部屬性中獲取到 Watcher 例項。

const tempVm = new Vue();
tempVm.$watch(() => store.state.users[2].name, () => {});
const tempWatch = tempVm._watchers[0];

// now pull the subs from the deps
tempWatch.deps.forEach(dep => dep.subs
  .filter(s => s !== tempWatch)
  .forEach(s => subs.add(s)));
複製程式碼

想把這個功能包裝成一個工具函式嗎?

我已經把這些小的程式碼片段封裝到了一個任何人都可以獲取到的工具庫中:vue-pursue

可以看看使用示例

例子中的 () => this.$store.state.users[2].name 經過 vue-pursue 處理後返回:

{
  "computed": [
    "currentUser",
    "validCurrentUser",
    "Comp.upperCaseName"
  ],
  "components": [
    "Comp"
  ],
  "unrecognised": 1
}
複製程式碼

需要注意的是,根元件將會在操作後更新,但因為根元件沒有名稱,所以其顯示為 unrecognisedcurrentUser 這個 Vuex 的 getter 將會更新,且這個更新並不來源於 name 的更新。

通過傳遞一個箭頭函式給 vue-pursue,這個箭頭函式所具有的所有依賴將會被將會被訂閱者考慮在內,這意味著 usersusers[2] 物件也包括在內。或者,如果我們傳遞 (this.$store.state.users[2], ‘name’),輸出將會是:

{
  "computed": [
    "validCurrentUser",
    "Comp.upperCaseName"
  ],
  "components": [
    "Comp"
  ],
  "unrecognised": 1
}
複製程式碼

最後一點...

我需要著重強調的是,要謹慎使用任何以下劃線作為開頭的屬性,因為這不是公共 API 的一部分,它們可能會在沒有任何警告的情況下被移除。上面介紹的這個功能,一開始就沒打算使用於生產環境,也沒打算使用在執行時環境,這只是一個方便除錯的開發者工具。

最終隨著 Vue3.0 的出現,這將會被更全面、更簡單易用、更可靠的替代。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章