「Vue 設計與實現」響應系統原理(三)- 更完善的副作用桶子

「Vue.js 設計與實現」之讀書筆記與整理 - 更完善的副作用桶子

· 3 min read

為了解決桶不夠完善的問題,需要把結構調整一下,原本只是簡單的 Set 收集副作用,現在需要調整為把各個物件、屬性、副作用分別收集。

// 單層
effectFn 
 target
   └── key
     └── effectFn

修改前的桶

只有 Set 的單層結構,每個物件、屬性被讀取(get),都會被收集到同一個 Set 內,當任一個屬性被設值(set),就會把所有的副作用拿出來執行,這樣是不合理的。

在下面的程式碼中,我們使用 new Set 當作桶子,在 setTimeout 2 秒後,修改了不存在的屬性 proxy.notExist,卻觸發了副作用函式的執行,log 了 'effect run',但是 notExist 這個屬性是沒有在任何地方被讀取的,不需要執行副作用。當 proxy 越來越龐大,這會是一個效能問題

不完善的桶 - stackblitz

/**
 * 副作用函式
 */
let activeEffect

/**
 * 註冊副作用的函式
 * effect => effectRegister
 */
function effectRegister(fn) {
  activeEffect = fn
  fn()
}

const bucket = new Set()

const data = { text: 'hello world', age: 22 }

const proxy = new Proxy(data, {
  get(target, key) {
    if (activeEffect)
      bucket.add(activeEffect)
    return target[key]
  },
  set(target, key, newValue) {
    target[key] = newValue
    bucket.forEach(fn => fn())

    return true
  },
})

effectRegister(() => {
  console.log('effect run')
  document.body.innerText = proxy.text
})

setTimeout(() => {
  proxy.notExist = 'goodbye'
  // 這邊讀取不存在的屬性,仍會執行整個 proxy 副作用收集的回調 `bucket.forEach(fn => fn()`
}, 2000)

修改後的桶

有以下的結構

  1. 用 WeakMap 把目標物件當作 key,使用 Map 當作 value
  2. Map 當中使用目標屬性的 key 當作 key,使用 Set 來當作 value
  3. value 內收集副作用函式

完善的桶

如此一來,在新的桶的設計當中,若任一個屬性沒有被讀取過,在 js 內去修改該屬性,也不會觸發副作用函式。

新的桶 - stackblitz

/**
 * 副作用函式
 */
let activeEffect

/**
 * 註冊副作用的函式
 * effect => effectRegister
 */
function effectRegister(fn) {
  activeEffect = fn
  fn()
}

const data = { text: 'hello world', age: 22 }

const bucket = new WeakMap()
const proxy = new Proxy(data, {
  get(target, key) {
    if (!activeEffect)
      return
    const _depsMap = bucket.get(target)
    const hasDepsMap = !!_depsMap

    // 檢查是否有對應的 Map,沒有就創建一個新的
    const depsMap = hasDepsMap ? _depsMap : new Map()
    !hasDepsMap && bucket.set(target, depsMap)

    const _deps = depsMap.get(key)
    const hasDeps = !!_deps

    // 檢查是否有對應的 Set,沒有就創建一個新的
    const deps = hasDeps ? _deps : new Set()
    !hasDeps && depsMap.set(key, deps)

    deps.add(activeEffect)

    return target[key]
  },
  set(target, key, newValue) {
    target[key] = newValue
    const effects = bucket.get(target)?.get(key)
    effects && effects.forEach(fn => fn())

    return true
  },
})

effectRegister(() => {
  console.log('effect run')
  document.body.innerText = proxy.text
})

setTimeout(() => {
  proxy.noExist = '222'
}, 2000)

為什麼使用 WeakMap

主要原因是怕記憶體流失(Memory Leak),WeakMap 具有較好的垃圾回收機制,這邊需要提一下 WeakMap & Map 的差異:

const map = new Map()
const weakMap = new WeakMap();
(function () {
  const foo = { foo: 1 }
  const bar = { bar: 2 }
  map.set(foo, 1)
  weakMap.set(bar, 2)
})()

在上方的程式碼中,map & weakMap 分別在 IIFE 執行當中,將 foo, bar 當作 key 引用,當函式執行完畢,foo 這個物件,因為仍被 map 引用,所以垃圾回收器(grabage collector)不会把它從內存記憶體中移除。仍然可以通過 map.keys 列出 foo 物件,但是 bar 物件在函式執行完畢後就會被釋放掉,因為 WeakMap 的 key 是弱引用,不會影響垃圾回收器的工作。

  • WeakMap 經常用於儲存那些只有當 key 所引用的物件存在時(没有被回收)才有價值的資料。
  • WeakMap 的 weak 就是弱引用的意思

在桶的設計當中,如果 target 物件沒有任何引用了,說明 client side 已經不需要它了,就讓垃圾回收器自動運作完成回收任務。如果使用 Map 來代替 WeakMap,即使 client side 對 target 沒有任何引用,這個物件也不會被回收,最終會導致 Memory Leak。

封裝 track & trigger 函數

  • 將副作用的收集封裝為 track 函數,在 get 函數中調用 track 函數追蹤變化
  • 將副作用的執行為 trigger 函數,在 set 函數内調用 trigger 函數觸發變化

這樣一來,就可以提高對 track, trigger 的擴展性。

封裝 track, trigger 函數 - stackblitz

/**
 * 副作用函式
 */
let activeEffect

/**
 * 註冊副作用的函式
 * effect => effectRegister
 */
function effectRegister(fn) {
  activeEffect = fn
  fn()
}

const data = { text: 'hello world', age: 22 }

const bucket = new WeakMap()
const proxy = new Proxy(data, {
  get(target, key) {
    track(target, key)
    return target[key]
  },
  set(target, key, newValue) {
    target[key] = newValue
    trigger(target, key)

    return true
  },
})

// 在 get 函數中調用 track 函數追蹤變化
function track(target, key) {
  if (!activeEffect)
    return
  const _depsMap = bucket.get(target)
  const hasDepsMap = !!_depsMap

  // 檢查是否有對應的 Map,沒有就創建一個新的
  const depsMap = hasDepsMap ? _depsMap : new Map()
  !hasDepsMap && bucket.set(target, depsMap)

  const _deps = depsMap.get(key)
  const hasDeps = !!_deps

  // 檢查是否有對應的 Set,沒有就創建一個新的
  const deps = hasDeps ? _deps : new Set()
  !hasDeps && depsMap.set(key, deps)

  deps.add(activeEffect)
}

// 在 set 函數内調用 trigger 函數觸發變化
function trigger(target, key) {
  const effects = bucket.get(target)?.get(key)
  effects && effects.forEach(fn => fn())
}

effectRegister(() => {
  console.log('effect run')
  document.body.innerText = proxy.text
})

setTimeout(() => {
  proxy.text = '222'
}, 2000)