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

  • 2020 年 4 月 11 日
  • 筆記

前言

Vue中的computed是一個非常強大的功能,在computed函數中訪問到的值改變了後,computed的值也會自動改變。

Vue2中的實現是利用了Watcher的嵌套收集,渲染watcher收集到computed watcher作為依賴,computed watcher又收集到響應式數據某個屬性作為依賴,這樣在響應式數據某個屬性發生改變時,就會按照 響應式屬性 -> computed值更新 -> 視圖渲染這樣的觸發鏈觸發過去,如果對Vue2中的原理感興趣,可以看我這篇文章的解析:

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

前置知識

閱讀本文需要你先學習Vue3響應式的基本原理,可以先看我的這篇文章,原理和Vue3是一致的: 帶你徹底搞懂Vue3的Proxy響應式原理!TypeScript從零實現基於Proxy的響應式庫。

在你擁有了一些前置知識以後,默認你應該知道的是:

  1. effect其實就是一個依賴收集函數,在它內部訪問了響應式數據,響應式數據就會把這個effect函數作為依賴收集起來,下次響應式數據改了就觸發它重新執行。
  2. reactive返回的就是個響應式數據,這玩意可以和effect搭配使用。

舉個簡單的栗子吧:

// 響應式數據  const data = reactive({ count: 0 })  // 依賴收集  effect(() => console.log(data.count))  // 觸發上面的effect重新執行  data.count ++  複製程式碼

就這個例子來說,data是一個響應式數據。

effect傳入的函數因為內部訪問到它上面的屬性count了,

所以形成了一個count -> effect的依賴。

下次count改變了,這個effect就會重新執行,就這麼簡單。

computed

那麼引入本文中的核心概念,computed來改寫這個例子後呢:

// 1. 響應式數據  const data = reactive({ count: 0 })  // 2. 計算屬性  const plusOne = computed(() => data.count + 1)  // 3. 依賴收集  effect(() => console.log(plusOne.value))  // 4. 觸發上面的effect重新執行  data.count ++  複製程式碼

這樣的例子也能跑通,為什麼data.count的改變能間接觸發訪問了計算屬性的effect的重新執行呢?

我們來配合單點調試一步步解析。

簡化版源碼

首先看一下簡化版的computed的程式碼:

export function computed(    getter  ) {    let dirty = true    let value: T      // 這裡還是利用了effect做依賴收集    const runner = effect(getter, {      // 這裡保證初始化的時候不去執行getter      lazy: true,      computed: true,      scheduler: () => {        // 在觸發更新時 只是把dirty置為true        // 而不去立刻計算值 所以計算屬性有lazy的特性        dirty = true      }    })    return {      get value() {        if (dirty) {          // 在真正的去獲取計算屬性的value的時候          // 依據dirty的值決定去不去重新執行getter 獲取最新值          value = runner()          dirty = false        }        // 這裡是關鍵 後續講解        trackChildRun(runner)        return value      },      set value(newValue: T) {        setter(newValue)      }    }  }  複製程式碼

可以看到,computed其實也是一個effect。這裡對閉包進行了巧妙的運用,注釋里的幾個關鍵點決定了計算屬性擁有懶載入的特徵,你不去讀取value的時候,它是不會去真正的求值的。

前置準備

首先要知道,effect函數會立即開始執行,再執行之前,先把effect自身變成全局的activeEffect,以供響應式數據收集依賴。

並且activeEffect的記錄是用棧的方式,隨著函數的開始執行入棧,隨著函數的執行結束出棧,這樣就可以維護嵌套的effect關係。

先起幾個別名便於講解

// 計算effect  computed(() => data.count + 1)  // 日誌effect  effect(() => console.log(plusOne.value))  複製程式碼

從依賴關係來看, 日誌effect讀取了計算effect 計算effect讀取了響應式屬性count 所以更新的順序也應該是: count改變 -> 計算effect更新 -> 日誌effect更新

那麼這個關係鏈是如何形成的呢

單步解讀

在日誌effect開始執行的時候,

⭐⭐ 此時activeEffect是日誌effect

此時的effectStack是[ 日誌effect ] ⭐⭐

plusOne.value的讀取,觸發了

 get value() {        if (dirty) {          // 在真正的去獲取計算屬性的value的時候          // 依據dirty的值決定去不去重新執行getter 獲取最新值          value = runner()          dirty = false        }        // 這裡是關鍵 後續講解        trackChildRun(runner)        return value  },  複製程式碼

首先進入了求值過程:value = runner(),runner其實就是計算effect,它是對於用戶傳入的getter函數的包裝,

進入了runner以後

⭐⭐ 此時activeEffect是計算effect

此時的effectStack是[ 日誌effect, 計算effect ] ⭐⭐ runner所包裹的() => data.count + 1也就是計算effect會去讀取count,因為是由effect包裹的函數,所以觸發了響應式數據的get攔截:

此時count會收集計算effect作為自己的依賴。

並且計算effect會收集count的依賴集合,保存在自己身上。(通過effect.deps屬性)

dep.add(activeEffect)  activeEffect.deps.push(dep)  複製程式碼

也就是形成了一個雙向收集的關係,

計算effect存了count的所有依賴,count也存了計算effect的依賴。

然後在runner運行結束後,計算effect出棧了,此時activeEffect變成了棧頂的日誌effect

⭐⭐ 此時activeEffect是日誌effect

此時的effectStack是[ 日誌effect ] ⭐⭐

接下來進入關鍵的步驟trackChildRun

trackChildRun(runner)    function trackChildRun(childRunner: ReactiveEffect) {    for (let i = 0; i < childRunner.deps.length; i++) {      const dep = childRunner.deps[i]      dep.add(activeEffect)    }  }  複製程式碼

這個runner就是計算effect,它的deps上此時掛著count的依賴集合,

trackChildRun中,它把當前的acctiveEffect也就是日誌effect也加入到了count的依賴集合中。

此時count的依賴集合是這樣的:[ 計算effect, 日誌effect ]

這樣下次count更新的時候,會把兩個effect都重新觸發,而由於觸發的順序是先觸發computed effect 後觸發普通effect,因此就完成了

  1. 計算effect的dirty置為true,標誌著下次讀取需要重新求值。
  2. 日誌effect讀取計算effect的value,獲得最新的值並列印出來。

總結

不得不承認,computed這個強大功能的實現果然少不了內部非常複雜的實現,這個雙向依賴收集的套路相信也會給各位小夥伴帶來很大的啟發。跟著尤大學習,果然有肉吃!

另外由於@vue/reactivity的框架無關性,我把它整合進了React,做了一個狀態管理庫,可以完整的使用上述的computed等強大的Vue3能力。

react-composition-api

有興趣的小夥伴也可以看一下,star一下!