Vue3 源碼解析(七):依賴收集與副作用函數

在上一篇文章《響應式原理與 reactive》中由於篇幅限制筆者留下了兩個小懸念 track 依賴收集處理器與 trigger 派發更新處理器沒有細緻講解,而在本篇文章中筆者會帶着大家一起來學習 Vue3 響應式系統中的依賴收集部分和副作用函數。

Vue 是怎樣追蹤變化的?

當我們在 template 模板中使用響應式變量,或者在計算屬性中傳入 getter 函數後當計算屬性中的源數據發生變化後,Vue 總能即時的通知更新並重新渲染組件,這些神奇的現象是如何實現的呢?

Vue 通過一個副作用(effect)函數來跟蹤當前正在運行的函數。副作用是一個函數包裹器,在函數被調用前就啓動跟蹤,而 Vue 在派發更新時就能準確的找到這些被收集起來的副作用函數,當數據發生更新時再次執行它。

爲了更好的理解依賴的收集過程,筆者先從副作用函數的實現開始說起。

effect 的類型

老規矩在介紹副作用之前,先一起看一下副作用的類型,這樣能夠幫助大家先對副作用“長的什麼樣子”有一個直觀的概念。

export interface ReactiveEffect<T = any> {
  (): T
  _isEffect: true
  id: number
  active: boolean
  raw: () => T
  deps: Array<Dep>
  options: ReactiveEffectOptions
  allowRecurse: boolean
}

從副作用的類型定義中可以清晰的看到它定義了一個泛型參數,這個泛型會被當做內部副作用函數的返回值,並且這個類型本身就是一個函數。還有一個 _isEffect 屬性標識這是一個副作用;active 屬性是用來標識這個副作用啓用和停用的狀態;raw 屬性保存初始傳入的函數;deps 屬性是這個副作用的所有依賴,對於這個數組中元素的 Dep 類型我們筆者就會介紹到;options 中保存着副作用對象的一些配置項;而 allowRecurse 暫時不用關注,它是一個副作用函數能否自身調用的標識。

副作用的全局變量

有三個變量是定義在副作用模塊中的全局變量,而提前認識這些變量能夠幫助我們瞭解整個副作用函數的生成以及調用的過程。

type Dep = Set<ReactiveEffect>
type KeyToDepMap = Map<any, Dep>
const targetMap = new WeakMap<any, KeyToDepMap>()

const effectStack: ReactiveEffect[] = []
let activeEffect: ReactiveEffect | undefined

targetMap:

這個 targetMap 是一個非常重要的變量,它是 WeakMap 類型,存儲了 { target -> key -> dep } 的鏈接。

targetMap 的值的類型是 KeyToDepMap,而 KeyToDepMap 又是一個以 Dep 爲值的類型的 Map 對象,Dep 就是筆者一直在提及的依賴,Vue 收集依賴其實就是在收集 Dep 類型。所以對照 Vue2 的源碼,從概念上來講,將依賴看成是一個維護了訂閱者 Set 集合的 Dep 類更容易理解,在 targetMap 中只是將 Dep 存儲在一個原始的 Set 集合中,是出於減少內存開銷的考慮。

effectStatck

這是一個存放當前正被調用的副作用的棧,當一個副作用在執行前會被壓入棧中,而在結束之後會被推出棧。

activeEffect

這個變量標記了當前正在執行的副作用,或者也可以理解爲副作用棧中的棧頂元素。當一個副作用被壓入棧時,會將這個副作用賦值給 activeEffect 變量,而當副作用中的函數執行完後該副作用會出棧,並將 activeEffect 賦值爲棧的下一個元素。所以當棧中只有一個元素時,執行完出棧後,activeEffect 就會爲 undefined。

副作用(effect)的實現

在學習完需要前置理解的類型與變量後,筆者就開始講解副作用函數的實現,話不多說直接看代碼。

export function effect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
  // 如果 fn 已經是一個副作用函數,則返回副作用的原始函數
  if (isEffect(fn)) {
    fn = fn.raw
  }
  // 創建一個副作用
  const effect = createReactiveEffect(fn, options)
  // 如果不是延遲執行的,則立即執行一次副作用函數
  if (!options.lazy) {
    effect()
  }
  // 返回生成的副作用函數
  return effect
}

effect api 的函數相對簡單,當傳入的 fn 已經是一個副作用函數時,會將 fn 賦值爲這個副作用的原始函數。接着會調用 createReactiveEffect 創建一個 ReactiveEffect 類型的函數,如果副作用的選項中沒有設置延遲執行,那麼這個副作用函數會被立即執行一次,最後將生成的副作用函數返回。

接着一起來看創建副作用函數的 createReactiveEffect 的邏輯。

createReactiveEffect

在 createReactiveEffect 中,首先會創建一個變量名爲 effect 的函數表達式,之後爲這個函數設置之前在 ReactiveEffect 類型中提及到的一些屬性,最後將這個函數返回。

而當這個 effect 函數被執行時,會首先判斷自己是不是已經停用,如果是停用狀態,則會查看選項中是否有調度函數,如果有調度函數就不再處理,直接 return undefined,若是不存在調度函數,則執行並返回傳入的 fn 函數,之後就不再運行下去。

如果 effect 函數狀態正常,會判斷當前 effect 函數是否已經在副作用棧中,若是已經被加入棧中,則不再繼續處理,避免循環調用。

如果當前 effect 函數不在棧中,就會通過 cleanup 函數清理副作用函數的依賴,並且打開依賴收集開關,將副作用函數壓入副作用棧中,並記錄當前副作用函數爲 activeEffect。這段邏輯筆者在介紹這兩個變量時已經講過,它就是在此處觸發的。

接下來就會執行傳入的 fn 函數被返回結果。

當函數執行完畢後,會將副作用函數彈出棧中,並且將依賴收集開關重置爲執行副作用前的狀態,再將 activeEffect 標記爲當前棧頂的元素。此時一次副作用函數的執行徹底結束,跟着筆者一起來看一下源碼的實現。

function createReactiveEffect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions
): ReactiveEffect<T> {
  // 通過一個函數表達式,創建一個變量名爲 effect ,函數名爲 reactiveEffect 的函數
  const effect = function reactiveEffect(): unknown {
    // 如果 effect 已停用,當選項中有調度函數時返回 undefined,否則返回原始函數
    if (!effect.active) {
      return options.scheduler ? undefined : fn()
    }
    if (!effectStack.includes(effect)) {
      // 清理依賴
      cleanup(effect)
      try {
        // 允許收集依賴
        enableTracking()
        effectStack.push(effect)
        activeEffect = effect
        return fn()
      } finally {
        effectStack.pop()
        resetTracking()
        activeEffect = effectStack[effectStack.length - 1]
      }
    }
  } as ReactiveEffect
  // 爲副作用函數設置屬性
  effect.id = uid++
  effect.allowRecurse = !!options.allowRecurse
  effect._isEffect = true
  effect.active = true
  effect.raw = fn
  effect.deps = []
  effect.options = options
  return effect
}

當在最後一行 return 了副作用函數後,上一段提及提及當 options 參數中 lazy 爲 false 時,這個副作用函數就會第一次被調用,此時就會觸發這段函數 第 6 行 const effect 創建函數後的函數內部邏輯。

理解了createReactiveEffect 的執行順序後,再配合詳細的邏輯講解,相信你也已經掌握 effect 副作用函數的創建了。

收集依賴、派發更新

爲了更邏輯順暢的引出依賴收集和派發更新的工作及實現流程,筆者決定在此處引入一個 Vue3 中 effect 模塊的一個簡單的單元測試用例,給大家講解示例的同時順帶聊聊依賴收集和派發更新。

let foo
const counter = reactive({ num: 0 })
effect(() => (foo = counter.num))
// 此時 foo 應該是 0
counter.num = 7
// 此時 foo 應該是 7

這是一個最簡單的 effect 的示例,我們都知道 foo 會隨着 counter.num 的改變而改變。那麼究竟是如何更新的呢?

首先,counter 通過 reactive api 生成一個 proxy 代理對象。這一個生成過程在上一篇文章中已經講解過了,所以這裏就不細講了。

接着使用 effect,向它傳入一個函數。這時 effect 開始它的創建過程,在 effect 函數中會執行到下方代碼的這一步。

const effect = createReactiveEffect(fn, options)

通過 createReactiveEffect 開始創建 effect 函數,並返回。

當 effect 函數被返回後,就會判斷當前副作用的選項中是否需要延遲執行,而這裏我們沒有傳入任何參數,所以不是延遲加載,需要立即執行,所以會開始執行返回回來的 effect 函數。

if (!options.lazy) {
    effect() // 不需要延遲執行,執行 effect 函數
}

於是會開始執行 createReactiveEffect 創建 effect 函數時的內部代碼邏輯。

const effect = function reactiveEffect(): unknown {/* 執行此函數內的邏輯 */}

由於 effect 函數是 active 狀態,並且也不在副作用棧中,於是會先清除依賴,由於現在並沒有收集任何依賴,所以 cleanup 的過程不用關心。接着會將 effet 壓入棧中,並設置爲 activeEffect,接下來會開始執行初始傳入的 fn:() => (foo = counter.num)

給 foo 賦值時,會先訪問 counter 的 num 屬性,所以會觸發 counter 的 proxy handler 的 get 陷阱:

// get 陷阱
return function get(target: Target, key: string | symbol, receiver: object) {
    /* 忽略邏輯 */
  // 獲取 Reflect 執行的 get 默認結果
  const res = Reflect.get(target, key, receiver)
  if (!isReadonly) {
    // 依賴收集
    track(target, TrackOpTypes.GET, key)
  }
  return res
}

這裏我簡化了 get 中的代碼,只保留關鍵部分,可以看到在獲取到 res 的值後,會通過 track 開始依賴收集。(🥺 注意要開始講依賴收集了哦,不要走神)

track 收集依賴

track 函數的路徑也是在 @vue/reactivity 庫的 effect.ts 的文件中。

在 track 的過程中,首先會判斷是否允許收集依賴,這個狀態是受 enableTracking()pauseTracking() 這一對函數控制的。接着會判斷當前是否有正在執行的副作用函數,如果沒有則直接 return。因爲依賴收集其實就是在收集副作用函數

接着從本文一開始介紹過的 targetMap 中去嘗試獲取對應的 traget 的依賴集合,並存儲在 depsMap 變量中,如果獲取失敗,就會將當前 target 添加進依賴集合中,並將 value 初始化爲 new Map()。例如在當前的示例中,target 即爲 { num: 0 },是 counter 對象的值。

在有了 depsMap 後,就會根據 target 中被讀取的 key,去依賴集合中查看是否有對應 key 的依賴,並賦值給 dep。如果沒有,就跟創建 depsMap 的邏輯一樣,創建一個 Set 類型的集合當做值。

如果當前執行的副作用函數沒有被 dep 這個 Set 集合當做依賴收集,就會將當前副作用函數添加進 dep 中,並且在當前的副作用函數的 deps 屬性中添加進該依賴 dep。

看到這裏,就能夠想象出依賴的收集是一個什麼樣的結構了。以 key 爲維度,將每一個 key 關聯的副作用函數收集起來,存放在一個 Set 數據結構中,並以鍵值對的形式存儲在 depsMap 的 Map 結構中。此時再看文章開頭描述 targetMap 這個Map 存儲的形式 { target -> key -> dep } 應該說是非常明確了。

track 處理器函數的代碼如下:

export function track(target: object, type: TrackOpTypes, key: unknown) {
  // 不啓用依賴收集,或者沒有 activeEffect 則直接 return
  if (!shouldTrack || activeEffect === undefined) {
    return
  }
  // 在 targetMap 中獲取對應的 target 的依賴集合
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    // 如果 target 不在 targetMap 中,則加入,並初始化 value 爲 new Map()
    targetMap.set(target, (depsMap = new Map()))
  }
  // 從依賴集合中獲取對應的 key 的依賴
  let dep = depsMap.get(key)
  if (!dep) {
    // 如果 key 不存在,將這個 key 作爲依賴收集起來,並初始化 value 爲 new Set()
    depsMap.set(key, (dep = new Set()))
  }
  // 如果依賴中並不存當前的 effect 副作用函數
  if (!dep.has(activeEffect)) {
    // 將當前的副作用函數收集進依賴中
    dep.add(activeEffect)
    // 並在當前副作用函數的 deps 屬性中記錄該依賴
    activeEffect.deps.push(dep)
  }
}

看完 track 繼續看我們的示例:

effect(() => (foo = counter.num))

當 track 收集完依賴後,get 陷阱返回了 Reflect.get 的結果,讀取到了 counter.num 的值爲 0,並將此結果賦值給 foo 變量。此時副作用 函數第一次運行結束,foo 已經有了值:0。當副作用函數執行完,會將當前的副作用函數彈出棧中,並且將 activeEffect 賦值爲 undefeind。

trigger 派發更新

搞懂了依賴收集之後,繼續來看派發更新的過程。

示例的最後一行代碼,將 num 賦值爲 7。

counter.num = 7

我們知道 foo 一定會同步更新爲 7 的。那麼過程是怎樣的呢?

當對 counter.num 賦值時,會觸發 set 陷阱:

const result = Reflect.set(target, key, value, receiver)
if (target === toRaw(receiver)) {
  if (!hadKey) {
    // 當 key 不存在時,觸發 trigger 的 ADD 事件
    trigger(target, TriggerOpTypes.ADD, key, value)
  } else if (hasChanged(value, oldValue)) {
    // 當 key 存在時,當新舊值變化後,觸發 trigger 的 SET 事件
    trigger(target, TriggerOpTypes.SET, key, value, oldValue)
  }
}
return result

一起來看 set 陷阱的部分代碼,trigger 的觸發會傳入一個 TriggerOpTypes 的枚舉,枚舉有四種類型,對應增、刪、改、清空操作。

export const enum TriggerOpTypes {
  SET = 'set',
  ADD = 'add',
  DELETE = 'delete',
  CLEAR = 'clear'
}

由於 counter 通過 reactive api 創建代理對象時已經添加了 num 這個 key,所以此時新舊值發生改變,就會觸發 SET 事件。

接着會執行 trigger 函數。

trigger 函數會立即從 targetMap 中通過 target 獲取 depsMap,如果沒有對應的 depsMap 就代表當前的 traget 從未通過 track 進行依賴收集,所以直接 return,不繼續執行。

接着會創建一個名爲 effects 的 Set 結構的集合,它的作用是存儲這個 key 所有需要派發更新執行的副作用函數。

同時聲明一個 add 函數,add 函數的作用是遍歷傳入的副作用函數,將不是當前正在執行的 activeEffect 函數或者能夠自我執行的副作用函數都加入到 effects 集合中。

然後會判斷清空依賴和數組的特殊情況,按需調用 add 函數添加依賴。

之後會判斷當前 key 是否不爲 undefined,注意這裏的判斷條件 void 0,是通過 void 運算符的形式表示 undefined,如果有 key 則將 key 相關的依賴通過 add 函數添加進 effects 集合中。

隨後的 Switch Case 通過區分 triggerOpTypes 來處理一些迭代鍵的特殊邏輯。

之後聲明的 run 函數作用就是來執行添加入 effects 數組中的副作用函數。

trigger 函數的結尾就是通過 effects.forEach(run) 遍歷集合內的所有副作用函數並執行。

先一起來看一下 trigger 的代碼:

export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
  const depsMap = targetMap.get(target)
  if (!depsMap) {
        // 該 target 從未被追蹤,不繼續執行
    return
  }
    
  // effects 集合存放所有需要派發更新的副作用函數。
  const effects = new Set<ReactiveEffect>()
  // 將不是當前副作用函數以及能執行自身的副作用函數加入集合中
  const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
    if (effectsToAdd) {
      effectsToAdd.forEach(effect => {
        if (effect !== activeEffect || effect.allowRecurse) {
          effects.add(effect)
        }
      })
    }
  }

  if (type === TriggerOpTypes.CLEAR) {
        // 當需要清除依賴時,將當前 target 的依賴全部傳入
    depsMap.forEach(add)
  } else if (key === 'length' && isArray(target)) {
    // 處理數組的特殊情況
    depsMap.forEach((dep, key) => {
      if (key === 'length' || key >= (newValue as number)) {
        add(dep)
      }
    })
  } else {
    // 在 SET | ADD | DELETE 的情況,添加當前 key 的依賴
    if (key !== void 0) {
      add(depsMap.get(key))
    }

    // 對 ADD | DELETE | Map.SET 執行一些迭代鍵的邏輯
    switch (type) { /* 暫時忽略 */ }
  }
    
  // 執行 effect 的函數
  const run = (effect: ReactiveEffect) => {
    // 判斷是否有調度器,如果有則執行調度函數並將 effect 作爲參數傳入
    if (effect.options.scheduler) {
      effect.options.scheduler(effect)
    } else {
      // 否則直接執行副作用函數
      effect()
    }
  }
    // 遍歷集合,執行收集到的副作用函數
  effects.forEach(run)
}

在將 SwitchCase 的特殊邏輯,以及 DEV 環境的特殊邏輯隱藏後,trigger 函數的長度已經比較精簡且邏輯清晰了。

回到我們的示例,當在 trigger 判斷是否有 key,並將 key 對應的依賴傳入 add 函數時,示例在 track 時被收集的副作用函數已經被 effects 集合獲取到了。當 trigger 執行到最後一行代碼時,副作用函數就會當做參數被傳入 run 函數,由於沒有設置調度器,所以會直接執行這個副作用函數:() => (foo = counter.num) ,執行完畢,foo 的值成功的被更新到 7。

至此收集依賴和派發更新的流程已經完整的結束,而本文的示例也運行完畢了,相信大家對這個過程也有了印象深刻的認識。如果還是有點犯迷糊,建議將本文 effect, track 和 trigger 的函數,以及上文 get、set 的陷阱源碼聯繫起來再看看,相信你會豁然開朗的。

總結

本篇文章中,筆者先給大家詳細講解了副作用已經副作用函數的生成過程以及執行時機。又通過一個簡單的示例引出依賴收集和派發更新的過程,在將這兩個部分時,結合上文中講過的 get 和 set 這兩個代理對象的 hanlders 陷阱將流程完整的串在一起,按照示例執行的流程給大家講完了整個依賴收集和派發更新的過程。

這也解答了文章開頭提到的問題:Vue 是如何追蹤變化的?通過 track 收集副作用的依賴,並在 trigger 時執行對應的副作用函數完成更新。

最後,如果這篇文章能夠幫助到你瞭解 Vue3 中的響應式的副作用以及依賴收集和派發更新的流程,希望能給本文點一個喜歡❤️。如果想繼續追蹤後續文章,也可以關注我的賬號或 follow 我的 github,再次謝謝各位可愛的看官老爺。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章