六千字詳解!講透 Vue3 響應式是如何實現的

語言: CN / TW / HK

點選上方  高階前端進階 ,回覆“ 加群

加入我們一起學習,天天進步

前言

本文使用 ref 對 vue 的響應性進行解讀,僅僅是響應性原理解析,不涉及 vue 元件等概念。

vue 的響應性的實現,在 @vue/reactivity 包下,對應的原始碼目錄為 packages/reactivity。如何除錯 vue 原始碼,可檢視 該文章 [1]

為什麼使用 ref 進行講解,而不是 reactive?

ref 比 reactive 的實現簡單,且不需要用到 es6 的 Proxy,僅僅需要使用到物件的 getter 和 setter 函式

因此,講述響應性原理,我們用簡單的 ref ,儘量減少大家的理解成本

什麼是響應性?

這部分的響應性定義,來自 vue3 官方文件 [2]

這個術語在程式設計中經常被提及,但這是什麼意思呢?響應性是一種允許我們以宣告式的方式去適應變化的程式設計範例。人們通常展示的典型例子,是一份 excel 電子表格 (一個非常好的例子)。

如果將數字 2 放在第一個單元格中,將數字 3 放在第二個單元格中並要求提供 SUM,則電子表格會將其計算出來給你。不要驚奇,同時,如果你更新第一個數字,SUM 也會自動更新。

JavaScript 通常不是這樣工作的——如果我們想用 JavaScript 編寫類似的內容:

let val1 = 2
let val2 = 3
let sum = val1 + val2

console.log(sum) // 5

val1 = 3

console.log(sum) // 仍然是 5
複製程式碼

如果我們更新第一個值,sum 不會被修改。

那麼我們如何用 JavaScript 實現這一點呢?

我們這裡直接看 @vue/reactive 的測試用例,來看看怎麼使用,才會做到響應性的效果

ref 的測試用例

it 包裹的是測試用例的具體內容,我們只需要關注回撥裡面的程式碼即可。

it('should be reactive', () => {
    const a = ref(1)
    let dummy
    let calls = 0
    effect(() => {
        calls++
        dummy = a.value
    })
    expect(calls).toBe(1)
    expect(dummy).toBe(1)
    a.value = 2
    expect(calls).toBe(2)
    expect(dummy).toBe(2)
    // same value should not trigger
    a.value = 2
    expect(calls).toBe(2)
    expect(dummy).toBe(2)
})
複製程式碼

我們從測試用例中,可以看出有以下幾點結論:

  1. 被 effect 包裹的函式,會 自動執行 一次。

  2. 被 effect 函式 包裹的函式體,擁有了響應性 —— 當 effect 內的函式中的 ref 物件 a.value 被修改時,該函式會自動重新執行。

  3. 當 a.value 被設定成 同一個值 時,函式並 不會自動的重新執行

effect 是什麼?

官方文件中的描述 [3] :Vue 通過一個副作用 (effect) 來跟蹤函式。副作用是一個函式的包裹器,在函式被呼叫之前就啟動跟蹤。Vue 知道哪個副作用在何時執行,並能在需要時再次執行它。

簡單地說,要使一個函式擁有響應性,就應該將它包裹在(傳入)effect 函式裡。

那麼這裡也可以稍微猜一下,如果有這麼一個 updateDom 函式:

const a_ref = ref('aaaa')
function updateDom(){
    return document.body.innerText = a_ref.value
}
effect(updateDom)
setTimeout(()=>{
    a_ref.value = 'bbb'
},1000)
複製程式碼

只要用 effect 包裹一下,當 a_ref.value 改變,就會自動設定 document.body.innerText,從而更新介面。

(當然這裡也只是猜一下,實際上基本的原理,也與這個差不多,但會複雜很多。由於本文篇幅優先,並沒有涉及到這部分)

依賴收集和觸發更新

要實現響應性,就需要在合適的時機,再次執行副作用 effect。如何確定這個合適的時機?就需要依賴收集(英文術語:track)和觸發更新(英文術語:trigger)

仍然看這個測試用例的例子

it('should be reactive', () => {
    const a = ref(1)
    let dummy
    let calls = 0
    effect(() => {
        calls++
        dummy = a.value
    })
    expect(calls).toBe(1)
    expect(dummy).toBe(1)
    a.value = 2
    expect(calls).toBe(2)
    expect(dummy).toBe(2)
    // same value should not trigger
    a.value = 2
    expect(calls).toBe(2)
    expect(dummy).toBe(2)
})
複製程式碼

我們已經知道,effect 包裹的函式,要在合適的時機被再次執行,那麼在這個例子中,合適的時機就是,a.value 這個 ref 物件被修改。

由於副作用函式,使用了 a.value,因此副作用函式,依賴 a 這個 ref 變數。我們應該把這個依賴記錄下來。

假如是自己實現,可以這麼寫:

const a = {
    // 當 a 被訪問時,可以將副作用函式儲存在 a 物件的 dependency 屬性中,實際上 @vue/reactivity 會稍微複雜一點 
 get value(){
        const fn = // 假設有辦法拿到 effect 的副作用函式
        // fn 就是以下這個函式
        // () => {
        //    calls++
        //    dummy = a.value
        // })
        a.dependence = fn
    }
    // 當 a.value 被修改時,可以這麼觸發更新
    set value(){
        this.dependence()
    }
}
複製程式碼

這樣就可以做到, 當 ref 被獲取時,收集依賴(即將副作用函式儲存起來);當 ref 被修改時,觸發更新(即呼叫副作用函式)

當然這個實現非常簡單,實際上還要考慮很多情況,例如:

  • 一個副作用函式,可能依賴多個 ref。如 computed,就可能依賴多個 ref,才能算出最終的值,因此依賴是一組的副作用函式。

  • 不是任何時候都收集依賴。僅僅在 effect 包裹的時候,才收集依賴

  • 一開始依賴 a 這個 ref 的,但後來不依賴了

  • ……

這些情況都是我們沒有考慮進去的,那麼,接下來,我們就看看真正的 ref 的實現

概念約定

在講解原始碼前,我們這裡先對一些概念進行約定:

  • 副作用物件:在接下來的原始碼解析中,特指 effect 函式內部建立的一個物件,型別為 ReactiveEffect(先記住有這麼名字即可)。 被收集依賴 的實際物件。先介紹這麼多,後面還會有詳細介紹

  • 副作用函式:在接下來的原始碼解析中,特指傳入 effect 的函式,也是被觸發再次執行的函式。

effect(() => {
    calls++
    dummy = a.value
})
複製程式碼
  • 響應式變數:ref、reactive、computed 等函式返回的變數。

  • track:收集依賴

  • trigger:觸發更新

  • 副作用物件依賴響應式變數。如:ReactiveEffect 依賴某個 ref

  • 響應式變數,擁有多個依賴,依賴的值副作用物件。如:某個 ref 擁有(收集到) n 個 ReactiveEffect 依賴

image-20211231112331231

ref 原始碼解析

通過 ref 的實現,看依賴是什麼,是怎麼被收集的

ref 物件的實現

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

// shallowRef,只是將 createRef 的第二個引數 shallow,標記為 true
export function shallowRef(value?: unknown) {
  return createRef(value, true)
}

function createRef(rawValue: unknown, shallow = false) {
  // 如果已經是ref,則直接返回
  if (isRef(rawValue)) {
    return rawValue
  }
  return new RefImpl(rawValue, shallow)
}
複製程式碼

ref 和 shallowRef, 本質都是 RefImpl 物件例項,只是 shallow 屬性不同

為了便於理解,我們可以只關注 ref 的實現,即預設 shallow === false

接下來,我們看看 RefImpl 是什麼

class RefImpl<T> {
  private _value: T
  private _rawValue: T

  // 用於儲存依賴的副作用函式
  public dep?: Dep = undefined
  public readonly __v_isRef = true

  constructor(value: T, public readonly _shallow = false) {
    // 儲存原始 value 到 _rawValue
    this._rawValue = _shallow ? value : toRaw(value)
    // convert函式的作用是,如果 value 是物件,則使用 reactive(value) 處理,否則返回value
    // 因此,將一個物件傳入 ref,實際上也是呼叫了 reactive
    this._value = _shallow ? value : convert(value)
  }

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

  set value(newVal) {
    newVal = this._shallow ? newVal : toRaw(newVal)
    // 如果值改變,才會觸發依賴
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal
      this._value = this._shallow ? newVal : convert(newVal)
      // 觸發依賴
      triggerRefValue(this, newVal)
    }
  }
}
複製程式碼

在 RefImpl 物件中

  • getter 獲取 value 屬性時,trace 收集依賴

  • setter 設定 value 屬性時,trigger 觸發依賴

因此,只有訪問/修改 ref 的 value 屬性,才會收集/觸發依賴

依賴是怎麼被收集的

export function trackRefValue(ref: RefBase<any>) {
  // 判斷是否需要收集依賴
  if (isTracking()) {
    ref = toRaw(ref)
    // 如果沒有 dep 屬性,則初始化 dep,dep 是一個 Set<ReactiveEffect>,儲存副作用函式
    if (!ref.dep) {
      ref.dep = createDep()
    }
    // 收集 effect 依賴
    trackEffects(ref.dep)
  }
}

// 判斷是否需要收集依賴
export function isTracking() {
  // shouldTrack 是一個全域性變數,代表當前是否需要 track 收集依賴
  // activeEffect 也是個全域性變數,代表當前的副作用物件 ReactiveEffect
  return shouldTrack && activeEffect !== undefined
}
複製程式碼

為什麼需要使用 isTracking,來判斷是否收集依賴?

不是任何情況 ref 被訪問時,都需要收集依賴。例如:

  • 沒有被 effect 包裹時,由於沒有副作用函式(即沒有依賴,activeEffect === undefined), 不應該收集依賴

  • 某些特殊情況,即使包裹在 effect,也不應該收集依賴(即 shouldTrack === false)。如:元件生命週期執行、元件 setup 執行

ref.dep 有什麼作用?

ref.dep 的型別是 Set<ReactiveEffect> ,關於 ReactiveEffect 的細節會在後面詳細闡述

ref.dep 用於儲存副作用物件,這些副作用物件,依賴該 ref,ref 被修改時就會觸發

我們再來看看 trackEffects:

// 代表當前的副作用 effect
let activeEffect: ReactiveEffect | undefined

export function trackEffects(
  dep: Dep
) {
  // 這個是區域性變數的 shouldTrack,跟上一部分的全域性 shouldTrack 不一樣
  let shouldTrack = false
  // 已經 track 收集過依賴,就可以跳過了
  shouldTrack = !dep.has(activeEffect!)

  if (shouldTrack) {
    // 收集依賴,將 effect 儲存到 dep
    dep.add(activeEffect!)
    // 同時 effect 也記錄一下 dep
    // 用於 trigger 觸發 effect 後,刪除 dep 裡面對應的 effect,即 dep.delete(activeEffect)
    activeEffect!.deps.push(dep)
  }
}
複製程式碼

收集依賴,就是把 activeEffect (當前的副作用物件), 儲存到 ref.dep 中 (當觸發依賴時,遍歷 ref.dep 執行 effect )

然後把 ref.dep,也儲存到 effect.deps 中 (用於在觸發依賴後, ref.dep.delete(effect),雙向刪除依賴)

image-20211230205303018

依賴是怎麼被觸發的

看完 track 收集依賴,那看看依賴是怎麼被觸發的

export function triggerRefValue(ref: RefBase<any>, newVal?: any) {
  // ref 可能是 reactive 物件的某個屬性的值
  // 這時候在 triggerRefValue(this, newVal) 時取 this,拿到的是一個 reactive 物件
  // 需要獲取 Proxy 代理背後的真實值 ref 物件
  ref = toRaw(ref)
  // 有依賴才觸發 effect
  if (ref.dep) {
     triggerEffects(ref.dep)
  }
}
複製程式碼

再來看看 triggerEffects

export function triggerEffects(
  dep: Dep | ReactiveEffect[]
) {
  // 迴圈遍歷 dep,去取每個依賴的副作用物件 ReactiveEffect
  for (const effect of isArray(dep) ? dep : [...dep]) {
    // 預設不允許遞迴,即當前 effect 副作用函式,如果遞迴觸發當前 effect,會被忽略
    if (effect !== activeEffect || effect.allowRecurse) {
      // effect.scheduler可以先不管,ref 和 reactive 都沒有
      if (effect.scheduler) {
        effect.scheduler()
      } else {
        // 執行 effect 的副作用函式
        effect.run()
      }
    }
  }
}
複製程式碼

這裡省略了一些程式碼,這樣結構更清晰。

當 ref 被修改時,會 trigger 觸發依賴,即執行了 ref.dep 裡的所有副作用函式(effect.run 執行副作用函式)

為什麼預設不允許遞迴?

const foo = ref([])
effect(()=>{
    foo.value.push(1)
})
複製程式碼

在這個副作用函式中,即會使用到 foo.value(getter 收集依賴),又會修改 foo 陣列(觸發依賴)。如果允許遞迴,會無限迴圈。

至此,ref 依賴收集和觸發的邏輯,已經比較清晰了。

那麼,接下來,我們需要進一步瞭解的是,effect 函式、ReactiveEffect 副作用物件、副作用函式,它們是什麼,它們之間有什麼關係?

effect 函式

我們來看一下 effect 的實現

// 傳入一個 fn 函式
export function effect<T = any>(
  fn: () => T
){
  // 引數 fn,可能也是一個 effect,所以要獲取到最初始的 fn 引數
  if ((fn as ReactiveEffectRunner).effect) {
    fn = (fn as ReactiveEffectRunner).effect.fn
  }

  // 建立 ReactiveEffect 物件
  const _effect = new ReactiveEffect(fn)
  _effect.run()
  
  const runner = _effect.run.bind(_effect)
  runner.effect = _effect
  return runner
}
複製程式碼

effect 函式接受一個函式作為引數,該函式,我們稱之為 副作用函式

effect 函式內部,會建立 ReactiveEffect 物件,我們稱之為 副作用物件

effect 函式,返回一個 runner,是一個函式,直接呼叫就是呼叫副作用函式;runner 的屬性 effect,儲存著它對應的 ReactiveEffect 物件 。

因此,它們的關係如下:

effect 函式的入參為副作用函式,在 effect 函式內部會建立副作用物件

我們繼續深入看看 ReactiveEffect 物件的實現

ReactiveEffect 副作用物件

該部分(effect.run 函式)程式碼有比較大的刪減,點選 檢視未刪減的原始碼 [4]

為什麼要刪減這部分程式碼?

在 vue 3.2 版本以後,effect.run 做了優化,提升效能,其中涉及到位運算。

優化方案在極端的情況下(effect 非常多次巢狀),會降級到原來的老方案(優化前,3.2版本前的方案)

因此, 為了便於理解,我這裡先介紹優化前的方案 ,深入瞭解,並闡述該方案的缺點, 以便更好地理解為什麼需要進行優化。

刪減部分為優化後的方案,這部分的方案會在下一小節進行介紹。

下面是 ReactiveEffect 程式碼解析:

// 全域性公用的 effect 棧,由於可以 effect 巢狀,因此需要用棧儲存 ReactiveEffect 副作用物件
const effectStack: ReactiveEffect[] = []
export class ReactiveEffect<T = any> {
  active = true
    
  // 儲存 Dep 物件,如上一小節的 ref.dep
  deps: Dep[] = []

  constructor(
    public fn: () => T,
    public scheduler: EffectScheduler | null = null,
    scope?: EffectScope | null
  ) {
    // 可以暫時不看,與 effectScope API 相關 https://v3.cn.vuejs.org/api/effect-scope.html#effectscope
    // 將當前 ReactiveEffect 副作用物件,記錄到 effectScope 中
    // 當 effectScope.stop() 被呼叫時,所有的 ReactiveEffect 物件都會被 stop
    recordEffectScope(this, scope)
  }

  run() {
    // 如果當前 ReactiveEffect 副作用物件,已經在棧裡了,就不需要再處理了
    if (!effectStack.includes(this)) {
      try {
        // 儲存上一個的 activeEffect,因為 effect 可以巢狀
        effectStack.push((activeEffect = this))
        // 開啟 shouldTrack 開關,快取上一個值
        enableTracking()

        // 在該 effect 所在的所有 dep 中,清除 effect,下面會詳細闡述
        cleanupEffect(this)
          
        // 執行副作用函式,執行過程中,又會 track 當前的 effect 進來,依賴重新被收集
        return this.fn()
      } finally {
        // 關閉shouldTrack開關,恢復上一個值
        resetTracking()
        // 恢復上一個的 activeEffect
        effectStack.pop()
        const n = effectStack.length
        activeEffect = n > 0 ? effectStack[n - 1] : undefined
      }
    }
  }
}

// 允許 track
export function enableTracking() {
  // trackStack 是個全域性的棧,由於 effect 可以巢狀,所以是否 track 的標記,也需要用棧儲存
  trackStack.push(shouldTrack)
  // 開啟全域性 shouldTrack 開關
  shouldTrack = true
}

// 重置上一個 track 狀態
export function resetTracking() {
  const last = trackStack.pop()
  // 恢復上一個 track 狀態
  shouldTrack = last === undefined ? true : last
}
複製程式碼

為什麼要用棧儲存 effect 和 track 狀態?

因為effect可能會巢狀,需要儲存之前的狀態,effect執行完成後恢復

cleanupEffect 做了什麼?

回顧下圖:

image-20220102234627353

effect.deps,也儲存著響應式變數的 dep(dep 是一個依賴集合, ReactiveEffect 物件的集合),目的是 在effect 執行後,在所有的 dep 中刪除當前執行過的 effect,雙向刪除

刪除程式碼如下:

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

刪除的 ReactiveEffect 如何被重新收集?

在 cleanupEffect 中,在各個 dep 中,刪除該 ReactiveEffect 物件。

在執行 this.fn() 時, 執行副作用函式 ,副作用函式的執行中,當使用到響應式變數(如 ref.value)時,又會 trackEffect, 重新收集依賴

為什麼要先刪除,再重新收集依賴?

因為 執行前後的依賴可能不一致 ,考慮一下情況:

const switch = ref(true)
const foo = ref('foo')
effect( () = {
  if(switch.value){
    console.log(foo.value)
  }else{
    console.log('else condition')
  }
})
switch.value = false
複製程式碼

當 switch 為 true 時,triggerEffect,雙向刪除後,執行副作用函式,switch、foo 會重新收集到依賴 effect

當 switch 變成 false 後,triggerEffect,雙向刪除後,執行副作用函式,僅有 switch 能重新收集到依賴 effect

image-20211231110604009

由於 effect 副作用函式執行前後,依賴的響應式變數(這裡是 ref )可能不一致,因此 vue 會 先刪除全部依賴,再重新收集

細心的你,可能會發現:自己寫 vue 程式碼時, 很少會出現前後依賴不一致的情況 。那既然這樣,刪除全部依賴這個實現就有優化的空間, 能不能只刪除失效的依賴呢

依賴更新演算法優化

該優化是 vue 3.2 版本引入的,原因即上一小節所說的,可以 只刪除失效的依賴 。並且在極端的巢狀深度下,能夠 降級到 cleanupEffect 方法 ,對所有依賴進行刪除。

先想想,假如是自己實現,要怎麼寫好呢?

  1. 不使用 cleanupEffect 刪除所有依賴

  2. 執行副作用函式前,給 ReactiveEffect 依賴的 響應式變數 ,加上 was 的標記(was 是 vue 給的名稱,過去的意思)

  3. 執行 this.fn()track 重新收集依賴時 ,給 ReactiveEffect 的每個依賴, 加上 new 的標記
  4. 最後,對失效(有 was 但是沒有 new)依賴進行刪除

為什麼是標記在響應式物件,而不是 ReactiveEffect ?

再回顧一下響應式變數和 ReactiveEffect 的關係:

image-20211231112331231

ReactiveEffect 依賴響應式變數(ref),響應式變數(ref)擁有多個 ReactiveEffect 依賴。

只刪除失效的依賴。就要 確定哪些依賴(響應式變數)需要被刪除 (實際上是響應式變數的 dep 被刪除)

因此,需要在響應式變數上做標記,對已經不依賴的響應式變數,將它們的 dep,從 ReactiveEffect.deps 中刪除

如何給響應式變數做標記

實現如下:

export const initDepMarkers = ({ deps }: ReactiveEffect) => {
  if (deps.length) {
    // 迴圈 deps,對每個 dep 進行標記
    for (let i = 0; i < deps.length; i++) {
      // 標記 dep 為 was,w 是 was 的意思
      deps[i].w |= trackOpBit
    }
  }
}
複製程式碼

這部分程式碼其實比較難理解,尤其是使用了位運算子,如果一開始就解析這些程式碼的話,很容易就勸退了。

下面我們對問題進行分析:

為什麼這裡標記的是 dep?

這裡的 dep,對於 ref,就是 ref.dep,它是一個 Set<ReactiveEffect>

dep 跟 ref 的關係是一一對應的,一個 ref僅僅有一個 dep,因此, 標記在 dep 和 標記在 ref,是等價的

那為什麼不在響應式變數上標記呢?

因為響應式變數的型別有幾種:ref、computed、reactive,它們 都使用 dep 物件儲存依賴 ,對它們都有的 dep 物件進行標記,可以將標記程式碼更好的進行 複用 (否則要判斷不同的型別,執行不同的標記邏輯)。

如果未來新增一種響應式變數,只需要也是用 dep 進行儲存依賴即可

這個按位與位運算的作用是什麼?

先來看看 dep 的真實結構,它其實還有兩個屬性 w 和 n:

export type Dep = Set<ReactiveEffect> & TrackedMarkers
type TrackedMarkers = {
  /**
   * wasTracked,代表副作用函式執行前被 track 過
   */
  w: number
  /**
   * newTracked,代表副作用函式執行後被 track
   */
  n: number
}
複製程式碼

那這個 w 和 n 是怎麼做標記的?我們先來看看位運算做了什麼,不瞭解位運算的同學 ,可以先看看 這裡的介紹 [5]

dep.w |= trackOpBit // 即 dep.w = dep.w | trackOpBit
複製程式碼
image-20220103205852303

將響應式變數標記,就是將對應整數的二進位制位,設定成 1

dep.n 的標記方法也是如此。

為什麼要使用位運算?

  1. 位運算速度快

  2. 只需要使用一個 number 型別的資料,就能儲存不同深度的標記(was / new)

如果不使用位運算,需要實現同樣的標記能力,需要用陣列儲存不同深度的標記,資料結構如下:

export type Dep = Set<ReactiveEffect> & TrackedMarkers
type TrackedMarkers = {
  /**
   * wasTrackedList,代表副作用函式執行前被 track 過
   * 設計為陣列,是因為 effect 可以巢狀,代表響應式變數在所在的 effect 深度(巢狀層級)中是否被 track
   */
  wasTrackedList: boolean[]
  /**
   * newTracked,代表副作用函式執行後被 track
   * 設計為陣列,是因為 effect 可以巢狀,代表響應式變數在所在的 effect 深度(巢狀層級)中是否被 track
   */
  newTrackedList: boolean[]
}
複製程式碼

使用陣列儲存標記位,修改處理沒有直接位運算快。由於 vue 每次執行副作用函式(一個頁面有非常多的副作用函式),都需要頻繁進行標記,這開銷也是非常大的。因此,這裡使用了運算子, 提升了標記的速度,也節省了執行記憶體

trackOpBit 是什麼?

trackOpBit 是代表當前操作的位,它是由 effect 巢狀深度決定的。

// 全域性變數巢狀深度一開始為 0 
effectTrackDepth = 0

// 每次執行 effect 副作用函式前,全域性變數巢狀深度會自增 1,執行完成 effect 副作用函式後會自減
trackOpBit = 1 << ++effectTrackDepth
複製程式碼

當深度為 1 時,trackOpBit 是 2(二進位制:00000010),操作的是第二位,將 dep.w 的第二位變成 1

因此如圖所說,dep.w 的第一位是不使用的

為什麼最大標記巢狀深度為 30?

從圖中我們可以看到, 深度受儲存型別的位數限制,否則就會溢位

在JavaScript內部,數值都是以64位浮點數的形式儲存,但是做位運算的時候,是以 32位帶符號的整數進行運算的 ,並且 返回值也是一個32位帶符號的整數

1 << 30
// 1073741824
1 << 31
// -2147483648,溢位
複製程式碼

因此,深度最大為 30,超過 30,則需要降級方案,使用全部清除再全部重新收集依賴的方案

判斷響應式變數是否被標記

export const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0

export const newTracked = (dep: Dep): boolean => (dep.n & trackOpBit) > 0
複製程式碼

使用 wasTrackednewTracked 判斷 dep 是否在當前深度被標記

trackOpBit 是一個全域性變數,根據當前深度生成的

image-20220103210036377

如圖,如果需要判斷深度為 2 時(trackOpBit 第 3 位為 1),是否被標記,僅當 dep.w 的第 3 位為 1 時, wasTrackednewTracked 才會返回 true

vue 通過這樣巧妙的位運算,快速算出依賴在當前深度是否被標記

副作用物件的優化實現

// 當前 effect 的巢狀深度,每次執行會 ++effectTrackDepth
let effectTrackDepth = 0
// 最大的 effect 巢狀層數為 30
const maxMarkerBits = 30      
// 位運算操作的第 trackOpBit 位
export let trackOpBit = 1
export class ReactiveEffect<T = any> {
  run() {
    if (!effectStack.includes(this)) {
      try {
        // 省略程式碼: 儲存上一個 activeEffect
        
        // trackOpBit: 根據深度生成 trackOpBit
        trackOpBit = 1 << ++effectTrackDepth

        // maxMarkerBits: 可支援的最大巢狀深度,為 30
        // 這裡就是之前說到的,正常情況下使用優化方案,極端巢狀場景下,使用降級方案
        if (effectTrackDepth <= maxMarkerBits) {
          // 標記所有的 dep 為 was
          initDepMarkers(this)
        } else {
          // 降級方案,刪除所有的依賴,再重新收集
          cleanupEffect(this)
        }
         // 執行過程中標記新的 dep 為 new
        return this.fn()
      } finally {
        if (effectTrackDepth <= maxMarkerBits) {
          // 對失效依賴進行刪除
          finalizeDepMarkers(this)
        }
  // 恢復上一次的狀態
        // 巢狀深度 effectTrackDepth 自減
        // 重置操作的位數
        trackOpBit = 1 << --effectTrackDepth

        // 省略程式碼: 恢復上一個 activeEffect
      }
    }
  }
}
複製程式碼

整體的思路如下:

  • 如果當前深度不超過 30,使用優化方案

  1. 執行副作用函式前,給 ReactiveEffect 依賴的 響應式變數 ,加上 was 的標記(was 是 vue 給的名稱,表示過去依賴)

  2. 執行 this.fn()track 重新收集依賴時 ,給 ReactiveEffect 的每個依賴, 加上 new 的標記
  3. 對失效依賴進行刪除(有 was 但是沒有 new)

  4. 恢復上一個深度的狀態

  • 如果深度超過 30 , 超過部分,使用降級方案

    1. 雙向刪除ReactiveEffect 副作用物件的 所有依賴 (effect.deps.length = 0)

    2. 執行 this.fn()track 重新收集依賴時
    3. 恢復上一個深度的狀態

    標記 ReactiveEffect 的所有的 dep 為 was 的實現:

    export const initDepMarkers = ({ deps }: ReactiveEffect) => {
      if (deps.length) {
        for (let i = 0; i < deps.length; i++) {
          deps[i].w |= trackOpBit // 遍歷每個 dep 標記為 was
        }
      }
    }
    複製程式碼
    

    對失效依賴進行刪除的實現如下(有 was 但是沒有 new):

    export const finalizeDepMarkers = (effect: ReactiveEffect) => {
      const { deps } = effect
      if (deps.length) {
        let ptr = 0
        for (let i = 0; i < deps.length; i++) {
          const dep = deps[i]
          //有 was 標記但是沒有 new 標記,應當刪除
          if (wasTracked(dep) && !newTracked(dep)) {
            dep.delete(effect)
          } else {
            // 需要保留的依賴,放到資料的較前位置,因為在最後會刪除較後位置的所有依賴
            deps[ptr++] = dep
          }
          // 清理 was 和 new 標記,將它們對應深度的 bit,置為 0
          dep.w &= ~trackOpBit
          dep.n &= ~trackOpBit
        }
        // 刪除依賴,只保留需要的
        deps.length = ptr
      }
    }
    複製程式碼
    

    參考文章

    • vue 官方文件 [6]

    • vue-next 原始碼 [7]

    最後

    如果這篇文章對您有所幫助,請幫忙點個贊:+1:,您的鼓勵是我創作路上的最大的動力。

    關於本文

    作者:candyTong

    https://juejin.cn/po st/7048970987500470279

    The End

    如果你覺得這篇內容對你挺有啟發,我想請你幫我三個小忙:

    1、點個  「在看」 ,讓更多的人也能看到這篇內容

    2、關注官網  https://muyiy.cn ,讓我們成為長期關係

    3、關注公眾號「高階前端進階」,公眾號後臺回覆  「加群」 ,加入我們一起學習並送你精心整理的高階前端面試題。

    》》面試官都在用的題庫,快來看看《《

    “在看”嗎?在看就點一下吧