細說 Vue-js 3-2 關於響應式部分的優化

背景

Vue 3 正式發佈距今已經快一年了,相信很多小夥伴已經在生產環境用上了 Vue 3 了。如今,Vue.js 3.2 已經正式發佈,而這次 minor 版本的升級主要體現在源碼層級的優化,對於用戶的使用層面來說其實變化並不大。其中一個吸引我的點是提升了響應式的性能:

  • More efficient ref implementation (~260% faster read / ~50% faster write)

  • ~40% faster dependency tracking

  • ~17% less memory usage

翻譯過來就是 ref API 的讀效率提升約爲 260%,寫效率提升約爲 50% ,依賴收集的效率提升約爲 40%,同時還減少了約 17% 的內存使用。

這簡直就是一個吊炸天的優化啊,因爲要知道響應式系統是 Vue.js 的核心實現之一,對它的優化就意味着對所有使用 Vue.js 開發的 App 的性能優化。

而且這個優化並不是 Vue 官方人員實現的,而是社區一位大佬 @basvanmeurs 提出的,相關的優化代碼在 2020 年 10 月 9 號就已經提交了,但由於對內部的實現改動較大,官方一直等到了 Vue.js 3.2 發佈,才把代碼合入。

這次 basvanmeurs 提出的響應式性能優化真的讓尤大喜出望外,不僅僅是大大提升了 Vue 3 的運行時性能,還因爲這麼核心的代碼能來自社區的貢獻,這就意味着 Vue 3 受到越來越多的人關注;一些能力強的開發人員參與到核心代碼的貢獻,可以讓 Vue 3 走的更遠更好。

我們知道,相比於 Vue 2,Vue 3 做了多方面的優化,其中一部分是數據響應式的實現由 Object.defineProperty API 改成了 Proxy API。

當初 Vue 3 在宣傳的時候,官方宣稱在響應式實現的性能上做了優化,那麼優化體現在哪些方面呢?有部分小夥伴認爲是 Proxy API 的性能要優於 Object.defineProperty 的,其實不然,實際上 Proxy 在性能上是要比 Object.defineProperty 差的,詳情可以參考 Thoughts on ES6 Proxies Performance 這篇文章,而我也對此做了測試,結論同上,可以參考這個 repo。

既然 Proxy 慢,爲啥 Vue 3 還是選擇了它來實現數據響應式呢?因爲 Proxy 本質上是對某個對象的劫持,這樣它不僅僅可以監聽對象某個屬性值的變化,還可以監聽對象屬性的新增和刪除;而 Object.defineProperty 是給對象的某個已存在的屬性添加對應的 gettersetter,所以它只能監聽這個屬性值的變化,而不能去監聽對象屬性的新增和刪除。

而響應式在性能方面的優化其實是體現在把嵌套層級較深的對象變成響應式的場景。在 Vue 2 的實現中,在組件初始化階段把數據變成響應式時,遇到子屬性仍然是對象的情況,會遞歸執行 Object.defineProperty 定義子對象的響應式;而在 Vue 3 的實現中,只有在對象屬性被訪問的時候纔會判斷子屬性的類型來決定要不要遞歸執行 reactive,這其實是一種延時定義子對象響應式的實現,在性能上會有一定的提升。

因此,相比於 Vue 2,Vue 3 確實在響應式實現部分做了一定的優化,但實際上效果是有限的。而 Vue.js 3.2 這次在響應式性能方面的優化,是真的做到了質的飛躍,接下來我們就來上點硬菜,從源碼層面分析具體做了哪些優化,以及這些優化背後帶來的技術層面的思考。

響應式實現原理

所謂響應式,就是當我們修改數據後,可以自動做某些事情;對應到組件的渲染,就是修改數據後,能自動觸發組件的重新渲染。

Vue 3 實現響應式,本質上是通過 Proxy API 劫持了數據對象的讀寫,當我們訪問數據時,會觸發 getter 執行依賴收集;修改數據時,會觸發 setter 派發通知。

接下來,我們簡單分析一下依賴收集和派發通知的實現(Vue.js 3.2 之前的版本)。

依賴收集

首先來看依賴收集的過程,核心就是在訪問響應式數據的時候,觸發 getter 函數,進而執行 track 函數收集依賴:

let shouldTrack = true
// 當前激活的 effect
let activeEffect
// 原始數據對象 map
const targetMap = new WeakMap()
function track(target, type, key) {
  if (!shouldTrack || activeEffect === undefined) {
    return
  }
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    // 每個 target 對應一個 depsMap
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key)
  if (!dep) {
    // 每個 key 對應一個 dep 集合
    depsMap.set(key, (dep = new Set()))
  }
  if (!dep.has(activeEffect)) {
    // 收集當前激活的 effect 作爲依賴
    dep.add(activeEffect)
   // 當前激活的 effect 收集 dep 集合作爲依賴
    activeEffect.deps.push(dep)
  }
}

分析這個函數的實現前,我們先想一下要收集的依賴是什麼,我們的目的是實現響應式,就是當數據變化的時候可以自動做一些事情,比如執行某些函數,所以我們收集的依賴就是數據變化後執行的副作用函數。

track 函數擁有三個參數,其中 target 表示原始數據;type 表示這次依賴收集的類型;key 表示訪問的屬性。

track 函數外部創建了全局的 targetMap 作爲原始數據對象的 Map,它的鍵是 target,值是 depsMap,作爲依賴的 Map;這個 depsMap 的鍵是 targetkey,值是 dep 集合,dep 集合中存儲的是依賴的副作用函數。爲了方便理解,可以通過下圖表示它們之間的關係:

因此每次執行 track 函數,就是把當前激活的副作用函數 activeEffect 作爲依賴,然後收集到 target 相關的 depsMap 對應 key 下的依賴集合 dep 中。

派發通知

派發通知發生在數據更新的階段,核心就是在修改響應式數據時,觸發 setter 函數,進而執行 trigger 函數派發通知:

const targetMap = new WeakMap()
function trigger(target, type, key) {
  // 通過 targetMap 拿到 target 對應的依賴集合
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    // 沒有依賴,直接返回
    return
  }
  // 創建運行的 effects 集合
  const effects = new Set()
  // 添加 effects 的函數
  const add = (effectsToAdd) ={
    if (effectsToAdd) {
      effectsToAdd.forEach(effect ={
        effects.add(effect)
      })
    }
  }
  // SET | ADD | DELETE 操作之一,添加對應的 effects
  if (key !== void 0) {
    add(depsMap.get(key))
  }
  const run = (effect) ={
    // 調度執行
    if (effect.options.scheduler) {
      effect.options.scheduler(effect)
    }
    else {
      // 直接運行
      effect()
    }
  }
  // 遍歷執行 effects
  effects.forEach(run)
}

trigger 函數擁有三個參數,其中 target 表示目標原始對象;type 表示更新的類型;key 表示要修改的屬性。

trigger 函數 主要做了四件事情:

  1. targetMap 中拿到 target 對應的依賴集合 depsMap

  2. 創建運行的 effects 集合;

  3. 根據 keydepsMap 中找到對應的 effect 添加到 effects 集合;

  4. 遍歷 effects 執行相關的副作用函數。

因此每次執行 trigger 函數,就是根據 targetkey,從 targetMap 中找到相關的所有副作用函數遍歷執行一遍。

在描述依賴收集和派發通知的過程中,我們都提到了一個詞:副作用函數,依賴收集過程中我們把 activeEffect(當前激活副作用函數)作爲依賴收集,它又是什麼?接下來我們來看一下副作用函數的廬山真面目。

副作用函數

那麼,什麼是副作用函數,在介紹它之前,我們先回顧一下響應式的原始需求,即我們修改了數據就能自動做某些事情,舉個簡單的例子:

import { reactive } from 'vue'
const counter = reactive({
  num: 0
})
function logCount() {
  console.log(counter.num)
}
function count() {
  counter.num++
}
logCount()
count()

我們定義了響應式對象 counter,然後在 logCount 中訪問了 counter.num,我們希望在執行 count 函數修改 counter.num 值的時候,能自動執行 logCount 函數。

按我們之前對依賴收集過程的分析,如果logCountactiveEffect 的話,那麼就可以實現需求,但顯然是做不到的,因爲代碼在執行到 console.log(counter.num) 這一行的時候,它對自己在 logCount 函數中的運行是一無所知的。

那麼該怎麼辦呢?其實只要我們運行 logCount 函數前,把 logCount 賦值給 activeEffect 就好了:

activeEffect = logCount 
logCount()

順着這個思路,我們可以利用高階函數的思想,對 logCount 做一層封裝:

function wrapper(fn) {
  const wrapped = function(...args) {
    activeEffect = fn
    fn(...args)
  }
  return wrapped
}
const wrappedLog = wrapper(logCount)
wrappedLog()

wrapper 本身也是一個函數,它接受 fn 作爲參數,返回一個新的函數 wrapped,然後維護一個全局變量 activeEffect,當 wrapped 執行的時候,把 activeEffect 設置爲 fn,然後執行 fn 即可。

這樣當我們執行 wrappedLog 後,再去修改 counter.num,就會自動執行 logCount 函數了。

實際上 Vue 3 就是採用類似的做法,在它內部就有一個 effect 副作用函數,我們來看一下它的實現:

// 全局 effect 棧
const effectStack = []
// 當前激活的 effect
let activeEffect
function effect(fn, options = EMPTY_OBJ) {
  if (isEffect(fn)) {
    // 如果 fn 已經是一個 effect 函數了,則指向原始函數
    fn = fn.raw
  }
  // 創建一個 wrapper,它是一個響應式的副作用的函數
  const effect = createReactiveEffect(fn, options)
  if (!options.lazy) {
    // lazy 配置,計算屬性會用到,非 lazy 則直接執行一次
    effect()
  }
  return effect
}
function createReactiveEffect(fn, options) {
  const effect = function reactiveEffect() {
    if (!effect.active) {
      // 非激活狀態,則判斷如果非調度執行,則直接執行原始函數。
      return options.scheduler ? undefined : fn()
    }
    if (!effectStack.includes(effect)) {
      // 清空 effect 引用的依賴
      cleanup(effect)
      try {
        // 開啓全局 shouldTrack,允許依賴收集
        enableTracking()
        // 壓棧
        effectStack.push(effect)
        activeEffect = effect
        // 執行原始函數
        return fn()
      }
      finally {
        // 出棧
        effectStack.pop()
        // 恢復 shouldTrack 開啓之前的狀態
        resetTracking()
        // 指向棧最後一個 effect
        activeEffect = effectStack[effectStack.length - 1]
      }
    }
  }
  effect.id = uid++
  // 標識是一個 effect 函數
  effect._isEffect = true
  // effect 自身的狀態
  effect.active = true
  // 包裝的原始函數
  effect.raw = fn
  // effect 對應的依賴,雙向指針,依賴包含對 effect 的引用,effect 也包含對依賴的引用
  effect.deps = []
  // effect 的相關配置
  effect.options = options
  return effect
}

結合上述代碼來看,effect 內部通過執行 createReactiveEffect 函數去創建一個新的 effect 函數,爲了和外部的 effect 函數區分,我們把它稱作 reactiveEffect 函數,並且還給它添加了一些額外屬性(我在註釋中都有標明)。另外,effect 函數還支持傳入一個配置參數以支持更多的 feature,這裏就不展開了。

reactiveEffect 函數就是響應式的副作用函數,當執行 trigger 過程派發通知的時候,執行的 effect 就是它。

按我們之前的分析,reactiveEffect 函數只需要做兩件事情:讓全局的 activeEffect 指向它, 然後執行被包裝的原始函數 fn

但實際上它的實現要更復雜一些,首先它會判斷 effect 的狀態是否是 active,這其實是一種控制手段,允許在非 active 狀態且非調度執行情況,則直接執行原始函數 fn 並返回。

接着判斷 effectStack 中是否包含 effect,如果沒有就把 effect 壓入棧內。之前我們提到,只要設置 activeEffect = effect 即可,那麼這裏爲什麼要設計一個棧的結構呢?

其實是考慮到以下這樣一個嵌套 effect 的場景:

import { reactive} from 'vue' 
import { effect } from '@vue/reactivity' 
const counter = reactive({ 
  num: 0, 
  num2: 0 
}) 
function logCount() { 
  effect(logCount2) 
  console.log('num:', counter.num) 
} 
function count() { 
  counter.num++ 
} 
function logCount2() { 
  console.log('num2:', counter.num2) 
} 
effect(logCount) 
count()

我們每次執行 effect 函數時,如果僅僅把 reactiveEffect 函數賦值給 activeEffect,那麼針對這種嵌套場景,執行完 effect(logCount2) 後,activeEffect 還是 effect(logCount2) 返回的 reactiveEffect 函數,這樣後續訪問 counter.num 的時候,依賴收集對應的 activeEffect 就不對了,此時我們外部執行 count 函數修改 counter.num 後執行的便不是 logCount 函數,而是 logCount2 函數,最終輸出的結果如下:

num2: 0 
num: 0 
num2: 0

而我們期望的結果應該如下:

num2: 0 
num: 0 
num2: 0 
num: 1

因此針對嵌套 effect 的場景,我們不能簡單地賦值 activeEffect,應該考慮到函數的執行本身就是一種入棧出棧操作,因此我們也可以設計一個 effectStack,這樣每次進入 reactiveEffect 函數就先把它入棧,然後 activeEffect 指向這個 reactiveEffect 函數,接着在 fn 執行完畢後出棧,再把 activeEffect 指向 effectStack 最後一個元素,也就是外層 effect 函數對應的 reactiveEffect

這裏我們還注意到一個細節,在入棧前會執行 cleanup 函數清空 reactiveEffect 函數對應的依賴 。在執行 track 函數的時候,除了收集當前激活的 effect 作爲依賴,還通過 activeEffect.deps.push(dep)dep 作爲 activeEffect 的依賴,這樣在 cleanup 的時候我們就可以找到 effect 對應的 dep 了,然後把 effect 從這些 dep 中刪除。cleanup 函數的代碼如下所示:

function cleanup(effect) {
  const { deps } = effect
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].delete(effect)
    }
    deps.length = 0
  }
}

爲什麼需要 cleanup 呢?如果遇到這種場景:

<template>
  <div v-if="state.showMsg">
    {{ state.msg }}
  </div>
  <div v-else>
    {{ Math.random()}}
  </div>
  <button @click="toggle">Toggle Msg</button>
  <button @click="switchView">Switch View</button>
</template>
<script>
  import { reactive } from 'vue'

  export default {
    setup() {
      const state = reactive({
        msg: 'Hello World',
        showMsg: true
      })

      function toggle() {
        state.msg = state.msg === 'Hello World' ? 'Hello Vue' : 'Hello World'
      }

      function switchView() {
        state.showMsg = !state.showMsg
      }

      return {
        toggle,
        switchView,
        state
      }
    }
  }
</script>

結合代碼可以知道,這個組件的視圖會根據 showMsg 變量的控制顯示 msg 或者一個隨機數,當我們點擊 Switch View 的按鈕時,就會修改這個變量值。

假設沒有 cleanup,在第一次渲染模板的時候,activeEffect 是組件的副作用渲染函數,因爲模板 render 的時候訪問了 state.msg,所以會執行依賴收集,把副作用渲染函數作爲 state.msg 的依賴,我們把它稱作 render effect。然後我們點擊 Switch View 按鈕,視圖切換爲顯示隨機數,此時我們再點擊 Toggle Msg 按鈕,由於修改了 state.msg 就會派發通知,找到了 render effect 並執行,就又觸發了組件的重新渲染。

但這個行爲實際上並不符合預期,因爲當我們點擊 Switch View 按鈕,視圖切換爲顯示隨機數的時候,也會觸發組件的重新渲染,但這個時候視圖並沒有渲染 state.msg,所以對它的改動並不應該影響組件的重新渲染。

因此在組件的 render effect 執行之前,如果通過 cleanup 清理依賴,我們就可以刪除之前 state.msg 收集的 render effect 依賴。這樣當我們修改 state.msg 時,由於已經沒有依賴了就不會觸發組件的重新渲染,符合預期。

響應式實現的優化

前面分析了響應式實現原理,看上去一切都很 OK,那麼這裏面還有哪些可以值得優化的點呢?

依賴收集的優化

目前每次副作用函數執行,都需要先執行 cleanup 清除依賴,然後在副作用函數執行的過程中重新收集依賴,這個過程牽涉到大量對 Set 集合的添加和刪除操作。在許多場景下,依賴關係是很少改變的,因此這裏存在一定的優化空間。

爲了減少集合的添加刪除操作,我們需要標識每個依賴集合的狀態,比如它是不是新收集的,還是已經被收集過的。

所以這裏需要給集合 dep 添加兩個屬性:

export const createDep = (effects) ={
  const dep = new Set(effects)
  dep.w = 0
  dep.n = 0
  return dep
}

其中 w 表示是否已經被收集,n 表示是否新收集。

然後設計幾個全局變量,effectTrackDepthtrackOpBitmaxMarkerBits

其中 effectTrackDepth 表示遞歸嵌套執行  effect 函數的深度;trackOpBit 用於標識依賴收集的狀態;maxMarkerBits 表示最大標記的位數。

接下來看它們的應用:

function effect(fn, options) {
  if (fn.effect) {
    fn = fn.effect.fn
  }
  // 創建 _effect 實例 
  const _effect = new ReactiveEffect(fn)
  if (options) {
    // 拷貝 options 中的屬性到 _effect 中
    extend(_effect, options)
    if (options.scope)
      // effectScope 相關處理邏輯
      recordEffectScope(_effect, options.scope)
  }
  if (!options || !options.lazy) {
    // 立即執行
    _effect.run()
  }
  // 綁定 run 函數,作爲 effect runner
  const runner = _effect.run.bind(_effect)
  // runner 中保留對 _effect 的引用
  runner.effect = _effect
  return runner
}

class ReactiveEffect {
  constructor(fn, scheduler = null, scope) {
    this.fn = fn
    this.scheduler = scheduler
    this.active = true
    // effect 存儲相關的 deps 依賴
    this.deps = []
    // effectScope 相關處理邏輯
    recordEffectScope(this, scope)
  }
  run() {
    if (!this.active) {
      return this.fn()
    }
    if (!effectStack.includes(this)) {
      try {
        // 壓棧
        effectStack.push((activeEffect = this))
        enableTracking()
        // 根據遞歸的深度記錄位數
        trackOpBit = 1 << ++effectTrackDepth
        // 超過 maxMarkerBits 則 trackOpBit 的計算會超過最大整形的位數,降級爲 cleanupEffect
        if (effectTrackDepth <= maxMarkerBits) {
          // 給依賴打標記
          initDepMarkers(this)
        }
        else {
          cleanupEffect(this)
        }
        return this.fn()
      }
      finally {
        if (effectTrackDepth <= maxMarkerBits) {
          // 完成依賴標記
          finalizeDepMarkers(this)
        }
        // 恢復到上一級
        trackOpBit = 1 << --effectTrackDepth
        resetTracking()
        // 出棧
        effectStack.pop()
        const n = effectStack.length
        // 指向棧最後一個 effect
        activeEffect = n > 0 ? effectStack[n - 1] : undefined
      }
    }
  }
  stop() {
    if (this.active) {
      cleanupEffect(this)
      if (this.onStop) {
        this.onStop()
      }
      this.active = false
    }
  }
}

可以看到,effect 函數的實現做了一定的修改和調整,內部使用 ReactiveEffect 類創建了一個 _effect 實例,並且函數返回的 runner 指向的是 ReactiveEffect 類的 run 方法。

也就是執行副作用函數 effect 函數時,實際上執行的就是這個 run 函數。

run 函數執行的時候,我們注意到 cleanup 函數不再默認執行,在封裝的函數 fn 執行前,首先執行 trackOpBit = 1 << ++effectTrackDepth 記錄 trackOpBit,然後對比遞歸深度是否超過了 maxMarkerBits,如果超過(通常情況下不會)則仍然執行老的 cleanup 邏輯,如果沒超過則執行 initDepMarkers 給依賴打標記,來看它的實現:

const initDepMarkers = ({ deps }) ={
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].w |= trackOpBit // 標記依賴已經被收集
    }
  }
}

initDepMarkers 函數實現很簡單,遍歷 _effect 實例中的 deps 屬性,給每個 depw 屬性標記爲 trackOpBit 的值。

接下來會執行 fn 函數,在就是副作用函數封裝的函數,比如針對組件渲染,fn 就是組件渲染函數。

fn 函數執行時候,會訪問到響應式數據,就會觸發它們的 getter,進而執行 track 函數執行依賴收集。相應的,依賴收集的過程也做了一些調整:

function track(target, type, key) {
  if (!isTracking()) {
    return
  }
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    // 每個 target 對應一個 depsMap
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key)
  if (!dep) {
    // 每個 key 對應一個 dep 集合
    depsMap.set(key, (dep = createDep()))
  }
  const eventInfo = (process.env.NODE_ENV !== 'production')
    ? { effect: activeEffect, target, type, key }
    : undefined
  trackEffects(dep, eventInfo)
}

function trackEffects(dep, debuggerEventExtraInfo) {
  let shouldTrack = false
  if (effectTrackDepth <= maxMarkerBits) {
    if (!newTracked(dep)) {
      // 標記爲新依賴
      dep.n |= trackOpBit 
      // 如果依賴已經被收集,則不需要再次收集
      shouldTrack = !wasTracked(dep)
    }
  }
  else {
    // cleanup 模式
    shouldTrack = !dep.has(activeEffect)
  }
  if (shouldTrack) {
    // 收集當前激活的 effect 作爲依賴
    dep.add(activeEffect)
    // 當前激活的 effect 收集 dep 集合作爲依賴
    activeEffect.deps.push(dep)
    if ((process.env.NODE_ENV !== 'production') && activeEffect.onTrack) {
      activeEffect.onTrack(Object.assign({
        effect: activeEffect
      }, debuggerEventExtraInfo))
    }
  }
}

我們發現,當創建 dep 的時候,是通過執行 createDep 方法完成的,此外,在 dep 把前激活的 effect 作爲依賴收集前,會判斷這個 dep 是否已經被收集,如果已經被收集,則不需要再次收集了。此外,這裏還會判斷這 dep 是不是新的依賴,如果不是,則標記爲新的。

接下來,我們再來看 fn 執行完後的邏輯:

finally {
  if (effectTrackDepth <= maxMarkerBits) {
    // 完成依賴標記
    finalizeDepMarkers(this)
  }
  // 恢復到上一級
  trackOpBit = 1 << --effectTrackDepth
  resetTracking()
  // 出棧
  effectStack.pop()
  const n = effectStack.length
  // 指向棧最後一個 effect
  activeEffect = n > 0 ? effectStack[n - 1] : undefined
}

在滿足依賴標記的條件下,需要執行 finalizeDepMarkers 完成依賴標記,來看它的實現:

const finalizeDepMarkers = (effect) ={
  const { deps } = effect
  if (deps.length) {
    let ptr = 0
    for (let i = 0; i < deps.length; i++) {
      const dep = deps[i]
      // 曾經被收集過但不是新的依賴,需要刪除
      if (wasTracked(dep) && !newTracked(dep)) {
        dep.delete(effect)
      }
      else {
        deps[ptr++] = dep
      }
      // 清空狀態
      dep.w &= ~trackOpBit
      dep.n &= ~trackOpBit
    }
    deps.length = ptr
  }
}

finalizeDepMarkers 主要做的事情就是找到那些曾經被收集過但是新的一輪依賴收集沒有被收集的依賴,從 deps 中移除。這其實就是解決前面提到的需要 cleanup 場景的問題:在新的組件渲染過程中沒有訪問到的響應式對象,那麼它的變化不應該觸發組件的重新渲染。

以上就實現了依賴收集部分的優化,可以看到相比於之前每次執行 effect 函數都需要先清空依賴,再添加依賴的過程,現在的實現會在每次執行 effect 包裹的函數前標記依賴的狀態,過程中對於已經收集的依賴不會重複收集,執行完 effect 函數還會移除掉已被收集但是新的一輪依賴收集中沒有被收集的依賴。

優化後對於 dep 依賴集合的操作減少了,自然也就優化了性能。

響應式 API 的優化

響應式 API 的優化主要體現在對 refcomputed 等 API 的優化。

ref API 爲例,來看看它優化前的實現:

function ref(value) {
  return createRef(value)
}

const convert = (val) => isObject(val) ? reactive(val) : val

function createRef(rawValue, shallow = false) {
  if (isRef(rawValue)) {
    // 如果傳入的就是一個 ref,那麼返回自身即可,處理嵌套 ref 的情況。
    return rawValue
  }
  return new RefImpl(rawValue, shallow)
}

class RefImpl {
  constructor(_rawValue, _shallow = false) {
    this._rawValue = _rawValue
    this._shallow = _shallow
    this.__v_isRef = true
    // 非 shallow 的情況,如果它的值是對象或者數組,則遞歸響應式
    this._value = _shallow ? _rawValue : convert(_rawValue)
  }
  get value() {
    // 給 value 屬性添加 getter,並做依賴收集
    track(toRaw(this)'get' /* GET */, 'value')
    return this._value
  }
  set value(newVal) {
    // 給 value 屬性添加 setter
    if (hasChanged(toRaw(newVal), this._rawValue)) {
      this._rawValue = newVal
      this._value = this._shallow ? newVal : convert(newVal)
      // 派發通知
      trigger(toRaw(this)'set' /* SET */, 'value', newVal)
    }
  }
}

ref 函數返回了 createRef 函數執行的返回值,而在 createRef 內部,首先處理了嵌套 ref 的情況,如果傳入的 rawValue 也是個 ref,那麼直接返回 rawValue;接着返回 RefImpl 對象的實例。

RefImpl 內部的實現,主要是劫持它的實例 value 屬性的 gettersetter

當訪問一個 ref 對象的 value 屬性,會觸發 getter 執行 track 函數做依賴收集然後返回它的值;當修改一個 ref 對象的 value 值,則會觸發 setter 設置新值並且執行 trigger 函數派發通知,如果新值 newVal 是對象或者數組類型,那麼把它轉換成一個 reactive 對象。

接下來,我們再來看 Vue.js 3.2 對於這部分的實現相關的改動:

class RefImpl {
  constructor(value, _shallow = false) {
    this._shallow = _shallow
    this.dep = undefined
    this.__v_isRef = true
    this._rawValue = _shallow ? value : toRaw(value)
    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)
    }
  }
}

主要改動部分就是對 ref 對象的 value 屬性執行依賴收集和派發通知的邏輯。

在 Vue.js 3.2 版本的 ref 的實現中,關於依賴收集部分,由原先的 track 函數改成了 trackRefValue,來看它的實現:

function trackRefValue(ref) {
  if (isTracking()) {
    ref = toRaw(ref)
    if (!ref.dep) {
      ref.dep = createDep()
    }
    if ((process.env.NODE_ENV !== 'production')) {
      trackEffects(ref.dep, {
        target: ref,
        type: "get" /* GET */,
        key: 'value'
      })
    }
    else {
      trackEffects(ref.dep)
    }
  }
}

可以看到這裏直接把 ref 的相關依賴保存到 dep 屬性中,而在 track 函數的實現中,會把依賴保留到全局的 targetMap 中:

let depsMap = targetMap.get(target)
if (!depsMap) {
  // 每個 target 對應一個 depsMap
  targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
  // 每個 key 對應一個 dep 集合
  depsMap.set(key, (dep = createDep()))
}

顯然,track 函數內部可能需要做多次判斷和設置邏輯,而把依賴保存到 ref 對象的 dep 屬性中則省去了這一系列的判斷和設置,從而優化性能。

相應的,ref 的實現關於派發通知部分,由原先的 trigger 函數改成了 triggerRefValue,來看它的實現:

function triggerRefValue(ref, newVal) {
  ref = toRaw(ref)
  if (ref.dep) {
    if ((process.env.NODE_ENV !== 'production')) {
      triggerEffects(ref.dep, {
        target: ref,
        type: "set" /* SET */,
        key: 'value',
        newValue: newVal
      })
    }
    else {
      triggerEffects(ref.dep)
    }
  }
}

function triggerEffects(dep, debuggerEventExtraInfo) {
  for (const effect of isArray(dep) ? dep : [...dep]) {
    if (effect !== activeEffect || effect.allowRecurse) {
      if ((process.env.NODE_ENV !== 'production') && effect.onTrigger) {
        effect.onTrigger(extend({ effect }, debuggerEventExtraInfo))
      }
      if (effect.scheduler) {
        effect.scheduler()
      }
      else {
        effect.run()
      }
    }
  }
}

由於直接從 ref 屬性中就拿到了它所有的依賴且遍歷執行,不需要執行 trigger 函數一些額外的查找邏輯,因此在性能上也得到了提升。

trackOpBit 的設計

細心的你可能會發現,標記依賴的 trackOpBit,在每次計算時採用了左移的運算符 trackOpBit = 1 << ++effectTrackDepth;並且在賦值的時候,使用了或運算:

deps[i].w |= trackOpBit
dep.n |= trackOpBit

那麼爲什麼這麼設計呢?因爲 effect 的執行可能會有遞歸的情況,通過這種方式就可以記錄每個層級的依賴標記情況。

在判斷某個 dep 是否已經被依賴收集的時候,使用了 wasTracked 函數:

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

通過與運算的結果是否大於 0 來判斷,這就要求依賴被收集時嵌套的層級要匹配。舉個例子,假設此時 dep.w 的值是 2,說明它是在第一層執行 effect 函數時創建的,但是這時候已經執行了嵌套在第二層的 effect 函數,trackOpBit 左移兩位變成了 42 & 4 的值是 0,那麼 wasTracked 函數返回值爲 false,說明需要收集這個依賴。顯然,這個需求是合理的。

可以看到,如果沒有 trackOpBit 位運算的設計,你就很難去處理不同嵌套層級的依賴標記,這個設計也體現了 basvanmeurs 大佬非常紮實的計算機基礎功力。

總結

一般在 Vue.js 的應用中,對響應式數據的訪問和修改都是非常頻繁的操作,因此對這個過程的性能優化,將極大提升整個應用的性能。

大部分人去看 Vue.js 響應式的實現,可能目標最多就是搞明白其中的實現原理,而很少去關注其中實現是否是最優的。而 basvanmeurs 大佬能對提出這一系列的優化的實現,並且手寫了一個 benchmark 工具來驗證自己的優化,非常值得我們學習。

希望你看完這篇文章,除了點贊在看轉發三連之外,也可以去看看原貼,看看他們的討論,相信你會收穫更多。

前端的性能優化永遠是一個值得深挖的方向,希望在日後的開發中,不論是寫框架還是業務,你都能夠經常去思考其中可能存在的優化的點。

參考資料

[1] Vue.js 3.2 升級介紹: https://blog.vuejs.org/posts/vue-3.2.html

[2] basvanmeurs GitHub 地址:https://github.com/basvanmeurs

[3] 相關 PR 討論地址:https://github.com/vuejs/vue-next/pull/2345

[4] Thoughts on ES6 Proxies Performance: https://thecodebarbarian.com/thoughts-on-es6-proxies-performance

[5] Proxy-vs-DefineProperty repo: https://github.com/ustbhuangyi/Proxy-vs-DefineProperty

[6]benchmark 工具: https://github.com/basvanmeurs/vue-next-benchmarks

最近組建了一個湖南人的前端交流羣,如果你是湖南人可以加我微信 ruochuan12 私信 湖南 拉你進羣。

················· 若川簡介 ·················

你好,我是若川,畢業於江西高校。現在是一名前端開發 “工程師”。寫有《學習源碼整體架構系列》多篇,在知乎、掘金收穫超百萬閱讀。
從 2014 年起,每年都會寫一篇年度總結,已經寫了 7 篇,點擊查看年度總結
同時,活躍在知乎 @若川,掘金 @若川。致力於分享前端開發經驗,願景:幫助 5 年內前端人走向前列。

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