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

小綿羊發表於2022-06-30

在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中的修改都能被攔截,舉個?:

<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 在檢視上也會發生變化的?,因為修改 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.png

最後,如果已經超過最大深度,因為採用降級方案,是全部刪除然後重新收集的,所以肯定是最新的,所以只需要把 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;
    }
};

舉個簡單的?,理解起來可能簡單點,有兩個元件,一個父元件,一個子元件,子元件接收父元件傳遞的 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 標記:

    • toggle 的 dep = {n: 0, w: 2}
    • visible 的 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
}

結尾

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

相關文章