Vue3響應式原始碼分析 - ref + ReactiveEffect篇

語言: CN / TW / HK

在Vue3中,因為reactive建立的響應式物件是通過Proxy來實現的,所以傳入資料不能為基礎型別,所以 ref 物件是對reactive不支援的資料的一個補充。

refreactive 中還有一個重要的工作就是收集、觸發依賴,那麼依賴是什麼呢?怎麼收集觸發?一起來看一下吧:

我們先來看一下 ref 的原始碼實現:

export function ref(value?: unknown) {
  return createRef(value, false)
}

export function shallowRef(value?: unknown) {
  return createRef(value, true)
}

const toReactive = (value) => isObject(value) ? reactive(value) : value;

function createRef(rawValue: unknown, shallow: boolean) {
  // 如果是ref則直接返回
  if (isRef(rawValue)) {
    return rawValue
  }
  return new RefImpl(rawValue, shallow)
}

class RefImpl<T> {
  private _value: T
  // 存放 raw 原始值
  private _rawValue: T

  // 存放依賴
  public dep?: Dep = undefined
  public readonly __v_isRef = true

  constructor(value: T, public readonly __v_isShallow: boolean) {
    // toRaw 拿到value的原始值
    this._rawValue = __v_isShallow ? value : toRaw(value)
    // 如果不是shallowRef,使用 reactive 轉成響應式物件
    this._value = __v_isShallow ? value : toReactive(value)
  }

  // getter攔截器
  get value() {
    // 收集依賴
    trackRefValue(this)
    return this._value
  }

  // setter攔截器
  set value(newVal) {
    // 如果是需要深度響應的則獲取 入參的raw
    newVal = this.__v_isShallow ? newVal : toRaw(newVal)
    // 新值與舊值是否改變
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal
      // 更新value 如果是深入建立並且是物件的話 還需要轉化為reactive代理
      this._value = this.__v_isShallow ? newVal : toReactive(newVal)
      // 觸發依賴
      triggerRefValue(this, newVal)
    }
  }
}

RefImpl 採用ES6類的寫法,包含 getset ,其實大家可以用 webpack 等打包工具打包成 ES5 的程式碼, 發現其實就是 Object.defineProperty

可以看到, shallowRefref 都呼叫了 createRef ,只是傳入的引數不同。當使用 shallowRef 時,不會呼叫 toReactive 去將物件轉換為響應式,由此可見,shallowRef物件只支援對value值的響應式,ref物件支援對value深度響應式,ref.value.a.b.c中的修改都能被攔截,舉個:chestnut::

<template>
    <p>{{ refData.a }}</p>
    <p>{{ shallowRefData.a }}</p>
    <button @click="handleChange">change</button>
</template>


let refData = ref({
  a: 'ref'
})
let shallowRefData = shallowRef({
  a: 'shallowRef'
})

const handleChange = () => {
  refData.value.a = "ref1"
  shallowRefData.value.a = "shallowRef1"
}

當我們點選按鈕修改資料後,介面上的 refData.a 的值會變為 ref1 ,而 shallowRefData.a 應該會不發生變化,但其實在這個例子裡, shallowRefData.a 在檢視上也會發生變化的:dog:,因為修改 refData.a 時候,觸發了setter函式,內會去呼叫 triggerRefValue(this, newVal) 從而觸發了 檢視更新 ,所以shallow的最新資料也會被更新到了檢視上 (把 refData.value.a = "ref1" 去掉它就不會變了)。

ref 裡最關鍵的還是 trackRefValuetriggerRefValue ,負責收集觸發依賴。

如何收集依賴:

function trackRefValue(ref) {
    // 判斷是否需要收集依賴
    // shouldTrack 全域性變數,代表當前是否需要 track 收集依賴
    // activeEffect 全域性變數,代表當前的副作用物件 ReactiveEffect
    if (shouldTrack && activeEffect) {
        ref = toRaw(ref);
        {
            // 如果沒有 dep 屬性,則初始化 dep,dep 是一個 Set<ReactiveEffect>,儲存副作用函式
            // trackEffects 收集依賴
            trackEffects(ref.dep || (ref.dep = createDep()), {
                target: ref,
                type: "get",
                key: 'value'
            });
        }
    }
}

為什麼要判斷 shouldTrackactiveEffect ,因為在Vue3中有些時候不需要收集依賴:

  • 當沒有 effect 包裹時,比如定義了一個ref變數,但沒有任何地方使用到,這時候就沒有依賴,activeEffect 為 undefined,就不需要收集依賴了
  • 比如在陣列的一些會改變自身長度的方法裡,也不應該收集依賴,容易造成死迴圈,此時 shouldTrack 為 false

*依賴是什麼?

ref.dep 用於儲存 依賴 (副作用物件),ref 被修改時就會觸發,那麼依賴是什麼呢?依賴就是 ReactiveEffect

為什麼要收集依賴(副作用物件),因為在Vue3中,一個響應式變數的變化,往往會觸發一些副作用,比如檢視更新、計算屬性變化等等,需要在響應式變數變化時去觸發其它一些副作用函式。

在我看來 ReactiveEffect 其實就和 Vue2 中的 Watcher 的作用差不多,我之前寫的 《Vue原始碼學習-響應式原理》 裡做過說明:

class ReactiveEffect {
    constructor(fn, scheduler = null, scope) {
        // 傳入一個副作用函式
        this.fn = fn;
        this.scheduler = scheduler;
        this.active = true;
        // 儲存 Dep 物件,如上面的 ref.dep
        // 用於在觸發依賴後, ref.dep.delete(effect),雙向刪除依賴)
        this.deps = [];
        this.parent = undefined;
        recordEffectScope(this, scope);
    }
    run() {
        // 如果當前effect已經被stop
        if (!this.active) {
            return this.fn();
        }
        let parent = activeEffect;
        let lastShouldTrack = shouldTrack;
        while (parent) {
            if (parent === this) {
                return;
            }
            parent = parent.parent;
        }
        try {
            // 儲存上一個 activeEffect
            this.parent = activeEffect;
            activeEffect = this;
            shouldTrack = true;
            // trackOpBit: 根據深度生成 trackOpBit
            trackOpBit = 1 << ++effectTrackDepth;
            // 如果不超過最大巢狀深度,使用優化方案
            if (effectTrackDepth <= maxMarkerBits) {
                // 標記所有的 dep 為 was
                initDepMarkers(this);
            }
            // 否則使用降級方案
            else {
                cleanupEffect(this);
            }
            // 執行過程中重新收集依賴標記新的 dep 為 new
            return this.fn();
        }
        finally {
            if (effectTrackDepth <= maxMarkerBits) {
                // 優化方案:刪除失效的依賴
                finalizeDepMarkers(this);
            }
            // 巢狀深度自 + 重置操作的位數
            trackOpBit = 1 << --effectTrackDepth;
            // 恢復上一個 activeEffect
            activeEffect = this.parent;
            shouldTrack = lastShouldTrack;
            this.parent = undefined;
            if (this.deferStop) {
                this.stop();
            }
        }
    }
}

ReactiveEffect 是副作用物件,它就是被收集依賴的實際物件,一個響應式變數可以有多個依賴,其中最主要的就是 run 方法,裡面有兩套方案,當 effect 巢狀次數不超過最大巢狀次數的時候,使用優化方案,否則使用降級方案。

降級方案:

function cleanupEffect(effect) {
    const { deps } = effect;
    if (deps.length) {
        for (let i = 0; i < deps.length; i++) {
            // 從 ref.dep 中刪除 ReactiveEffect
            deps[i].delete(effect);
        }
        deps.length = 0;
    }
}

這個很簡單,刪除全部依賴,然後重新收集。在各個 dep 中,刪除該 ReactiveEffect 物件,然後執行 this.fn() (副作用函式) 時,當獲取響應式變數觸發 getter 時,又會重新收集依賴。之所以要先刪除然後重新收集,是因為隨著響應式變數的變化,收集到的依賴前後可能不一樣。

const toggle = ref(false)
const visible = ref('show')
effect(() = {
  if (toggle.value) {
    console.log(visible.value)
  } else {
    console.log('xxxxxxxxxxx')
  }
})
toggle.value = true
  • 當 toggle 為 true 時,toggle、visible 都能收集到依賴
  • 當 toggle 為 false 時,只有visible 可以收集到依賴

優化方案:

全部刪除,再重新收集,明顯太消耗效能了,很多依賴其實是不需要被刪除的,所以優化方案的做法是:

// 響應式變數上都有一個 dep 用來儲存依賴
const createDep = (effects) => {
    const dep = new Set(effects);
    dep.w = 0;
    dep.n = 0;
    return dep;
};
  1. 執行副作用函式前,給 ReactiveEffect 依賴的響應式變數 ,加上 w(was的意思) 標記。
  2. 執行 this.fn(),track 重新收集依賴時,給 ReactiveEffect 的每個依賴,加上 n(new的意思) 標記。
  3. 最後,對有 w 但是沒有 n 的依賴進行刪除。

其實就是一個篩選的過程,我們現在來第一步,如何加上 was 標記:

// 在 ReactiveEffect 的 run 方法裡
if (effectTrackDepth <= maxMarkerBits) {
    initDepMarkers(this);
}

const initDepMarkers = ({ deps }) => {
    if (deps.length) {
        for (let i = 0; i < deps.length; i++) {
            deps[i].w |= trackOpBit;
        }
    }
};

這裡使用了位運算,快捷高效。trackOpBit是什麼呢?代表當前巢狀深度 (effect可以巢狀) ,在Vue3中有一個全域性變數 effectTrackDepth

// 全域性變數 巢狀深度 
let effectTrackDepth = 0;

// 在 ReactiveEffect 的 run 方法裡
// 每次執行 effect 副作用函式前,全域性變數巢狀深度會自增1
trackOpBit = 1 << ++effectTrackDepth

// 執行完副作用函式後會自減
trackOpBit = 1 << --effectTrackDepth;

當深度為 1 時,trackOpBit為 2(二進位制:00000010),這樣執行 deps[i].w |= trackOpBit 時,操作的是第二位,所以第一位是用不到的。

為什麼Vue3中巢狀深度最大是 30 ?

1 << 30
// 0100 0000 0000 0000 0000 0000 0000 0000
// 1073741824

1 << 31
// 1000 0000 0000 0000 0000 0000 0000 0000
// -2147483648 溢位

因為js中位運算是以32位帶符號的整數進行運算的,最左邊一位是符號位,所以可用的正數最多隻能到30位。

可以看到,在執行副作用函式之前,使用 deps[i].w |= trackOpBit ,對依賴在不同深度是否被依賴( w )進行標記,然後執行 this.fn() ,重新收集依賴,上面說到收集依賴呼叫 trackRefValue 方法,該方法內會呼叫 trackEffects

function trackEffects(dep, debuggerEventExtraInfo) {
    let shouldTrack = false;
    if (effectTrackDepth <= maxMarkerBits) {
        // 檢視是否記錄過當前依賴
        if (!newTracked(dep)) {
            dep.n |= trackOpBit;
            // 如果 w 在當前深度有值,說明effect之前已經收集過
            // 不是新增依賴,不需要再次收集
            shouldTrack = !wasTracked(dep);
        }
    }
    else {
        shouldTrack = !dep.has(activeEffect);
    }
    if (shouldTrack) {
        // dep添加當前正在使用的effect
        dep.add(activeEffect);
         // effect的deps也記錄當前dep 雙向引用
        activeEffect.deps.push(dep);
    }
}

可以看到再重新收集依賴的時候,使用 dep.n |= trackOpBit 對依賴在不同深度是否被依賴( n )進行標記,這裡還用到兩個工具函式:

const wasTracked = (dep) => (dep.w & trackOpBit) > 0;
const newTracked = (dep) => (dep.n & trackOpBit) > 0;

使用 wasTracked 和 newTracked,判斷 dep 是否在當前深度被標記。比如判斷依賴在深度 1 時 (trackOpBit第二位是1) 是否被標記,採用按位與:

最後,如果已經超過最大深度,因為採用降級方案,是全部刪除然後重新收集的,所以肯定是最新的,所以只需要把 trackOpBit 恢復,恢復上一個 activeEffect:

finally {
    if (effectTrackDepth <= maxMarkerBits) {
        // 優化方案:刪除失效的依賴
        finalizeDepMarkers(this);
    }
    trackOpBit = 1 << --effectTrackDepth;
    // 恢復上一個 activeEffect
    activeEffect = this.parent;
    shouldTrack = lastShouldTrack;
    this.parent = undefined;
    if (this.deferStop) {
        this.stop();
    }
}

如果沒超過最大深度,就像之前說的把失效的依賴刪除掉,然後更新一下deps的順序:

const finalizeDepMarkers = (effect) => {
    const { deps } = effect;
    if (deps.length) {
        let ptr = 0;
        for (let i = 0; i < deps.length; i++) {
            const dep = deps[i];
            // 把有 w 沒有 n 的刪除
            if (wasTracked(dep) && !newTracked(dep)) {
                dep.delete(effect);
            }
            else {
                // 更新deps,因為有的可能會被刪掉
                // 所以要把前面空的補上,用 ptr 單獨控制下標 
                deps[ptr++] = dep;
            }
            // 與非,恢復到進入時的狀態
            dep.w &= ~trackOpBit;
            dep.n &= ~trackOpBit;
        }
        deps.length = ptr;
    }
};

舉個簡單的:chestnut:,理解起來可能簡單點,有兩個元件,一個父元件,一個子元件,子元件接收父元件傳遞的 toggle 引數顯示在介面上, toggle 還控制著 visible 的顯示,點選按鈕切換 toggle 的值:

// Parent
<script setup lang="ts">
const toggle = ref(true)
const visible = ref('show')

const handleChange = () => {
  toggle.value = false
}
</script>

<template>
  <div>
    <p v-if="toggle">{{ visible }}</p>
    <p v-else>xxxxxxxxxxx</p>
    <button @click="handleChange">change</button>
    <Child :toggle="toggle" />
  </div>
</template>
// Child
<script setup lang="ts">
const props = defineProps({
  toggle: {
    type: Boolean,
  },
});
</script>

<template>
  <p>{{ toggle }}</p>
</template>

第一次渲染,因為toggle 預設為 true,我們可以收集到 togglevisible 的依賴,

  1. Parent 元件, 執行 run 方法中的 initDepMarkers 方法,首次進入,還未收集依賴, ReactiveEffectdeps 長度為0,跳過。
  2. 執行 run 方法中的 this.fn ,重新收集依賴,觸發 trackEffects:

    • toggle 的 dep = {n: 2, w: 0}shouldTrack 為 true,收集依賴。
    • visible 的 dep = {n: 2, w: 0}shouldTrack 為 true,收集依賴。
  3. 進入 Child 元件,執行 run 方法中的 initDepMarkers 方法,首次進入,還為收集依賴,deps長度為0,跳過。
  4. 執行 run 方法中的 this.fn ,重新收集依賴,觸發 trackEffects:

    • toggle 的 dep = {n: 4, w: 0}shouldTrack 為 true,收集依賴。

這樣首次進入頁面的收集依賴就結束了,然後我們點選按鈕,把 toggle 改為 false:

  1. Parent 元件: 執行 run 方法中的 initDepMarkers 方法,之前在 Parent 元件裡收集到了兩個變數的依賴,所以將他們 w 標記:

    dep = {n: 0, w: 2}
    dep = {n: 0, w: 2}
    
  2. 執行 run 方法中的 this.fn ,重新收集依賴,觸發 trackEffects:

    • toggle 的 dep = {n: 2, w: 2}shouldTrack 為 false, 不用 收集依賴。
    • visible 不顯示了 ,所以沒有重新收集到,還是 {n: 0, w: 2}
  3. 進入 Child 元件,執行 run 方法中的 initDepMarkers 方法,之前 收集過 toggle 依賴了,將 toggle 的 w 做標記,toggle 的 dep = {n: 0, w: 4}
  4. 執行 run 方法中的 this.fn ,重新收集依賴,觸發 trackEffects:

    • toggle 的 dep = {n: 4, w: 4}shouldTrack 為 false,不用收集依賴。

最後發現 visiblew 沒有 n ,在 finalizeDepMarkers 中刪除掉失效依賴。

如何觸發依賴:

在一開始講到的 ref 原始碼裡,可以看到在 setter 時會呼叫 triggerRefValue 觸發依賴:

function triggerRefValue(ref, newVal) {
    ref = toRaw(ref);
    if (ref.dep) {
        {
            triggerEffects(ref.dep, {
                target: ref,
                type: "set",
                key: 'value',
                newValue: newVal
            });
        }
    }
}

function triggerEffects(
  dep: Dep | ReactiveEffect[]
) {
  // 迴圈去取每個依賴的副作用物件 ReactiveEffect
  for (const effect of isArray(dep) ? dep : [...dep]) {
    // effect !== activeEffect 防止遞迴,造成死迴圈
    if (effect !== activeEffect || effect.allowRecurse) {
      // effect.scheduler可以先不管,ref 和 reactive 都沒有
      if (effect.scheduler) {
        effect.scheduler()
      } else {
        // 執行 effect 的副作用函式
        effect.run()
      }
    }
  }
}

觸發依賴最終的目的其實就是去執行 依賴每個的副作用物件副作用函式 ,這裡的副作用函式可能是執行更新檢視、watch資料監聽、計算屬性等。

我個人再看原始碼的時候還遇到了一個問題,不知道大家遇到沒有(我看的程式碼版本算是比較新v3.2.37),一開始我也是上網看一些原始碼的解析文章,看到好多講解 effect 這個函式的,先來看看這個方法的原始碼:

function effect(fn, options) {
    if (fn.effect) {
        fn = fn.effect.fn;
    }
    const _effect = new ReactiveEffect(fn);
    if (options) {
        extend(_effect, options);
        if (options.scope)
            recordEffectScope(_effect, options.scope);
    }
    if (!options || !options.lazy) {
        _effect.run();
    }
    const runner = _effect.run.bind(_effect);
    runner.effect = _effect;
    // 返回一個包裝後的函式,執行收集依賴
    return runner;
}

這個函式看上去挺簡單的,建立一個 ReactiveEffect 副作用物件,將使用者傳入的引數附加到物件上,然後呼叫 run 方法收集依賴,如果有 lazy 配置不會自動去收集依賴,使用者主動執行 effect 包裝後的函式,也能夠正確的收集依賴。

但我找了一圈,發現原始碼裡一個地方都沒呼叫,於是我就在想是不是以前用到過,現在去掉了,去commit記錄裡找了一圈,還真找到了:

這次更新把 ReactiveEffect 改為用類來實現,避免不必要時也建立 effect runner ,節省了17%的記憶體等。

原來的 effect 方法包括了現在的 ReactiveEffect ,在檢視更新渲染、watch等地方都直接引用了這個方法,但更新後都是直接 new ReactiveEffect ,然後去觸發 run 方法,不走 effect 了,可以說現在的 ReactiveEffect 類就是之前的 effect 方法 。

export function effect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
  const effect = createReactiveEffect(fn, options)
  return effect
}

let uid = 0

function createReactiveEffect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions
): ReactiveEffect<T> {
  const effect = function reactiveEffect(): unknown {
    if (!effect.active) {
      return fn()
    }
    if (!effectStack.includes(effect)) {
      cleanup(effect)
      try {
        enableTracking()
        effectStack.push(effect)
        activeEffect = effect
        return fn()
      } finally {
        effectStack.pop()
        resetTracking()
        const n = effectStack.length
        activeEffect = n > 0 ? effectStack[n - 1] : undefined
      }
    }
  } 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
}

結尾

我是周小羊,一個前端萌新,寫文章是為了記錄自己日常工作遇到的問題和學習的內容,提升自己,如果您覺得本文對你有用的話,麻煩點個贊鼓勵一下喲~