Vue3 Reactivity 數據響應式原理解析

Vue3 如火如荼,與其乾等,不如花一個下午茶的時間來看下最新的響應式數據是如何實現的吧。在本文中,會寫到 vue3 的依賴收集和 proxy 數據代理,以及副作用 (effect) 是如何進行工作的。

基本差不多了,圖有點小丑,也可以看比人比較全的圖。QAQ

前言

好久沒有接觸Vue了,在前幾天觀看尤大的直播時談論對於看源碼的一些看法,是爲了更好的上手vue? 還是想要學習內部的框架思想?

國內前端:面試,面試會問。

在大環境下似乎已經卷到了只要你是開發者,那麼必然需要去學習源碼,不論你是實習生,還是應屆生,或者是多年經驗的老前端。

如果你停滯下來,不跟着卷,那麼忽然之間帶來的壓力就會將你沖垮,以至於你可能很難在內卷的環境下生存下去,哪怕你是對的。

有興趣的話可以閱讀一下 @掘金泥石流大佬的寫的程序員焦慮程度自測表。

似乎講了太多的題外話,與其發牢騷不如靜下心來,一起學習一下Reactivity的一些基本原理吧,相信閱讀完文章的你會對vue 3數據響應式有更加深刻的理解。

而之所以選擇Reactivity模塊來說,是因爲其耦合度較低,且是vue3.0核心模塊之一,性價比成本非常高。

基礎篇

在開始之前,如果不瞭解ES6出現的一些高階api,如,Proxy, Reflect, WeakMap, WeakSet,Map, Set等等可以自行翻閱到資源章節,先了解前置知識點在重新觀看爲最佳。

Proxy

@vue/reactivity中,Proxy是整個調度的基石。

通過Proxy代理對象,才能夠在get, set方法中完成後續的事情,比如依賴收集effecttrack, trigger等等操作,在這裏就不詳細展開,後續會詳細展開敘述。

如果有同學迫不及待,加上天資聰慧,ES6有一定基礎,可以直接跳轉到原理篇進行觀看和思考。

先來手寫一個簡單的Proxy。在其中handleCallback中寫了了set, get兩個方法,又來攔截當前屬性值變化的數據監聽。先上代碼:

const user = {
  name: 'wangly19',
  age: 22,
  description: '一名掉頭髮微乎其微的前端小哥。'
}

const userProxy = new Proxy(user, {
  get(target, key) {
    console.log(`userProxy: 當前獲取key爲${key}`)
    if (target.hasOwnProperty(key)) return target[key]
    return {
    }
  },
  set(target, key, value) {
    console.log(`userProxy: 當前設置值key爲${key}, value爲${value}`)
    let isWriteSuccess = false
    if (target.hasOwnProperty(key)) {
      target[key] = value
      isWriteSuccess = true
    }
    return isWriteSuccess
  }
})

console.log('myNaame', userProxy.name)

userProxy.age = 23
複製代碼

當我們在對值去進行賦值修改和打印的時候,分別觸發了當前的setget方法。

這一點非常重要,對於其他的一些屬性和使用方法在這裏就不過多的贅述,

Reflect

Reflect並不是一個類,是一個內置的對象。這一點呢大家要知悉,不要直接實例化(new)使用,它的功能比較和Proxyhandles有點類似,在這一點基礎上又添加了很多Object的方法。

在這裏我們不去深究Reflect, 如果想要深入瞭解功能的同學,可以在後續資源中找到對應地址進行學習。在本章主要介紹了通過Reflect安全的操作對象。

以下是對user對象的一些修改操作的實例,可以參考一下,在後續可能會用到。

const user = {
  name: 'wangly19',
  age: 22,
  description: '一名掉頭髮微乎其微的前端小哥。'
}

console.log('change age before' , Reflect.get(user, 'age'))

const hasChange = Reflect.set(user, 'age', 23)
console.log('set user age is done? ', hasChange ? 'yes' : 'no')

console.log('change age after' , Reflect.get(user, 'age'))

const hasDelete = Reflect.deleteProperty(user, 'age')

console.log('delete user age is done?', hasDelete ? 'yes' : 'none')

console.log('delete age after' , Reflect.get(user, 'age'))
複製代碼

原理篇

當了解了前置的一些知識後,就要開始@vue/reactivity的源碼解析篇章了。下面開始會以簡單的思路來實現一個基礎的reactivity,當你瞭解其本質原理後,你會對@vue/reactivity依賴收集(track)觸發更新(trigger),以及副作用(effect)究竟是什麼工作。

reactive

reactivevue3中用於生成引用類型api

const user = reactive({
  name: 'wangly19',
  age: 22,
  description: '一名掉頭髮微乎其微的前端小哥。'
})
複製代碼

那麼往函數內部看看,reactive方法究竟做了什麼?

在內部,對傳入的對象進行了一個target的只讀判斷,如果你傳入的target是一個只讀代理的話,會直接返回掉。對於正常進行reactive的話則是返回了createReactiveObject方法的值。

export function reactive(target: object) {
  // if trying to observe a readonly proxy, return the readonly version.
  if (target && (target as Target)[ReactiveFlags.IS_READONLY]) {
    return target
  }
  return createReactiveObject(
    target,
    false,
    mutableHandlers,
    mutableCollectionHandlers,
    reactiveMap
  )
}
複製代碼

createReactiveObject

createReactiveObject中,做的事情就是爲target添加一個proxy代理。這是其核心,reactive最終拿到的是一個proxy代理,參考Proxy章節的簡單事例就可以知道reactive是如何進行工作的了,那麼在來看下createReactiveObject做了一些什麼事情。

首先先判斷當前target的類型,如果不符合要求,直接拋出警告並且返回原來的值。

if (!isObject(target)) {
    if (__DEV__) {
      console.warn(`value cannot be made reactive: ${String(target)}`)
    }
    return target
  }
複製代碼

其次判斷當前對象是否已經被代理且並不是只讀的,那麼本身就是一個代理對象,那麼就沒有必要再去進行代理了,直接將其當作返回值返回,避免重複代理。

if (
    target[ReactiveFlags.RAW] &&
    !(isReadonly && target[ReactiveFlags.IS_REACTIVE])
  ) {
    return target
  }
複製代碼

對於這些判斷代碼來說,閱讀起來並不是很困難,注意if ()中判斷的條件,看看它做了一些什麼動作即可。而createReactiveObject做的最重要的事情就是創建targetproxy, 並將其放到Map中記錄。

而比較有意思的是其中對傳入的target調用了不同的proxy handle。那麼就一起來看看handles中究竟幹了一些什麼吧。

const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
  )
  proxyMap.set(target, proxy)
  return proxy
複製代碼

handles 的類型

在對象類型中,將ObjectArrayMap,Set, WeakMap,WeakSet區分開來了。它們調用的是不同的Proxy Handle

/**
 * 對象類型判斷
 * @lineNumber 41
 */
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
  }
}
複製代碼

會在new Proxy的根據返回的targetType判斷。

const proxy = new Proxy(
  target,
  targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
)
複製代碼

由於篇幅有限,下文中只舉例mutableHandlers當作分析的參考。當理解mutableHandlers後對於collectionHandlers只是時間的問題。

Proxy Handle

在上面說到了根據不同的Type調用不同的handle,那麼一起來看看mutableHandlers究竟做了什麼吧。

在基礎篇中,都知道Proxy可以接收一個配置對象,其中我們演示了getset的屬性方法。而mutableHandlers就是何其相同意義的事情,在內部分別定義get, set, deleteProperty, has, oneKeys等多個屬性參數,如果不知道什麼含義的話,可以看下Proxy Mdn。在這裏你需要理解被監聽的數據 只要發生增查刪改後,絕大多數都會進入到對應的回執通道里面。

在這裏,我們用簡單的get, set來進行簡單的模擬實例。

function createGetter () {
    return (target, key, receiver) ={
      const result = Reflect.get(target, key, receiver)
      track(target, key)
      return result
    }
}

const get = /*#__PURE__*/ createGetter()

function createSetter () {
  
  return (target, key, value, receiver) ={
    const oldValue = target[key]
  const result = Reflect.set(target, key, value, receiver)
  if (result && oldValue != value) {
    trigger(target, key)
  }
  return result
  }
}
複製代碼

get的時候會進行一個track的依賴收集,而set的時候則是觸發trigger的觸發機制。在vue3,而triggertrack的話都是在我們effect.ts當中聲明的,那麼接下來就來看看依賴收集響應觸發究竟做了一些什麼吧。

Effect

對於整個 effect 模塊,將其分爲三個部分來去閱讀:

effect

通過一段實例來看下effect的使用,並且瞭解它主要參數是一個函數。在函數內部會幫你執行一些副作用記錄和特性判斷。

effect(() ={
    proxy.user = 1
})
複製代碼

來看看vueeffect幹了什麼?

在這裏,首先判斷當前參數fn是否是一個effect,如果是的話就將raw中存放的fn進行替換。然後重新進行createReactiveEffect生成。

export function effect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
  if (isEffect(fn)) {
    fn = fn.raw
  }
  const effect = createReactiveEffect(fn, options)
  if (!options.lazy) {
    effect()
  }
  return effect
}

複製代碼

createReactiveEffect會將我們effect推入到effectStack中進行入棧操作,然後用activeEffect進行存取當前執行的effect,在執行完後會將其進行出棧。同時替換activeEffect爲新的棧頂。

而在effect執行的過程中就會觸發proxy handle然後tracktrigger兩個關鍵的函數。

function createReactiveEffect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions
): ReactiveEffect<T> {
  const effect = function reactiveEffect(): unknown {
    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
}
複製代碼

來看一個簡版的effect,拋開大多數代碼包袱,下面的代碼是不是清晰很多。

function effect(eff) {
  try {
    effectStack.push(eff)
    activeEffect = eff
    return eff(...argsument)
    
  } finally {
    effectStack.pop()
    activeEffect = effectStack[effectStack.length  - 1]
  }
}
複製代碼

track(依賴收集)

track的時候,會進行我們所熟知的依賴收集,會將當前activeEffect添加到dep裏面,而說起這一類的關係。它會有一個一對多對多的關係。

從代碼看也非常的清晰,首先我們會有一個一個總的targetMap它是一個WeakMapkeytarget(代理的對象), value是一個Map,稱之爲depsMap,它是用於管理當前target中每個keydeps也就是副作用依賴,也就是以前熟知的depend。在vue3中是通過Set來去實現的。

第一步先憑藉當前target獲取targetMap中的depsMap,如果不存在就進行targetMap.set(target, (depsMap = new Map()))初始化聲明,其次就是從depsMap中拿當前keydeps, 如果沒有找到的話,同樣是使用depsMap.set(key, (dep = new Set()))進行初始化聲明,最後將當前activeEffect推入到deps, 進行依賴收集。

  1. targetMap中找target
  1. depsMap中找key
  1. activeEffect保存到dep裏面。

這樣的話就會形成一個一對多對多的結構模式,裏面存放的是所有被proxy劫持的依賴。

function track(target: object, type: TrackOpTypes, key: unknown) {
  if (!shouldTrack || activeEffect === undefined) {
    return
  }
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Set()))
  }
  if (!dep.has(activeEffect)) {
    dep.add(activeEffect)
    activeEffect.deps.push(dep)
    if (__DEV__ && activeEffect.options.onTrack) {
      activeEffect.options.onTrack({
        effect: activeEffect,
        target,
        type,
        key
      })
    }
  }
}
複製代碼

trigger(響應觸發)

trigger的時候,做的事情其實就是觸發當前響應依賴的執行。

首先,需要獲取當前key下所有渠道的deps,所以會看到有一個effectsadd函數, 做的事情非常的簡單,就是來判斷當前傳入的depsMap的屬性是否需要添加到effects裏面,在這裏的條件就是effect不能是當前的activeEffecteffect.allowRecurse,來確保當前set key的依賴都進行執行。

const effects = new Set<ReactiveEffect>()
  const add = (effectsToAdd: Set<ReactiveEffect> | undefined) ={
    if (effectsToAdd) {
      effectsToAdd.forEach(effect ={
        if (effect !== activeEffect || effect.allowRecurse) {
          effects.add(effect)
        }
      })
    }
  }
複製代碼

下面下面熟知的場景就是判斷當前傳入的一些變化行爲,最常見的就是在trigger中會傳遞的TriggerOpTypes行爲,然後執行add方法將其將符合條件的effect添加到effects當中去,在這裏@vue/reactivity做了很多數據就變異上的行爲,如length變化。

然後根據不同的TriggerOpTypes進行depsMap的數據取出,最後放入effects。隨後通過run方法將當前的effect執行,通過effects.forEach(run)進行執行。

if (type === TriggerOpTypes.CLEAR) {
    // collection being cleared
    // trigger all effects for target
    depsMap.forEach(add)
  } else if (key === 'length' && isArray(target)) {
    depsMap.forEach((dep, key) ={
      if (key === 'length' || key >= (newValue as number)) {
        add(dep)
      }
    })
  } else {
    // schedule runs for SET | ADD | DELETE
    if (key !== void 0) {
      add(depsMap.get(key))
    }

    // also run for iteration key on ADD | DELETE | Map.SET
    switch (type) {
      case TriggerOpTypes.ADD:
        if (!isArray(target)) {
          add(depsMap.get(ITERATE_KEY))
          if (isMap(target)) {
            add(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        } else if (isIntegerKey(key)) {
          // new index added to array -> length changes
          add(depsMap.get('length'))
        }
        break
      case TriggerOpTypes.DELETE:
        if (!isArray(target)) {
          add(depsMap.get(ITERATE_KEY))
          if (isMap(target)) {
            add(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        }
        break
      case TriggerOpTypes.SET:
        if (isMap(target)) {
          add(depsMap.get(ITERATE_KEY))
        }
        break
    }
  }
複製代碼

run又做了什麼呢?

首先就是判斷當前effectoptions下有沒有scheduler,如果有的話就使用schedule來處理執行,反之直接直接執行effect()

if (effect.options.scheduler) {
      effect.options.scheduler(effect)
    } else {
      effect()
    }
複製代碼

將其縮短一點看處理邏輯,其實就是從targetMap中拿對應key的依賴。

const depsMap = targetMap.get(target)
  if (!depsMap) {
    return
  }
  const dep = depsMap.get(key)
  if (dep) {
    dep.forEach((effect) ={
      effect()
    })
  }
複製代碼

Ref

衆所周知,refvue3對普通類型的一個響應式數據聲明。而獲取ref的值需要通過ref.value的方式進行獲取,很多人以爲ref就是一個簡單的reactive但其實不是。

在源碼中,ref最終是調用一個createRef的方法,在其內部返回了RefImpl的實例。它與Proxy不同的是,ref的依賴收集和響應觸發是在getter/setter當中,這一點可以參考圖中demo形式,鏈接地址 gettter/setter。

export function ref<T extends object>(value: T): ToRef<T>
export function ref<T>(value: T): Ref<UnwrapRef<T>>
export function ref<T = any>(): Ref<T | undefined>
export function ref(value?: unknown) {
  return createRef(value)
}

function createRef(rawValue: unknown, shallow = false) {
  if (isRef(rawValue)) {
    return rawValue
  }
  return new RefImpl(rawValue, shallow)
}
複製代碼

如圖所示,vuegetter中與proxy中的get一樣都調用了track收集依賴,在setter中進行_value值更改後調用trigger觸發器。

class RefImpl<T> {
  private _value: T

  public readonly __v_isRef = true

  constructor(private _rawValue: T, public readonly _shallow = false) {
    this._value = _shallow ? _rawValue : convert(_rawValue)
  }

  get value() {
    track(toRaw(this), TrackOpTypes.GET, 'value')
    return this._value
  }

  set value(newVal) {
    if (hasChanged(toRaw(newVal), this._rawValue)) {
      this._rawValue = newVal
      this._value = this._shallow ? newVal : convert(newVal)
      trigger(toRaw(this), TriggerOpTypes.SET, 'value', newVal)
    }
  }
}
複製代碼

那麼你現在應該知道:

Computed

computed一般有兩種常見的用法, 一種是通過傳入一個對象,內部有setget方法,這種屬於ComputedOptions的形式。

export function computed<T>(getter: ComputedGetter<T>): ComputedRef<T>
export function computed<T>(
  options: WritableComputedOptions<T>
): WritableComputedRef<T>
export function computed<T>(
  getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>
)
複製代碼

而在內部會有getter / setter兩個變量來進行保存。

getterOrOptions爲函數的時候,會將其賦值給與getter

getterOrOptions爲對象的時候,會將setget分別賦值給setter,getter

隨後將其作爲參數進行實例化ComputedRefImpl類,並將其當作返回值返回出去。

let getter: ComputedGetter<T>
  let setter: ComputedSetter<T>

  if (isFunction(getterOrOptions)) {
    getter = getterOrOptions
    setter = __DEV__
      ? () ={
          console.warn('Write operation failed: computed value is readonly')
        }
      : NOOP
  } else {
    getter = getterOrOptions.get
    setter = getterOrOptions.set
  }
  
  return new ComputedRefImpl(
    getter,
    setter,
    isFunction(getterOrOptions) || !getterOrOptions.set
  ) as any
複製代碼

那麼ComputedRefImpl幹了一些什麼?

計算屬性的源碼,其實絕大多數是依賴前面對effect的一些理解。

首先,我們都知道,effect可以傳遞一個函數和一個對象options

在這裏將getter當作函數參數傳遞,也就是副作用,而在options當中配置了lazyscheduler

lazy表示effect並不會立即被執行,而scheduler是在trigger中會判斷你是否傳入了scheduler,傳入後就執行scheduler方法。

而在computed scheduler當中,會判斷當前的_dirty是否爲false,如果是的話會把_dirty設置爲true,且執行trigger觸發響應。

class ComputedRefImpl<T> {
  private _value!: T
  private _dirty = true

  public readonly effect: ReactiveEffect<T>

  public readonly __v_isRef = true;
  public readonly [ReactiveFlags.IS_READONLY]: boolean

  constructor(
    getter: ComputedGetter<T>,
    private readonly _setter: ComputedSetter<T>,
    isReadonly: boolean
  ) {
    this.effect = effect(getter, {
      lazy: true,
      scheduler: () ={
        if (!this._dirty) {
          this._dirty = true
          trigger(toRaw(this), TriggerOpTypes.SET, 'value')
        }
      }
    })

    this[ReactiveFlags.IS_READONLY] = isReadonly
  }
複製代碼

而在getter/setter中會對_value進行不同操作。

首先,在get value中,判斷當前._dirty是否爲true,如果是的話執行緩存的effect並將其返回結果存放到_value,並執行track進行依賴收集。

其次,在set value中,則是調用_setter方法重新新值。

get value() {
    // the computed ref may get wrapped by other proxies e.g. readonly() #3376
    const self = toRaw(this)
    if (self._dirty) {
      self._value = this.effect()
      self._dirty = false
    }
    track(self, TrackOpTypes.GET, 'value')
    return self._value
  }

  set value(newValue: T) {
    this._setter(newValue)
  }
複製代碼

資源引用

下面是一些參考資源,有興趣的小夥伴可以看下

總結

如果你使用vue的話強烈建議自己debug將這一塊看完,絕對會對你寫代碼有很大的幫助。vue3如火如荼,目前已經有團隊作用於生產環境進行項目開發,社區的生態也慢慢的發展起來。

@vue/reactivity的閱讀難度並不高,也有很多優質的教程,有一定的工作基礎和代碼知識都能循序漸進的理解下來。我個人其實並不需要將其理解的滾瓜爛熟,理解每一行代碼的意思什麼的,而是瞭解其核心思想,學習框架理念以及一些框架開發者代碼寫法的思路。這都是能夠借鑑並將其吸收成爲自己的知識。

對於一個已經轉到React生態體系下的前端來說,讀Vue的源碼其實更多的是豐富自己在思維上的知識,而不是爲了面試而去讀的。正如同你背書不是爲了考試,而是學習知識。在現在的環境下,很難做到這些事情,靜下心來專心理解一件知識不如背幾篇面經。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/V_959uAoCQ9UTE9NB4tIpg