Vue3 源碼解析(六):響應式原理與 reactive

今天這篇文章是筆者會帶着大家一起深入剖析 Vue3 的響應式原理實現,以及在響應式基礎 API 中的 reactive 是如何實現的。對於 Vue 框架來說,其非侵入的響應式系統是最獨特的特性之一了,所以不論任何一個版本的 Vue,在熟悉其基礎用法後,響應式原理都是筆者最想優先了解的部分,也是閱讀源碼時必細細研究的部分。畢竟知己知彼百戰不殆,當你使用 Vue 時,掌握了響應式原理一定會讓你的 coding 過程更加遊刃有餘的。

Vue2 的響應式原理

在開始介紹 Vue3 的響應式原理前,我們先一起回顧一下 Vue2 的響應式原理。

當我們把一個普通選項傳入 Vue 實例的 data 選項中,Vue 將遍歷此對象所有的 property,並使用 Object.defineProperty 把這些 property 全部轉爲 getter/setter。而 Vue2 在處理數組時,也會通過原型鏈劫持會改變數組內元素的方法,並在原型鏈觀察新增的元素,以及派發更新通知。

這裏放上一張 Vue2 文檔中介紹響應式的圖片。對於文檔中有的描述筆者就不再贅述,而從 Vue2 的源碼角度來對照圖片說一說。在 Vue2 的源碼中的 src/core 路徑下有一個 observer 模塊,它就是 Vue2 中處理響應式的地方了。在這個模塊下 observer 負責將對象、數組轉換成響應式的,即圖中的紫色部分,處理 Data 的 getter 及 setter。當 data 中的選項被訪問時,會觸發 getter,此時 observer 目錄下的 wather.js 模塊就會開始工作,它的任務就是收集依賴,我們收集到的依賴是一個個 Dep 類的實例化對象。而 data 中的選項變更時,會觸發 setter 的調用,而在 setter 的過程中,觸發 dep 的 notify 函數,派發更新事件,由此實現數據的響應監聽。

Vue3 的響應式變化

在簡單回顧了 Vue2 的響應式原理後,我們會有一個疑惑,Vue3 的響應式原理與 Vue2 相比有什麼不同呢?

在 Vue3 中響應式系統最大的區別就是,數據模型是被代理的 JavaScript 對象了。不論是我們在組件的 data 選項中返回一個普通的JavaScript 對象,還是使用 composition api 創建一個 reactive 對象,Vue3 都會將該對象包裹在一個帶有 get 和 set 處理程序的 Proxy 中。

Proxy 對象用於創建一個對象的代理,從而實現基本操作的攔截和自定義(如屬性查找、賦值等)。

其基礎語法類似於:

const p = new Proxy(target, handler)

Proxy 相比較於 Object.defineProperty 究竟有什麼優勢呢?這個問題讓我們先從 Object.defineProperty 的弊端說起。

從 Object 的角度來說,由於 Object.defineProperty 是對指定的 key 生成 getter/setter 以進行變化追蹤,那麼如果這個 key 一開始不存在我們定義的對象上,響應式系統就無能爲力了,所以在 Vue2 中無法檢測對象的 property 的添加或移除。而對於這個缺陷,Vue2 提供了 vm.$set 和全局的 Vue.set API 讓我們能夠向對象添加響應式的 property。

從數組的角度來說,當我們直接利用索引設置一個數組項時,或者當我們修改數組長度時,Vue2 的響應式系統都不能監聽到變化,解決的方法也如上,使用上面提及的 2 個 api。

而這些問題在 ES6 的新特性 Proxy 面前通通都是不存在的,Proxy 對象能夠利用 handler 陷阱在 get、set 時捕獲到任何變動,也能監聽對數組索引的改動以及 數組 length 的改動。

而依賴收集和派發更新的方式在 Vue3 中也變得不同,在這裏我先快速的整體描述一下:在 Vue3 中,通過 track 的處理器函數來收集依賴,通過 trigger 的處理器函數來派發更新,每個依賴的使用都會被包裹到一個副作用(effect)函數中,而派發更新後就會執行副作用函數,這樣依賴處的值就被更新了。

響應式基礎 reactive 的實現

既然這是一個源碼分析的文章,咱們還是從源碼的角度來分析響應式究竟是如何實現的。所以筆者會先分析響應式基礎的 API —— reactive ,相信通過講解 reactive 的實現,大家會對 Proxy 有更深刻的認識。

reactive

二話不說,直接看源碼。下面是 reactive API 的函數,函數的參數接受一個對象,通過 createReactiveObject 函數處理後,直接返回一個 proxy 對象。

export function reactive<T extends object>(target: T): UnwrapNestedRefs<T>
export function reactive(target: object) {
  // 如果試圖去觀察一個只讀的代理對象,會直接返回只讀版本
  if (target && (target as Target)[ReactiveFlags.IS_READONLY]) {
    return target
  }
  // 創建一個代理對象並返回
  return createReactiveObject(
    target,
    false,
    mutableHandlers,
    mutableCollectionHandlers,
    reactiveMap
  )
}

在第三行能看到通過判斷 target 中是否有 ReactiveFlags 中的 IS_READONLY key 確定對象是否爲只讀對象。ReactiveFlags 枚舉會在源碼中不斷的與我們見面,所以有必要提前介紹一下 ReactiveFlags:

export const enum ReactiveFlags {
  SKIP = '__v_skip', // 是否跳過響應式 返回原始對象
  IS_REACTIVE = '__v_isReactive', // 標記一個響應式對象
  IS_READONLY = '__v_isReadonly', // 標記一個只讀對象
  RAW = '__v_raw' // 標記獲取原始值
}

在 ReactiveFlags 枚舉中有 4 個枚舉值,這四個枚舉值的含義都在註釋裏。對於 ReactiveFlags 的使用是代理對象對 handler 中的 trap 陷阱非常好的應用,對象中並不存在這些 key,而通過 get 訪問這些 key 時,返回值都是通過 get 陷阱的函數內處理的。介紹完 ReactiveFlags 後我們繼續往下看。

createReactiveObject

function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>
)

先看 createReactiveObject 函數的簽名,該函數接受 5 個參數:

  • target:目標對象,想要生成響應式的原始對象。
  • isReadonly:生成的代理對象是否只讀。
  • baseHandlers:生成代理對象的 handler 參數。當 target 類型是 Array 或 Object 時使用該 handler。
  • collectionHandlers:當 target 類型是 Map、Set、WeakMap、WeakSet 時使用該 handler。
  • proxyMap:存儲生成代理對象後的 Map 對象。

這裏需要注意的是 baseHandlers 和 collectionHandlers 的區別,這兩個參數會根據 target 的類型進行判斷,最終選擇將哪個參數傳入 Proxy 的構造函數,當做 handler 參數使用。

接着我們開始看 createReactiveObject 的邏輯部分:

function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>
) {
  // 如果目標不是對象,直接返回原始值
  if (!isObject(target)) {
    return target
  }
  // 如果目標已經是一個代理,直接返回
  // 除非對一個響應式對象執行 readonly
  if (
    target[ReactiveFlags.RAW] &&
    !(isReadonly && target[ReactiveFlags.IS_REACTIVE])
  ) {
    return target
  }
  // 目標已經存在對應的代理對象
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }
  // 只有白名單裏的類型才能被創建響應式對象
  const targetType = getTargetType(target)
  if (targetType === TargetType.INVALID) {
    return target
  }
  const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
  )
  proxyMap.set(target, proxy)
  return proxy
}

在該函數的邏輯部分,可以看到基礎數據類型並不會被轉換成代理對象,而是直接返回原始值。

並且會將已經生成的代理對象緩存進傳入的 proxyMap,當這個代理對象已存在時不會重複生成,會直接返回已有對象。

也會通過 TargetType 來判斷 target 目標對象的類型,Vue3 僅會對 Array、Object、Map、Set、WeakMap、WeakSet 生成代理,其他對象會被標記爲 INVALID,並返回原始值。

當目標對象通過類型校驗後,會通過 new Proxy() 生成一個代理對象 proxy,handler 參數的傳入也是與 targetType 相關,並最終返回已生成的 proxy 對象。

所以回顧 reactive api,我們可能會得到一個代理對象,也可能只是獲得傳入的 target 目標對象的原始值。

Handlers 的組成

在 @vue/reactive 庫中有 baseHandlers 和 collectionHandlers 兩個模塊,分別生成 Proxy 代理的 handlers 中的 trap 陷阱。

例如在上面生成 reactive 的 api 中 baseHandlers 的參數傳入了一個 mutableHandlers 對象,這個對象是這樣的:

export const mutableHandlers: ProxyHandler<object> = {
  get,
  set,
  deleteProperty,
  has,
  ownKeys
}

通過變量名我們能知道 mutableHandlers 中存在 5 個 trap 陷阱。而在 baseHandlers 中,get 和 set 都是通過工廠函數生成的,以便於適配除 reactive 外的其他 api,例如 readonly、shallowReactive、shallowReadonly 等。

baseHandlers 是處理 Array、Object 的數據類型的,這也是我們絕大部分時間使用 Vue3 時使用的類型,所以筆者接下來着重的講一下baseHandlers 中的 get 和 set 陷阱。

get 陷阱

上一段提到 get 是由一個工廠函數生成的,先來看一下 get 陷阱的種類。

const get = /*#__PURE__*/ createGetter()
const shallowGet = /*#__PURE__*/ createGetter(false, true)
const readonlyGet = /*#__PURE__*/ createGetter(true)
const shallowReadonlyGet = /*#__PURE__*/ createGetter(true, true)

get 陷阱有 4 個類型,分別對應不同的響應式 API,從名稱中就可以知道對應的 API 名稱,非常一目瞭然。而所有的 get 都是由 createGetter 函數生成的。所以接下來我們着重看一下 createGetter 的邏輯。

還是老規矩,先從函數簽名看起。

function createGetter(isReadonly = false, shallow = false) {
  return function get(target: Target, key: string | symbol, receiver: object) {}
}

createGetter 有 isReadonly 和 shallow 兩個參數,讓使用 get 陷阱的 api 按需使用。而函數的內部返回了一個 get 函數,使用高階函數的方式返回將會傳入 handlers 中 get 參數的函數。

接着看 createGetter 的邏輯:

// 如果 get 訪問的 key 是 '__v_isReactive',返回 createGetter 的 isReadonly 參數取反結果
if (key === ReactiveFlags.IS_REACTIVE) {
  return !isReadonly
} else if (key === ReactiveFlags.IS_READONLY) {
  // 如果 get 訪問的 key 是 '__v_isReadonly',返回 createGetter 的 isReadonly 參數
  return isReadonly
} else if (
  // 如果 get 訪問的 key 是 '__v_raw',並且 receiver 與原始標識相等,則返回原始值
  key === ReactiveFlags.RAW &&
  receiver ===
    (isReadonly
      ? shallow
        ? shallowReadonlyMap
        : readonlyMap
      : shallow
        ? shallowReactiveMap
        : reactiveMap
    ).get(target)
) {
  return target
}

從這段 createGetter 邏輯中,筆者專門介紹過的 ReactiveFlags 枚舉在這就取得了妙用。其實目標對象中並沒有這些 key,但是在 get 中Vue3 就對這些 key 做了特殊處理,當我們在對象上訪問這幾個特殊的枚舉值時,就會返回特定意義的結果。而可以關注一下 ReactiveFlags.IS_REACTIVE 這個 key 的判斷方式,爲什麼是隻讀標識的取反呢?因爲當一個對象的訪問能觸發這個 get 陷阱時,說明這個對象必然已經是一個 Proxy 對象了,所以只要不是隻讀的,那麼就可以認爲是響應式對象了。

接着看 get 的後續邏輯。

繼續判斷 target 是否是一個數組,如果代理對象不是隻讀的,並且 target 是一個數組,並且訪問的 key 在數組需要特殊處理的方法裏,就會直接調用特殊處理的數組函數執行結果,並返回。

arrayInstrumentations 是一個對象,對象內保存了若干個被特殊處理的數組方法,並以鍵值對的形式存儲。

我們之前說過 Vue2 以原型鏈的方式劫持了數組,而在這裏也有類似地作用,而數組的部分我們準備放在後續的文章中再介紹,下面是需要特殊處理的數組。

  • 對索引敏感的數組方法
    • includes、indexOf、lastIndexOf
  • 會改變自身長度的數組方法,需要避免 length 被依賴收集,因爲這樣可能會造成循環引用
    • push、pop、shift、unshift、splice
// 判斷 taeget 是否是數組
const targetIsArray = isArray(target)
// 如果不是隻讀對象,並且目標對象是個數組,訪問的 key 又在數組需要劫持的方法裏,直接調用修改後的數組方法執行
if (!isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {
  return Reflect.get(arrayInstrumentations, key, receiver)
}

// 獲取 Reflect 執行的 get 默認結果
const res = Reflect.get(target, key, receiver)

// 如果是 key 是 Symbol,並且 key 是 Symbol 對象中的 Symbol 類型的 key
// 或者 key 是不需要追蹤的 key: __proto__,__v_isRef,__isVue
// 直接返回 get 結果
if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
  return res
}

// 不是隻讀對象,執行 track 收集依賴
if (!isReadonly) {
  track(target, TrackOpTypes.GET, key)
}

// 如果是 shallow 淺層響應式,直接返回 get 結果
if (shallow) {
  return res
}

if (isRef(res)) {
  // 如果是 ref ,則返回解包後的值 - 當 target 是數組,key 是 int 類型時,不需要解包
  const shouldUnwrap = !targetIsArray || !isIntegerKey(key)
  return shouldUnwrap ? res.value : res
}

if (isObject(res)) {
  // 將返回的值也轉換成代理,我們在這裏做 isObject 的檢查以避免無效值警告。
  // 也需要在這裏惰性訪問只讀和星影視對象,以避免循環依賴。
  return isReadonly ? readonly(res) : reactive(res)
}

// 不是 object 類型則直接返回 get 結果
return res

在處理完數組後,我們對 target 執行 Reflect.get 方法,獲得默認行爲的 get 返回值。

之後判斷 當前 key 是否是 Symbol,或者是否是不需要追蹤的 key,如果是的話直接返回 get 的結果 res。

下面👇幾個 key 是不需要被依賴收集或者返回響應式結果的。

  • __proto__
  • _v_isRef
  • __isVue

接着判斷當前代理對象是否是隻讀對象,如果不是隻讀的話,則運行筆者上文提及的 tarck 處理器函數收集依賴。

如果是 shallow 的淺層響應式,則不需要將內部的屬性轉換成代理,直接返回 res。

如果 res 是一個 Ref 類型的對象,就會自動解包返回,這裏就能解釋官方文檔中提及的 ref 在 reactive 中會自動解包的特性了。而需要注意的是,當 target 是一個數組類型,並且 key 是 int 類型時,即使用索引訪問數組元素時,不會被自動解包。

如果 res 是一個對象,就會將該對象轉成響應式的 Proxy 代理對象返回,再結合我們之前分析的緩存已生成的 proxy 對象,可以知道這裏的邏輯並不會重複生成相同的 res,也可以理解文檔中提及的當我們訪問 reactive 對象中的 key 是一個對象時,它也會自動的轉換成響應式對象,而且由於在此處生成 reactive 或者 readonly 對象是一個延遲行爲,不需要在第一時間就遍歷 reactive 傳入的對象中的所有 key,也對性能的提升是一個幫助。

當 res 都不滿足上述條件時,直接返回 res 結果。例如基礎數據類型就會直接返回結果,而不做特殊處理。

至此,get 陷阱的邏輯全部結束了。

set 陷阱

與 createGetter 對應,set 也有一個 createSetter 的工廠函數,也是通過柯里化的方式返回一個 set 函數。

函數簽名都大同小異,那麼接下來筆者直接帶大家盤邏輯。

set 的函數比較簡短,所以這次一次性把寫好註釋的代碼放上來,先看代碼再講邏輯。

function createSetter(shallow = false) {
  return function set(
    target: object,
    key: string | symbol,
    value: unknown,
    receiver: object
  ): boolean {
    let oldValue = (target as any)[key]
    if (!shallow) {
      value = toRaw(value)
      oldValue = toRaw(oldValue)
      // 當不是 shallow 模式時,判斷舊值是否是 Ref,如果是則直接更新舊值的 value
      // 因爲 ref 有自己的 setter
      if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
        oldValue.value = value
        return true
      }
    } else {
      // shallow 模式不需要特殊處理,對象按原樣 set
    }
        
    // 判斷 target 中是否存在 key
    const hadKey =
      isArray(target) && isIntegerKey(key)
        ? Number(key) < target.length
        : hasOwn(target, key)
    // Reflect.set 獲取默認行爲的返回值
    const result = Reflect.set(target, key, value, receiver)
    // 如果目標是原始對象原型鏈上的屬性,則不會觸發 trigger 派發更新
    if (target === toRaw(receiver)) {
      // 使用 trigger 派發更新,根據 hadKey 區別調用事件
      if (!hadKey) {
        trigger(target, TriggerOpTypes.ADD, key, value)
      } else if (hasChanged(value, oldValue)) {
        trigger(target, TriggerOpTypes.SET, key, value, oldValue)
      }
    }
    return result
  }
}

在 set 的過程中會首先獲取新舊與舊值,當目前的代理對象不是淺層比較時,會判斷舊值是否是一個 Ref,如果舊值不是數組且是一個 ref類型的對象,並且新值不是 ref 對象時,會直接修改舊值的 value。

看到這裏可能會有疑問,爲什麼要更新舊值的 value?如果你使用過 ref 這個 api 就會知道,每個 ref 對象的值都是放在 value 裏的,而 ref 與 reactive 的實現是有區別的,ref 其實是一個 class 實例,它的 value 有自己的 set ,所以就不會在這裏繼續進行 set 了。ref 的部分在後續的文章中會詳細講解。

在處理完 ref 類型的值後,會聲明一個變量 hadKey,判斷當前要 set 的 key 是否是對象中已有的屬性。

接下來調用 Reflect.set 獲取默認行爲的 set 返回值 result。

然後會開始派發更新的過程,在派發更新前,需要保證 target 和原始的 receiver 相等,target 不能是一個原型鏈上的屬性。

之後開始使用 trigger 處理器函數派發更新,如果 hadKey 不存在,則是一個新增屬性,通過 TriggerOpTypes.ADD 枚舉來標記。這裏可以看到開篇分析 Proxy 強於 Object.defineProperty 的地方,會監測到任何一個新增的 key,讓響應式系統更強大。

如果 key 是當前 target 上已經存在的屬性,則比較一下新舊值,如果新舊值不一樣,則代表屬性被更新,通過 TriggerOpTypes.SET 來標記派發更新。

在更新派發完後,返回 set 的結果 result,至此 set 結束。

總結

在今天的文章中,筆者先帶大家回顧了 Vue2 的響應式原理,又開始介紹 Vue3 的響應式原理,通過比較 Vue2 和 Vue3 的響應式系統的區別引出 Vue3 響應式系統的提升之處,尤其是其中最主要的調整將 Object.defineProperty 替換爲 Proxy 代理對象。

爲了讓大家屬性 Proxy 對響應式系統的影響,筆者着重介紹了響應式基礎 API:reactive。分析了 reactive 的實現,以及 reactive api 返回的 proxy 代理對象使用的 handlers 陷阱。並且對陷阱中我們最常用的 get 和 set 的源碼進行分析,相信大家在看完本篇文章以後,對 proxy 這個 ES2015 的新特性的使用又有了新的理解。

本文只是介紹 Vue3 響應式系統的第一篇文章,所以 track 收集依賴,trigger 派發更新的過程沒有詳細展開,在後續的文章中計劃詳細講解副作用函數 effect,以及 track 和 trigger 的過程,如果希望能詳細瞭解響應式系統的源碼,麻煩大家點個關注免得迷路。

最後,如果這篇文章能夠幫助到你瞭解 Vue3 中的響應式原理和 reactive 的實現,希望能給本文點一個喜歡❤️。如果想繼續追蹤後續文章,也可以關注我的賬號或 follow 我的 github,再次謝謝各位可愛的看官老爺。

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