petite-vue源碼剖析-逐行解讀@vue/reactivity之reactive

在petite-vue中我們通過reactive構建上下文對象,並將根據狀態渲染UI的邏輯作爲入參傳遞給effect,然後神奇的事情發生了,當狀態發生變化時將自動觸發UI重新渲染。那麼到底這是怎麼做到的呢?
@vue/reactivity功能十分豐富,而petite-vue僅使用到reactiveeffect兩個最基本的API,作爲入門本文將僅僅對這兩個API進行源碼解讀。

一切源於Proxy

我們知道Vue2是基於Object.defineProperty攔截對象屬性的讀寫操作,從而實現依賴收集和響應式UI渲染。而@vue/reactivity作爲Vue3的子項目,採用的是ES6的Proxy接口實現這一功能。

const state = {
  count: 1
}

const proxyState = new Proxy(state, {
  get(target: T, property: string, receiver?: T | Proxy): any {
    // 攔截讀操作
    console.log('get')
    return Reflect.get(target, property, receiver)
  },
  set(target: T, property: string, value: any, receiver?: T | Proxy): boolean {
    // 攔截寫操作
    console.log('set')
    return Reflect.set(target, property, value, receiver)
  },
  deleteProperty(target, prop) {
    // 攔截屬性刪除操作
    console.log('delete')
    delete target[prop]
    return true
  }
})

相對Object.defineProperty,Proxy的特點:

  1. 通過new Proxy構建的對象進行操作才能攔截對象屬性的讀寫操作,而被代理的對象則沒有任何變化;
  2. 可以監聽數組元素的變化和增減;
  3. 可以監聽對象屬性的增減;
  4. Proxy可以逐層代理對象屬性,而Object.defineProperty則需要一次性代理對象所有層級的屬性。

響應式編程

// 定義響應式對象
const state = reactive({
  num1: 1,
  num2: 2
})

// 在副作用函數中訪問響應式對象屬性,當這些屬性發生變化時副作用函數將被自動調用
effect(() => {
  console.log('outer', state.num1)
  effect(() => {
    console.log('inner', state.num2)
  })
})
// 回顯 outer 1
// 回顯 inner 2

state.num2 += 1
// 回顯 inner 3

state.num1 += 1
// 回顯 outer 2
// 回顯 inner 3

state.num2 += 1
// 回顯 inner 4
// 回顯 inner 4

本篇我們將從reactive入手,解讀Vue3到底如何構造一個響應式對象。

深入reactive的工作原理

@vue/reactivity的源碼位於vue-next項目的packages/reactivity下,而reactive函數則位於其下的src/reactive.ts文件中。該文件中除了包含reactive函數外,還包含如shallowReactivereadonlyshallowReadonly和其它幫助函數。
reactive核心工作則是通過Proxy將一個普通的JavaScript對象轉換爲監控對象,攔截對象屬性的讀寫刪操作,並收集依賴該對象(屬性)的副作用函數。大致流程如下:

  1. 通過reactive構造的響應式對象都會將被代理對象和響應式對象的映射關係保存在reactiveMap,防止重複生成響應式對象,優化性能;
  2. 當調用reactive後會對被代理對象進行檢查,若不是隻讀對象、響應式對象、primitive value和reactiveMap中不存在則根據被代理對象的類型構造響應式對象
  3. 攔截讀操作(get,hasownKeys)時調用effect.ts中的track收集依賴
  4. 攔截寫操作(set, deleteProperty)時調用effect.ts中的trigger觸發副作用函數執行

下面我們一起逐行理解源碼吧!

源碼解讀——reactive入口

// Vue3內部定義的對象特性標識
export const enum ReactiveFlags {
  SKIP = '__v_skip', // 標識該對象不被代理
  IS_REACTIVE = '__v_isReactive', // 標識該對象是響應式對象
  IS_READONLY = '__v_isReadonly', // 標識該對象爲只讀對象
  RAW = '__v_raw' // 指向被代理的JavaScript對象
}

// 響應式對象的接口
export interface Target {
  [ReactiveFlags.SKIP]?: boolean
  [ReactiveFlags.IS_REACTIVE]?: boolean
  [ReactiveFlags.IS_READONLY]?: boolean
  [ReactiveFlags.RAW]?: any // 用於指向被代理的JavaScript對象
}

// 用於緩存被代理對象和代理對象的關係,防止重複代理
export const reactiveMap = new WeakMap<Target, any>()

// 將被代理對象的處理方式分爲不代理(INVALID)、普通對象和數組(COMMON)和Map、Set(COLLECTION)
const enum TargetType {
  INVALID = 0,
  COMMON = 1,
  COLLECTION = 2,
}

function targetTypeMap(rawType: string) {
  switch(rawType) {
    case 'Object':
    case 'Array':
      return TargetType.COMMON
    case 'Map':
    case 'Set':
    case 'WeakMap':
    case 'WeakSet':
      return TargetType.COLLECTION
    default:
      return TargetType.INVALID
  }
}

function getTargetType(value: Target) {
  // 若對象標記爲跳過,或不可擴展則不代理該對象
  return value[ReactiveFlags.SKIP] || !Object.isExtensible(value)
    ? TargetType.INVALID
    // 根據類型決定處理方式
    : targetTypeMap(toRawType(value))
}

export function reative(target: object) {
  // 不攔截只讀對象的讀寫刪操作
  if (target && (target as Target)[ReactiveFlags.IS_READONLY]) {
    return target
  }
  return createReactiveObject(
    target,
    false,
    mutableHandlers,
    mutableCollectionHandlers,
    reactiveMap
  )
}

function createReactiveObject (
  target: Target,
  isReadonly: boolean,
  beaseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>
) {
  // reactive函數入參必須是JavaScript對象或數組,若是primitive value則會直接返回primitive value
  if (!isObject(target)) {
    return target
  }
  /**
   * 1. 僅能對非響應式和非只讀對象構造響應式對象
   * 2. 可以對非只讀對象構造響應式對象
   */
  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
}

可以看到reactive方法中會對被代理對象進行各種檢查,從而減少不必要的操作提高性能。最後若被代理對象的類型爲ObjectArray則採用baseHandlers生成代理,否則使用collectionHandlers生成代理。

源碼解讀-代理ObjectArraybaseHandlers

//文件 ./baseHandlers.ts

// /*#__PURE__*/用於告訴webpack等bundler工具後面緊跟的函數是純函數,若沒被調用過則可以採用tree-shaking移除掉該函數
const get = /*#__PURE__*/ createGetter()

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

我們首先看看是如何攔截讀操作吧

攔截讀操作

攔截讀操作核心是收集依賴所讀屬性的輔作用函數的信息,具體流程邏輯是

  1. 對於Vue3內部屬性的讀操作,即返回對應的值而不用收集依賴
  2. 對於數組內置方法的讀操作,需要改寫這些內置方法用於在調用該方法前對數組元素進行依賴收集,或解決一些邊界問題
  3. 對於內置Symbol屬性和其它Vue3內部屬性的讀操作,直接返回原始值且不用收集依賴
  4. 對於非只讀對象的除上述外的其餘屬性的讀操作,執行依賴收集(核心邏輯)
  5. 若淺層響應式對象則直接返回屬性值,否則若屬性值爲對象,則將其構造爲響應式對象(reactive)或只讀對象(readonly)
//文件 ./baseHandlers.ts

/**
 * isNonTrackableKeys = {'__proto__': true, '__v_isRef': true, '__isVue': true}
 */
const isNonTrackableKeys = /*#__PURE__*/ makeMap(`__proto__,__v_isRef,__isVue`)

// 內置的Symbol實例包含:hasInstance, isConcatSpreadable, iterator, asyncIterator, match, matchAll, replace, search, split, toPrimitive, toStringTag, species, unscopables
const builtInSymbols = new Set(
  Object.getOwnPropertyNames(Symbol)
    .map(key => (Symbol as any)[key])
    .filter(isSymbol)
)

function createGetter(isReadonly = false, shallow = false) {
  return function get(target: Target, key: string | symbol, receiver: object) {
    // 處理Vue3內部屬性名(`__v_isReactive`, `__v_isReadonly`, `__v_raw`)的值
    if (key === ReactiveFlags.IS_REACTIVE) {
      return !isReadonly
    }
    else if (key === ReactiveFlags.IS_READONLY) {
      return isReadonly
    }
    // TODO
    else if (
      key === ReactiveFlags.RAW &&
      receiver === reactiveMap
    ) {
      return target
    }

    // 如果key是includes,indexOf,lastIndexOf,push,pop,shift,unshift,splice時則返回能跟蹤依賴變化的版本
    const targetIsArray = isArray(target)
    if (!isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {
      return Reflect.get(arrayInstrumentations, key, receiver)
    }

    const res = Reflect.get(target, key, receiver)

    // 不攔截內置Symbol屬性和__proto__,__v_isRef和__isVue屬性
    if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
      return res
    }

    // 收集依賴該屬性的副作用函數
    if (!isReadonly) {
      track(target, TrackOpTypes.GET, key)
    }

    // 如果是構建ShallowReactive則不會基於屬性值構造響應式式對象
    if (shallow) {
      return res
    }

    /* 對於屬性值爲@vue/reactivity的Ref實例時,如果不是執行[1,2,3][0]的操作則返回Ref實例包含的primitive value,否則返回Ref實例
     * 因此我們在effect updator中可以通過如下方式直接獲取Ref實例屬性的primitive value
     * const age = ref(0), state = reactive({ age })
     * console.log(age.value) // 回顯0
     * effect(() => { console.log(state.age) }) // 回顯0
     */
    if (isRef(res)) {
      const shouldUnwrap = !targetIsArray || !isIntegerKey(key)
      return shouldUnwrap ? res.value : res
    }

    // 若屬性值不是primitive value或BOM,則基於屬性值構造響應式對象
    if (isObject(res)) {
      return isReadonly ? readonly(res) :  reactive(res)
    }
  }
}

這裏可以看到當讀取屬性時才根據屬性值類型來爲屬性值構造響應式對象,而不是當我們調用reactive時就一股腦的遍歷對象所有屬性,併爲各個屬性構建響應式對象。

另外,針對includes等數組操作會返回對應的能跟蹤依賴變化的版本,到底什麼是能跟蹤依賴變化的版本呢?

// 文件 ./baseHandlers.ts

const arrayInstrumentations = /*#__PURE__*/ createArrayInstrumentations()

function createArrayInstrumentations() {
  const instrumentations: Record<string, Function> = {}
  ;(['includes', 'indexOf', 'lastIndexOf'] as const).forEach(key => {
    instrumentations[key] = function(this: unknown[], ...args: unknown[]) {
      const arr = toRaw(this) as any
      /* 提前遍歷數組所有元素,跟蹤每個元素的變化。若其中一個元素髮生變化都會觸發調用includes,indexOf或lastIndexOf副作用函數的執行。
       * 假如執行`[2,1,2].includes(1)`,那麼當匹配到第二個元素1時就會返回匹配結果,後續的元素不會被讀取到,因此也就不會被跟蹤收集到,那麼當我們執行`[2,1,2][2] = 1`時就不會觸發副作用執行。
       */
      // 
      for (let i = 0, l = this.length; i < l; i++) {
        track(arr, TrackOpTypes.GET, i + '')
      }

      // 調用數組原生的includes,indexOf和lastIndexOf方法
      const res = arr[key](...args)
      if (res === -1 // indexOf和lastIndexOf
          || res === false // includes
      ) {
        // 由於數組元素有可能爲響應式對象而入參也有可能是響應式對象,因此當匹配失敗,則將嘗試獲取數組元素的被代理對象重新匹配
        return arr[key](...args.map(toRaw))
      } else {
        return res
      }
    }
  })
  // 下面的操作會修改數組的長度,這裏避免觸發依賴長度的副作用函數執行
  ;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => {
    instrumentations[key] = function(this: unknown[], ...args: unknown[]) {
      pauseTracking()
      const res = (toRaw(this) as any)[key].apply(this, args)
      resetTracking()
      return res
    }
  })

  return instrumentations
}

// 文件 ./reactive.ts
export function toRaw<T>(observed: T): T {
  const raw = observed && (observed as Target)[ReactiveFlags.RAW]
  return raw ? toRaw(raw) : observed
}

TypeScript小課堂1:['includes', 'indexOf', 'lastIndexOf'] as const在TypeScript中用於標識對象或數組爲不可修改對象。即

let a = ['includes', 'indexOf', 'lastIndexOf'] as const
a[0] = 'hi' // 編譯時報錯

const b = ['includes', 'indexOf', 'lastIndexOf']
b[0] = 'hi' // 修改成功
console.log(b[0]) // 回顯 hi

TypeScript小課堂2:instrumentations[key] = function(this: unknown[], ...args: unknown[]) {...}採用的是TypeScript的this參數,用於限制調用函數時的this類型。
轉換爲JavaScript就是

instrumentations[key] = function(...args){
  pauseTracking()
  const res = (toRaw(this) as any)[key].apply(this, args)
  resetTracking()
  return res
}

攔截寫操作

既然攔截讀操作是爲了收集依賴,那麼攔截寫操作自然就是用於觸發副作用函數了。流程邏輯如下:

  1. 若屬性值爲Ref對象,而新值取原始值後不是Ref對象,則更新Ref對象的value,由Ref內部觸發副作用函數
  2. 判斷是否爲新增屬性,還是更新屬性值,並觸發副作用函數
const set = /*#__PURE__*/ createSetter()

function createSetter(shallow = false) {
  return function set(
    target: Object,
    key: string | symbol,
    value: unknown,
    receiver: object
  ): boolean {
    // Proxy的set攔截器返回true表示賦值成功,false表示賦值失敗

    let oldValue = (target as any)[key]
    if (!shallow) {
      /* 若舊屬性值爲Ref,而新值不是Ref,則直接將新值賦值給舊屬性的value屬性
       * 一眼看上去貌似沒有觸發依賴該屬性的副作用函數執行任務壓入調度器,但Ref對象也是響應式對象,賦值給它的value屬性,會觸發依賴該Ref對象的輔佐用函數壓入調度器
       */  
      value = toRaw(value)
      oldValue = toRaw(oldValue)
      if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
        oldValue.value = value
        return true
      }
    }

    // 用於判斷是新增屬性還是修改屬性
    const hadKey = 
      isArray(target) && isIntegerKey(key)
        ? Number(key) < target.length // 數組索引的處理
        : hasOwn(target, key) // 對象或數組非索引的而處理
    // 賦值後再將副作用函數執行任務壓入調度器
    const result = Reflect.set(target, key, value, receiver)
    if (target === toRaw(receiver)) {
      if (!hadKey) {
        // 觸發依賴該屬性的副作用函數執行任務壓入調度器
        trigger(target, TriggerOpTypes.ADD, key, value)
      }
      else if (hasChange(value, oldValue)) {
        // 觸發依賴該屬性的副作用函數執行任務壓入調度器
        trigger(target, TriggerOpTypes.SET, key, value, oldValue)
      }
    }
    return result
  }
}

// 文件 @vue/shared
export const hasChanged = (value: any, oldValue: any): boolean => !Object.is(value, oldValue)

爲什麼不用===而要使用Object.is來比較兩個值是否相等呢?
對於-0===0返回true,NaN === NaN返回false,而Object.is(-0, 0)返回false,Object.is(NaN, NaN)返回true
更多信息請查看《Source Code Reading for Vue 3: How does hasChanged work? 》

攔截刪除操作

刪除操作會修改屬性自然也會觸發依賴該屬性的副作用函數啦

function deleteProperty(target: object, key: string | symbol): boolean {
  const hadKey = hasOwn(target, key)
  const oldValue = (target as any)[key]
  const result = Reflect.deleteProperty(target, key)
  if (result && hadKey) {
    // 若刪除成功,且存在舊值則觸發依賴該屬性的副作用函數執行任務壓入調度器
    trigger(target, TriggerOpTypes.DELETE, key, undefined, oldValue)
  }
  return result
}

攔截檢查存在與否操作('name' in state)

檢查存在與否屬於讀操作,因此我們可以用於依賴收集。

function has(target: object, key: string | symbol): boolean {
  const result = Reflect.has(target, key)
  // Symbol內置屬性不收集
  if (!isSymbol(key) || !builtInSymbols.has(key)) {
    track(target, TrackOpTypes.HAS, key)
  }
  return result
}

攔截鍵遍歷操作

以下操作都會執行ownKeysProxy trap方法

  • Object.getOwnPropertyNames
  • Object.getOwnPropertySymbols
  • Object.keys
  • Object.names
  • for..in

流程邏輯是:對於數組則跟蹤數組長度,否則跟蹤由effect模塊提供的ITERATE_KEY,這個是什麼東東呢?繼續往下看就知道了:)

function ownKeys(target: object): (string | symbol)[] {
  track(target, TrackOpTypes.ITERATE, isArray(target) ? 'length' : ITERATE_KEY)
  return Reflect.ownKeys(target)
}

Proxy中的receiver到底是什麼?

在上述代碼中我們發現會使用到Proxy攔截函數入參receiver,如:

  1. 在寫入攔截時,如果target === toRaw(receiver)成立則觸發副作用函數執行

  2. 在讀取攔截時,若key === ReactiveFlags.RAW && receiver === reactiveMap則不以入參會基礎構建響應式對象

  3. 另外,在開篇《petite-vue源碼剖析-從靜態視圖開始》中創建作用域鏈createScopedContext有如下代碼

     const reactiveProxy = reactive(
       new Proxy(mergeScope, {
         set(target, key, val, receiver) {
           // 若當設置的屬性不存在於當前作用域則將值設置到父作用域上,由於父作用域以同樣方式創建,因此遞歸找到擁有該屬性的祖先作用域並賦值
           if (receiver === reactiveProxy && !target.hasOwnProperty(key)) {
             return Reflect.set(parentScope, key, val)
           }
           return Reflect.set(target, key, val, receiver)
         }
       })
     )
    

那麼到底receiver是什麼呢?

  1. 對於數據屬性(data properties)的攔截,receiver指向當前構建的Proxy實例本身

    // `receiver`指向當前構建的`Proxy`實例本身
    const state = {
      name: 'john'
    }
    let pState = new Proxy(state, {
      get(target, key, receiver) {
        console.log("receiver === pState:", receiver === pState)
        return Reflect.get(target, key, receiver)
      }
    })
    
    pState.name
    // 回顯 receiver === pState: true
    
  2. 對於訪問器屬性(accessor properties)的攔截,receiver指向this或者繼承Proxy實例的對象

    const state = {
      _name: 'john',
      name() {
        return this._name
      }
    }
    
    let pState = new Proxy(state, {
      get(target, key, receiver) {
        console.log("target[key]():", target[key])
        console.log("receiver !== pState:", receiver !== pState)
        return Reflect.get(target, key, receiver)
      }
    })
    
    const son = {
      __proto__: pState,
      _name: 'son'
    }
    
    console.log(son.name)
    // 回顯 target[key](): john
    // 回顯 receiver !== pState: true
    // 回顯 son
    

雖然瞭解了receiver的作用,但對如下問題已經無法作出完整的解答:

  1. 在寫入攔截時,如果target === toRaw(receiver)成立則觸發副作用函數執行
    首先receiver是Proxy實例一定不會等於target,而toRaw(receiver)則是獲取其代理的對象,僅當被代理的對象和當前target相同時才觸發副作用函數執行。(至於什麼場景會出現,求高人指導?)
  2. 在讀取攔截時,若key === ReactiveFlags.RAW && receiver === reactiveMap則不以入參會基礎構建響應式對象
    爲何reactiveMap會進行Proxy呢?
  3. 另外,在開篇《petite-vue源碼剖析-從靜態視圖開始》中創建作用域鏈createScopedContext如下代碼
    receiver === reactiveProxy && !target.hasOwnProperty(key)即對當前作用域(receiver === reactiveProxy)進行寫操作時,若屬性不存在於該作用域對象,則往父作用域上遞歸執行寫操作。

總結

下一篇我們來看看代理Map/WeakMap/Set/WeakSetmutableCollectionHandlers的實現吧!
尊重原創,轉載請註明來自:https://www.cnblogs.com/fsjohnhuang/p/16037690.html肥仔John

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