Vue3計算屬性是如何實現的?聊聊實現原理

2022-04-14 10:00:08
Vue3計算屬性是如何實現的?下面本篇文章就來帶大家淺析下Vue3.0 中計算屬性的實現原理,希望對大家有所幫助!

計算屬性是 開發中一個非常實用的 API ,它允許使用者定義一個計算方法,然後根據一些依賴的響應式資料計算出新值並返回。當依賴發生變化時,計算屬性可以自動重新計算獲取新值,所以使用起來非常方便。

在 Vue.js 2.x 中,相信你對計算屬性的應用已經如數家珍了,我們可以在元件物件中定義 computed 屬性。到了 Vue.js 3.0 ,雖然也可以在元件中沿用 Vue.js 2.x 的使用方式,但是我們也可以單獨使用計算屬性 API。

計算屬性本質上還是對依賴的計算,那麼為什麼我們不直接用函數呢?在 Vue.js 3.0 中計算屬性的 API 又是如何實現呢?本文就來分析下計算屬性的實現原理。(學習視訊分享:)

計算屬性API:computed

Vue.js 3.0 提供了一個 computed 函數作為計算屬性 API,我們先來看看它是如何使用的。

我們舉個簡單的例子:

const count = ref(1) 
const plusOne = computed(() => count.value + 1) 
console.log(plusOne.value) // 2 
plusOne.value++ // error 
count.value++ 
console.log(plusOne.value) // 3

從程式碼中可以看到,我們先使用 ref API 建立了一個響應式物件 count,然後使用 computed API 建立了另一個響應式物件 plusOne,它的值是 count.value + 1,當我們修改 count.value 的時候, plusOne.value 就會自動發生變化。

注意,這裡我們直接修改 plusOne.value 會報一個錯誤,這是因為如果我們傳遞給 computed 的是一個函數,那麼這就是一個 getter 函數,我們只能獲取它的值,而不能直接修改它。

在 getter 函數中,我們會根據響應式物件重新計算出新的值,這也就是它被叫做計算屬性的原因,而這個響應式物件,就是計算屬性的依賴。

當然,有時候我們也希望能夠直接修改 computed 的返回值,那麼我們可以給 computed 傳入一個物件:

const count = ref(1) 
const plusOne = computed({ 
  get: () => count.value + 1, 
  set: val => { 
    count.value = val - 1 
  } 
}) 
plusOne.value = 1 
console.log(count.value) // 0

在這個例子中,結合上述程式碼可以看到,我們給 computed 函數傳入了一個擁有 getter 函數和 setter 函數的物件,getter 函數和之前一樣,還是返回 count.value + 1;而 setter 函數,請注意,這裡我們修改 plusOne.value 的值就會觸發 setter 函數,其實 setter 函數內部實際上會根據傳入的引數修改計算屬性的依賴值 count.value,因為一旦依賴的值被修改了,我們再去獲取計算屬性就會重新執行一遍 getter,所以這樣獲取的值也就發生了變化。

好了,我們現在已經知道了 computed API 的兩種使用方式了,接下來就看看它是怎樣實現的:

function computed(getterOrOptions) { 
  // getter 函數 
  let getter 
  // setter 函數 
  let setter 
  // 標準化引數 
  if (isFunction(getterOrOptions)) { 
    // 表面傳入的是 getter 函數,不能修改計算屬性的值 
    getter = getterOrOptions 
    setter = (process.env.NODE_ENV !== 'production') 
      ? () => { 
        console.warn('Write operation failed: computed value is readonly') 
      } 
      : NOOP 
  } 
  else { 
    getter = getterOrOptions.get 
    setter = getterOrOptions.set 
  } 
  // 資料是否髒的 
  let dirty = true 
  // 計算結果 
  let value 
  let computed 
  // 建立副作用函數 
  const runner = effect(getter, { 
    // 延時執行 
    lazy: true, 
    // 標記這是一個 computed effect 用於在 trigger 階段的優先順序排序 
    computed: true, 
    // 排程執行的實現 
    scheduler: () => { 
      if (!dirty) { 
        dirty = true 
        // 派發通知,通知執行存取該計算屬性的 activeEffect 
        trigger(computed, "set" /* SET */, 'value') 
      } 
    } 
    }) 
  // 建立 computed 物件 
  computed = { 
    __v_isRef: true, 
    // 暴露 effect 物件以便計算屬性可以停止計算 
    effect: runner, 
    get value() { 
      // 計算屬性的 getter 
      if (dirty) { 
        // 只有資料為髒的時候才會重新計算 
        value = runner() 
        dirty = false 
      } 
      // 依賴收集,收集執行存取該計算屬性的 activeEffect 
      track(computed, "get" /* GET */, 'value') 
      return value 
    }, 
    set value(newValue) { 
      // 計算屬性的 setter 
      setter(newValue) 
    } 
  } 
  return computed 
}

從程式碼中可以看到,computed 函數的流程主要做了三件事情:標準化引數建立副作用函數建立 computed 物件。 我們來詳細分析一下這幾個步驟。

首先是標準化引數。computed 函數接受兩種型別的引數,一個是 getter 函數,一個是擁有 getter 和 setter 函數的物件,通過判斷引數的型別,我們初始化了函數內部定義的 getter 和 setter 函數。

接著是建立副作用函數 runner。computed 內部通過 effect 建立了一個副作用函數,它是對 getter 函數做的一層封裝,另外我們這裡要注意第二個引數,也就是 effect 函數的設定物件。其中 lazy 為 true 表示 effect 函數返回的 runner 並不會立即執行;computed 為 true 用於表示這是一個 computed effect,用於 trigger 階段的優先順序排序,我們稍後會分析;scheduler 表示它的排程執行的方式,我們也稍後分析。

最後是建立 computed 物件並返回,這個物件也擁有 getter 和 setter 函數。當 computed 物件被存取的時候會觸發 getter,然後會判斷是否 dirty,如果是就執行 runner,然後做依賴收集;當我們直接設定 computed 物件時會觸發 setter,即執行 computed 函數內部定義的 setter 函數。

計算屬性的執行機制

computed 函數的邏輯會有一點繞,不過不要緊,我們可以結合一個應用 computed 計算屬性的例子,來理解整個計算屬性的執行機制。分析之前我們需要記住 computed 內部兩個重要的變數,第一個 dirty 表示一個計算屬性的值是否是「髒的」,用來判斷需不需要重新計算,第二個 value 表示計算屬性每次計算後的結果。

現在,我們來看這個範例:

<template> 
  <div> 
    {{ plusOne }} 
  </div> 
  <button @click="plus">plus</button> 
</template> 
<script> 
  import { ref, computed } from 'vue' 
  export default { 
    setup() { 
      const count = ref(0) 
      const plusOne = computed(() => { 
        return count.value + 1 
      }) 

      function plus() { 
        count.value++ 
      } 
      return { 
        plusOne, 
        plus 
      } 
    } 
  } 
</script>

可以看到,在這個例子中我們利用 computed API 建立了計算屬性物件 plusOne,它傳入的是一個 getter 函數,為了和後面計算屬性物件的 getter 函數區分,我們把它稱作 computed getter。另外,元件模板中參照了 plusOne 變數和 plus 函數。

元件渲染階段會存取 plusOne,也就觸發了 plusOne 物件的 getter 函數:

get value() { 
  // 計算屬性的 getter 
  if (dirty) { 
    // 只有資料為髒的時候才會重新計算 
    value = runner() 
    dirty = false 
  } 
  // 依賴收集,收集執行存取該計算屬性的 activeEffect 
  track(computed, "get" /* GET */, 'value') 
  return value 
}

由於預設 dirty 是 true,所以這個時候會執行 runner 函數,並進一步執行 computed getter,也就是 count.value + 1,因為存取了 count 的值,並且由於 count 也是一個響應式物件,所以就會觸發 count 物件的依賴收集過程。

請注意,由於是在 runner 執行的時候存取 count,所以這個時候的 activeEffect 是 runner 函數。runner 函數執行完畢,會把 dirty 設定為 false,並進一步執行 track(computed,"get",'value') 函數做依賴收集,這個時候 runner 已經執行完了,所以 activeEffect 是元件副作用渲染函數。

所以你要特別注意這是兩個依賴收集過程:對於 plusOne 來說,它收集的依賴是元件副作用渲染函數;對於 count 來說,它收集的依賴是 plusOne 內部的 runner 函數。

然後當我們點選按鈕的時候,會執行 plus 函數,函數內部通過 count.value++ 修改 count 的值,並派發通知。請注意,這裡不是直接呼叫 runner 函數,而是把 runner 作為引數去執行 scheduler 函數。我們來回顧一下 trigger 函數內部對於 effect 函數的執行方式:

const run = (effect) => { 
  // 排程執行 
  if (effect.options.scheduler) { 
    effect.options.scheduler(effect) 
  } 
  else { 
    // 直接執行 
    effect() 
  } 
}

computed API 內部建立副作用函數時,已經設定了 scheduler 函數,如下:

scheduler: () => { 
  if (!dirty) { 
    dirty = true 
    // 派發通知,通知執行存取該計算屬性的 activeEffect 
    trigger(computed, "set" /* SET */, 'value') 
  } 
}

它並沒有對計算屬性求新值,而僅僅是把 dirty 設定為 true,再執行 trigger(computed, "set" , 'value'),去通知執行 plusOne 依賴的元件渲染副作用函數,即觸發元件的重新渲染。

在元件重新渲染的時候,會再次存取 plusOne,我們發現這個時候 dirty 為 true,然後會再次執行 computed getter,此時才會執行 count.value + 1 求得新值。這就是雖然元件沒有直接存取 count,但是當我們修改 count 的值的時候,元件仍然會重新渲染的原因。

通過下圖可以直觀的展現上述過程:

1.png

通過以上分析,我們可以看出 computed 計算屬性有兩個特點:

  • 延時計算,只有當我們存取計算屬性的時候,它才會真正執行 computed getter 函數計算;

  • 快取,它的內部會快取上次的計算結果 value,而且只有 dirty 為 true 時才會重新計算。如果存取計算屬性時 dirty 為 false,那麼直接返回這個 value。

現在,我們就可以回答開頭提的問題了。和單純使用普通函數相比,計算屬性的優勢是:只要依賴不變化,就可以使用快取的 value 而不用每次在渲染元件的時候都執行函數去計算,這是典型的空間換時間的優化思想。

巢狀計算屬性

計算屬性也支援巢狀,我們可以針對上述例子做個小修改,即不在渲染函數中存取 plusOne,而在另一個計算屬性中存取:

const count = ref(0) 
const plusOne = computed(() => { 
  return count.value + 1 
}) 
const plusTwo = computed(() => { 
  return plusOne.value + 1 
}) 
console.log(plusTwo.value)

從程式碼中可以看到,當我們存取 plusTwo 的時候,過程和前面都差不多,同樣也是兩個依賴收集的過程。對於 plusOne 來說,它收集的依賴是 plusTwo 內部的 runner 函數;對於 count 來說,它收集的依賴是 plusOne 內部的 runner 函數。

接著當我們修改 count 的值時,它會派發通知,先執行 plusOne 內部的 scheduler 函數,把 plusOne 內部的 dirty 變為 true,然後執行 trigger 函數再次派發通知,接著執行 plusTwo 內部的 scheduler 函數,把 plusTwo 內部的 dirty 設定為 true。

然後當我們再次存取 plusTwo 的值時,發現 dirty 為 true,就會執行 plusTwo 的 computed getter 函數去執行 plusOne.value + 1,進而執行 plusOne 的 computed gette 即 count.value + 1 + 1,求得最終新值 2。

得益於 computed 這種巧妙的設計,無論巢狀多少層計算屬性都可以正常工作。

計算屬性的執行順序

我們曾提到計算屬性內部建立副作用函數的時候會設定 computed 為 true,標識這是一個 computed effect,用於在 trigger 階段的優先順序排序。我們來回顧一下 trigger 函數執行 effects 的過程:

const add = (effectsToAdd) => { 
  if (effectsToAdd) { 
    effectsToAdd.forEach(effect => { 
      if (effect !== activeEffect || !shouldTrack) { 
        if (effect.options.computed) { 
          computedRunners.add(effect) 
        } 
        else { 
          effects.add(effect) 
        } 
      } 
    }) 
  } 
} 
const run = (effect) => { 
  if (effect.options.scheduler) { 
    effect.options.scheduler(effect) 
  } 
  else { 
    effect() 
  } 
} 
computedRunners.forEach(run) 
effects.forEach(run)

在新增待執行的 effects 的時候,我們會判斷每一個 effect 是不是一個 computed effect,如果是的話會新增到 computedRunners 中,在後面執行的時候會優先執行 computedRunners,然後再執行普通的 effects。

那麼為什麼要這麼設計呢?其實是考慮到了一些特殊場景,我們通過一個範例來說明:

import { ref, computed } from 'vue' 
import { effect } from '@vue/reactivity' 
const count = ref(0) 
const plusOne = computed(() => { 
  return count.value + 1 
}) 
effect(() => { 
  console.log(plusOne.value + count.value) 
}) 
function plus() { 
  count.value++ 
} 
plus()

這個範例執行後的結果輸出:

1 
3 
3

在執行 effect 函數時執行 console.log(plusOne.value + count.value),所以第一次輸出 1,此時 count.value 是 0,plusOne.value 是 1。

後面連續輸出兩次 3 是因為, plusOne 和 count 的依賴都是這個 effect 函數,所以當我們執行 plus 函數修改 count 的值時,會觸發並執行這個 effect 函數,因為 plusOne 的 runner 也是 count 的依賴,count 值修改也會執行 plusOne 的 runner,也就會再次執行 plusOne 的依賴即 effect 函數,因此會輸出兩次。

那麼為什麼兩次都輸出 3 呢?這就跟先執行 computed runner 有關。首先,由於 plusOne 的 runner 和 effect 都是 count 的依賴,當我們修改 count 值的時候, plusOne 的 runner 和 effect 都會執行,那麼此時執行順序就很重要了。

這裡先執行 plusOne 的 runner,把 plusOne 的 dirty 設定為 true,然後通知它的依賴 effect 執行 plusOne.value + count.value。這個時候,由於 dirty 為 true,就會再次執行 plusOne 的 getter 計算新值,拿到了新值 2, 再加上 1 就得到 3。執行完 plusOne 的 runner 以及依賴更新之後,再去執行 count 的普通effect 依賴,從而去執行 plusOne.value + count.value,這個時候 plusOne dirty 為 false, 直接返回上次的計算結果 2,然後再加 1 就又得到 3。

如果我們把 computed runner 和 effect 的執行順序換一下會怎樣呢?我來告訴你,會輸出如下結果:

1 
2 
3

第一次輸出 1 很好理解,因為流程是一樣的。第二次為什麼會輸出 2 呢?我們來分析一下,當我們執行 plus 函數修改 count 的值時,會觸發 plusOne 的 runner 和 effect 的執行,這一次我們先讓 effect 執行 plusOne.value + count.value,那麼就會存取 plusOne.value,但由於 plusOne 的 runner 還沒執行,所以此時 dirty 為 false,得到的值還是上一次的計算結果 1,然後再加 1 得到 2。

接著再執行 plusOne 的 runner,把 plusOne 的 dirty 設定為 true,然後通知它的依賴 effect 執行 plusOne.value + count.value,這個時候由於 dirty 為 true,就會再次執行 plusOne 的 getter 計算新值,拿到了 2,然後再加上 1 就得到 3。

知道原因後,我們再回過頭看例子。因為 effect 函數依賴了 plusOne 和 count,所以 plusOne 先計算會更合理,這就是為什麼我們需要讓 computed runner 的執行優先於普通的 effect 函數。

總結

本文分析了計算屬性的工作機制、計算屬性巢狀程式碼的執行順序,以及計算屬性的兩個特點——延時計算和快取。

本文轉載自:https://juejin.cn/post/7085524315063451684

作者:風度前端

(學習視訊分享:)

以上就是Vue3計算屬性是如何實現的?聊聊實現原理的詳細內容,更多請關注TW511.COM其它相關文章!