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

在上一篇文章《響應(yīng)式原理與 reactive》中由于篇幅限制筆者留下了兩個小懸念 track 依賴收集處理器與 trigger 派發(fā)更新處理器沒有細致講解,而在本篇文章中筆者會帶著大家一起來學(xué)習(xí) Vue3 響應(yīng)式系統(tǒng)中的依賴收集部分和副作用函數(shù)。

Vue 是怎樣追蹤變化的?

當(dāng)我們在 template 模板中使用響應(yīng)式變量,或者在計算屬性中傳入 getter 函數(shù)后當(dāng)計算屬性中的源數(shù)據(jù)發(fā)生變化后,Vue 總能即時的通知更新并重新渲染組件,這些神奇的現(xiàn)象是如何實現(xiàn)的呢?

Vue 通過一個副作用(effect)函數(shù)來跟蹤當(dāng)前正在運行的函數(shù)。副作用是一個函數(shù)包裹器,在函數(shù)被調(diào)用前就啟動跟蹤,而 Vue 在派發(fā)更新時就能準確的找到這些被收集起來的副作用函數(shù),當(dāng)數(shù)據(jù)發(fā)生更新時再次執(zhí)行它。

為了更好的理解依賴的收集過程,筆者先從副作用函數(shù)的實現(xiàn)開始說起。

effect 的類型

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

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

從副作用的類型定義中可以清晰的看到它定義了一個泛型參數(shù),這個泛型會被當(dāng)做內(nèi)部副作用函數(shù)的返回值,并且這個類型本身就是一個函數(shù)。還有一個 _isEffect 屬性標(biāo)識這是一個副作用;active 屬性是用來標(biāo)識這個副作用啟用和停用的狀態(tài);raw 屬性保存初始傳入的函數(shù);deps 屬性是這個副作用的所有依賴,對于這個數(shù)組中元素的 Dep 類型我們筆者就會介紹到;options 中保存著副作用對象的一些配置項;而 allowRecurse 暫時不用關(guān)注,它是一個副作用函數(shù)能否自身調(diào)用的標(biāo)識。

副作用的全局變量

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

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 集合中,是出于減少內(nèi)存開銷的考慮。

effectStatck

這是一個存放當(dāng)前正被調(diào)用的副作用的棧,當(dāng)一個副作用在執(zhí)行前會被壓入棧中,而在結(jié)束之后會被推出棧。

activeEffect

這個變量標(biāo)記了當(dāng)前正在執(zhí)行的副作用,或者也可以理解為副作用棧中的棧頂元素。當(dāng)一個副作用被壓入棧時,會將這個副作用賦值給 activeEffect 變量,而當(dāng)副作用中的函數(shù)執(zhí)行完后該副作用會出棧,并將 activeEffect 賦值為棧的下一個元素。所以當(dāng)棧中只有一個元素時,執(zhí)行完出棧后,activeEffect 就會為 undefined。

副作用(effect)的實現(xiàn)

在學(xué)習(xí)完需要前置理解的類型與變量后,筆者就開始講解副作用函數(shù)的實現(xiàn),話不多說直接看代碼。

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

effect api 的函數(shù)相對簡單,當(dāng)傳入的 fn 已經(jīng)是一個副作用函數(shù)時,會將 fn 賦值為這個副作用的原始函數(shù)。接著會調(diào)用 createReactiveEffect 創(chuàng)建一個 ReactiveEffect 類型的函數(shù),如果副作用的選項中沒有設(shè)置延遲執(zhí)行,那么這個副作用函數(shù)會被立即執(zhí)行一次,最后將生成的副作用函數(shù)返回。

接著一起來看創(chuàng)建副作用函數(shù)的 createReactiveEffect 的邏輯。

createReactiveEffect

在 createReactiveEffect 中,首先會創(chuàng)建一個變量名為 effect 的函數(shù)表達式,之后為這個函數(shù)設(shè)置之前在 ReactiveEffect 類型中提及到的一些屬性,最后將這個函數(shù)返回。

而當(dāng)這個 effect 函數(shù)被執(zhí)行時,會首先判斷自己是不是已經(jīng)停用,如果是停用狀態(tài),則會查看選項中是否有調(diào)度函數(shù),如果有調(diào)度函數(shù)就不再處理,直接 return undefined,若是不存在調(diào)度函數(shù),則執(zhí)行并返回傳入的 fn 函數(shù),之后就不再運行下去。

如果 effect 函數(shù)狀態(tài)正常,會判斷當(dāng)前 effect 函數(shù)是否已經(jīng)在副作用棧中,若是已經(jīng)被加入棧中,則不再繼續(xù)處理,避免循環(huán)調(diào)用。

如果當(dāng)前 effect 函數(shù)不在棧中,就會通過 cleanup 函數(shù)清理副作用函數(shù)的依賴,并且打開依賴收集開關(guān),將副作用函數(shù)壓入副作用棧中,并記錄當(dāng)前副作用函數(shù)為 activeEffect。這段邏輯筆者在介紹這兩個變量時已經(jīng)講過,它就是在此處觸發(fā)的。

接下來就會執(zhí)行傳入的 fn 函數(shù)被返回結(jié)果。

當(dāng)函數(shù)執(zhí)行完畢后,會將副作用函數(shù)彈出棧中,并且將依賴收集開關(guān)重置為執(zhí)行副作用前的狀態(tài),再將 activeEffect 標(biāo)記為當(dāng)前棧頂?shù)脑亍4藭r一次副作用函數(shù)的執(zhí)行徹底結(jié)束,跟著筆者一起來看一下源碼的實現(xiàn)。

function createReactiveEffect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions
): ReactiveEffect<T> {
  // 通過一個函數(shù)表達式,創(chuàng)建一個變量名為 effect ,函數(shù)名為 reactiveEffect 的函數(shù)
  const effect = function reactiveEffect(): unknown {
    // 如果 effect 已停用,當(dāng)選項中有調(diào)度函數(shù)時返回 undefined,否則返回原始函數(shù)
    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
  // 為副作用函數(shù)設(shè)置屬性
  effect.id = uid++
  effect.allowRecurse = !!options.allowRecurse
  effect._isEffect = true
  effect.active = true
  effect.raw = fn
  effect.deps = []
  effect.options = options
  return effect
}

當(dāng)在最后一行 return 了副作用函數(shù)后,上一段提及提及當(dāng) options 參數(shù)中 lazy 為 false 時,這個副作用函數(shù)就會第一次被調(diào)用,此時就會觸發(fā)這段函數(shù) 第 6 行 const effect 創(chuàng)建函數(shù)后的函數(shù)內(nèi)部邏輯。

理解了createReactiveEffect 的執(zhí)行順序后,再配合詳細的邏輯講解,相信你也已經(jīng)掌握 effect 副作用函數(shù)的創(chuàng)建了。

收集依賴、派發(fā)更新

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

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

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

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

接著使用 effect,向它傳入一個函數(shù)。這時 effect 開始它的創(chuàng)建過程,在 effect 函數(shù)中會執(zhí)行到下方代碼的這一步。

const effect = createReactiveEffect(fn, options)

通過 createReactiveEffect 開始創(chuàng)建 effect 函數(shù),并返回。

當(dāng) effect 函數(shù)被返回后,就會判斷當(dāng)前副作用的選項中是否需要延遲執(zhí)行,而這里我們沒有傳入任何參數(shù),所以不是延遲加載,需要立即執(zhí)行,所以會開始執(zhí)行返回回來的 effect 函數(shù)。

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

于是會開始執(zhí)行 createReactiveEffect 創(chuàng)建 effect 函數(shù)時的內(nèi)部代碼邏輯。

const effect = function reactiveEffect(): unknown {/* 執(zhí)行此函數(shù)內(nèi)的邏輯 */}

由于 effect 函數(shù)是 active 狀態(tài),并且也不在副作用棧中,于是會先清除依賴,由于現(xiàn)在并沒有收集任何依賴,所以 cleanup 的過程不用關(guān)心。接著會將 effet 壓入棧中,并設(shè)置為 activeEffect,接下來會開始執(zhí)行初始傳入的 fn:() => (foo = counter.num)。

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

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

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

track 收集依賴

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

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

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

在有了 depsMap 后,就會根據(jù) target 中被讀取的 key,去依賴集合中查看是否有對應(yīng) key 的依賴,并賦值給 dep。如果沒有,就跟創(chuàng)建 depsMap 的邏輯一樣,創(chuàng)建一個 Set 類型的集合當(dāng)做值。

如果當(dāng)前執(zhí)行的副作用函數(shù)沒有被 dep 這個 Set 集合當(dāng)做依賴收集,就會將當(dāng)前副作用函數(shù)添加進 dep 中,并且在當(dāng)前的副作用函數(shù)的 deps 屬性中添加進該依賴 dep。

看到這里,就能夠想象出依賴的收集是一個什么樣的結(jié)構(gòu)了。以 key 為維度,將每一個 key 關(guān)聯(lián)的副作用函數(shù)收集起來,存放在一個 Set 數(shù)據(jù)結(jié)構(gòu)中,并以鍵值對的形式存儲在 depsMap 的 Map 結(jié)構(gòu)中。此時再看文章開頭描述 targetMap 這個Map 存儲的形式 { target -> key -> dep } 應(yīng)該說是非常明確了。

track 處理器函數(shù)的代碼如下:

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

看完 track 繼續(xù)看我們的示例:

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

當(dāng) track 收集完依賴后,get 陷阱返回了 Reflect.get 的結(jié)果,讀取到了 counter.num 的值為 0,并將此結(jié)果賦值給 foo 變量。此時副作用 函數(shù)第一次運行結(jié)束,foo 已經(jīng)有了值:0。當(dāng)副作用函數(shù)執(zhí)行完,會將當(dāng)前的副作用函數(shù)彈出棧中,并且將 activeEffect 賦值為 undefeind。

trigger 派發(fā)更新

搞懂了依賴收集之后,繼續(xù)來看派發(fā)更新的過程。

示例的最后一行代碼,將 num 賦值為 7。

counter.num = 7

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

當(dāng)對 counter.num 賦值時,會觸發(fā) set 陷阱:

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

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

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

由于 counter 通過 reactive api 創(chuàng)建代理對象時已經(jīng)添加了 num 這個 key,所以此時新舊值發(fā)生改變,就會觸發(fā) SET 事件。

接著會執(zhí)行 trigger 函數(shù)。

trigger 函數(shù)會立即從 targetMap 中通過 target 獲取 depsMap,如果沒有對應(yīng)的 depsMap 就代表當(dāng)前的 traget 從未通過 track 進行依賴收集,所以直接 return,不繼續(xù)執(zhí)行。

接著會創(chuàng)建一個名為 effects 的 Set 結(jié)構(gòu)的集合,它的作用是存儲這個 key 所有需要派發(fā)更新執(zhí)行的副作用函數(shù)。

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

然后會判斷清空依賴和數(shù)組的特殊情況,按需調(diào)用 add 函數(shù)添加依賴。

之后會判斷當(dāng)前 key 是否不為 undefined,注意這里的判斷條件 void 0,是通過 void 運算符的形式表示 undefined,如果有 key 則將 key 相關(guān)的依賴通過 add 函數(shù)添加進 effects 集合中。

隨后的 Switch Case 通過區(qū)分 triggerOpTypes 來處理一些迭代鍵的特殊邏輯。

之后聲明的 run 函數(shù)作用就是來執(zhí)行添加入 effects 數(shù)組中的副作用函數(shù)。

trigger 函數(shù)的結(jié)尾就是通過 effects.forEach(run) 遍歷集合內(nèi)的所有副作用函數(shù)并執(zhí)行。

先一起來看一下 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 從未被追蹤,不繼續(xù)執(zhí)行
    return
  }
    
  // effects 集合存放所有需要派發(fā)更新的副作用函數(shù)。
  const effects = new Set<ReactiveEffect>()
  // 將不是當(dāng)前副作用函數(shù)以及能執(zhí)行自身的副作用函數(shù)加入集合中
  const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
    if (effectsToAdd) {
      effectsToAdd.forEach(effect => {
        if (effect !== activeEffect || effect.allowRecurse) {
          effects.add(effect)
        }
      })
    }
  }

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

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

在將 SwitchCase 的特殊邏輯,以及 DEV 環(huán)境的特殊邏輯隱藏后,trigger 函數(shù)的長度已經(jīng)比較精簡且邏輯清晰了。

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

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

總結(jié)

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

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

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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容