「Vue 設計與實現」響應系統原理(四)- cleanup 副作用回收機制

「Vue.js 設計與實現」之讀書筆記與整理 - cleanup 副作用回收機制

· 3 min read

遺留的副作用函式

先前設計的桶子更完善了,會根據各個屬性收集副作用,也進行了 track, trigger 封裝,提高擴充性。

但在某些情況,還是會有不夠完善的問題,以下面的程式碼為例,按照目前的實現,當 proxy.ok 改為 false 之後,觸發副作用函式執行,此時 proxy.text 不會被讀取,理想上副作用桶裡應該不存在 proxy.text 的收集,但當 proxy.text 重新賦值後,仍會觸發副作用函式,打印出「effect run」,稱為「遺留的副作用函式」。

副作用遺留 - stackblitz

const data = { ok: true, text: 'hello world' }
const proxy = new Proxy(data, { /* ... */ })
effect(() => {
  console.log('effect run')
  document.body.innerText = proxy.ok ? proxy.text : 'not'
})

setTimeout(() => {
  proxy.ok = false

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

副作用遺留

理想的副作用收集

副作用回收機制(clean up)

為了解決這個問題,需要設計一個副作用回收的機制,在每次 trigger 副作用函式執行前,就將桶中對應的的副作用函式刪除,這樣當副作用函式執行時,就可以依照當前情境去 track 當前作用域內的所有 proxy 的讀取。

實作 cleanup

會有三個步驟:

1. cleanup function

創建一個 cleanup 函式,將 effectFn 傳入,並需要讓 effectFn 知道自己被哪些副作用函示集合(effectFn Set)收集。

effectFn 需要擴充一個屬性 deps: Set[],在屬性讀取並 track 時,將 effectFn Set 收集,cleanup 執行時,去所有桶子內清除當前相關的 effectFn。

function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    effectFn.deps[i].forEach((i) => {
      i.delete(effectFn)
    })
  }

  effectFn.deps.length = 0
}

2. 在 effectRegister 內封裝 effectFn

let activeEffect

function effectRegister(fn) {
  const effectFn = () => {
    // 從桶中清除當前要執行的副作用
    cleanup(effectFn)

    // 寫入全域副作用變數,方便下次追蹤可以正常抓取
    activeEffect = effectFn

    // 執行副作用
    fn()
  }

  // 初始化 effectFn.deps
  effectFn.deps = []
  effectFn()
}
  1. track 時收集

在 track 函式內新增收集 activeEffect.deps.push(deps)

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)

  // 新增這一行
  activeEffect.deps.push(deps)
}

無限迴圈問題

ECMA-2023 Set.prototype.forEach

根據 2023 ECMA Script 規範當中提到,在調用 forEach 遍歷 Set 集合時,如果在訪問某個值後將其刪除,然後在 forEach 調用完成之前重新添加該值,則會重新訪問該值。

Each value is normally visited only once. However, a value will be revisited if it is deleted after it has been visited and then re-added before the forEach call completes.

每個值通常只被訪問一次。但是,如果在訪問某個值後將其刪除,然後在 forEach 調用完成之前重新添加該值,則會重新訪問該值。

簡單以程式碼實現無限迴圈會是這樣:

const set = new Set([1])

const newSet = new Set(set)
newSet.forEach((item) => {
  set.delete(1)
  set.add(1)
  console.log('遍歷中')
})

解決無限迴圈問題

加上目前 cleanup 回收的機制後,會有無限迴圈的問題,問題出在 trigger 函數內 forEach 執行 Set 導致的。

effect Set 迴圈執行 -> cleanup -> 副作用函式 -> track -> 重新收集進 effect Set -> 因為 effect Set 迴圈還沒結束就重新收集 -> 無限迴圈

function trigger(target, key) {
  /**
   * ...
   */
  effects && effects.forEach(fn => fn())
}

解決辦法很簡單,就是在 trigger 內創建一個新的 Set 集合 effectToRun 遍歷。

function trigger(target, key) {
  /**
   * ...
   */
  const effectToRun = new Set(effects)
  effectToRun && effectToRun.forEach(fn => fn())
  // effects && effects.forEach((fn) => fn());
}

完整程式碼

完整程式碼 - stackblitz

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

/**
 * 註冊副作用的函式
 * effect => effectRegister
 */
function effectRegister(fn) {
  const effectFn = () => {
    // 從桶中清除當前要執行的副作用
    cleanup(effectFn)

    // 寫入全域副作用變數,方便下次追蹤可以正常抓取
    activeEffect = effectFn

    // 執行副作用
    fn()
  }

  // 初始化 effectFn.deps
  effectFn.deps = []
  effectFn()
}

function cleanup(effectFn) {
  // 跑迴圈刪除,確保當前 effect 內收集的所有相同的副作用,只會執行一次
  for (let i = 0; i < effectFn.deps.length; i++) {
    const depsSet = effectFn.deps[i]
    depsSet.delete(effectFn)
  }

  // 重置 effectFn.deps
  effectFn.deps.length = 0
}

const data = { text: 'hello world', ok: true }

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)
  activeEffect.deps.push(deps)
}

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

effectRegister(() => {
  console.log('effect run')

  document.body.innerText = proxy.ok ? proxy.text : 'not'
})

setTimeout(() => {
  proxy.ok = false

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