【原始碼系列#02】Vue3響應式原理(Effect)

柏成發表於2023-11-28

專欄分享:vue2原始碼專欄vue3原始碼專欄vue router原始碼專欄玩具專案專欄,硬核?推薦?
歡迎各位ITer關注點贊收藏???

Vue3中響應資料核心是 reactive , reactive 的實現是由 proxy 加 effect 組合,上一章節我們利用 proxy 實現了一個簡易版的 reactive,# 【原始碼系列#01】Vue3響應式原理(Reactive)。接下來讓我們一起手寫下 effect 的原始碼

effect

effect 作為 reactive 的核心,主要負責收集依賴,更新依賴

在學習 effect之前,我們再來看下這張圖

  • targetMap:儲存了每個 "響應性物件屬性" 關聯的依賴;型別是 WeakMap
  • depsMap:儲存了每個屬性的依賴;型別是 Map
  • dep:儲存了我們的 effects ,一個 effects 集,這些 effect 在值發生變化時重新執行;型別是 Set

編寫effect函式

// 當前正在執行的effect
export let activeEffect = undefined

export class ReactiveEffect {
  // @issue2
  // 這裡表示在例項上新增了parent屬性,記錄父級effect
  public parent = null
  // 記錄effect依賴的屬性
  public deps = []
  // 這個effect預設是啟用狀態
  public active = true

  // 使用者傳遞的引數也會傳遞到this上 this.fn = fn
  constructor(public fn, public scheduler) {}

  // run就是執行effect
  run() {
    // 這裡表示如果是非啟用的情況,只需要執行函式,不需要進行依賴收集
    if (!this.active) {
      return this.fn()
    }
    // 這裡就要依賴收集了 核心就是將當前的effect 和 稍後渲染的屬性關聯在一起
    try {
      // 記錄父級effect
      this.parent = activeEffect
      activeEffect = this
      // 當稍後呼叫取值操作的時候 就可以獲取到這個全域性的activeEffect了
      return this.fn()
    } finally {
      // 還原父級effect
      activeEffect = this.parent
    }
  }
}

export function effect(fn, options: any = {}) {
  // 這裡fn可以根據狀態變化 重新執行, effect可以巢狀著寫
  const _effect = new ReactiveEffect(fn) // 建立響應式的effect
  // issue1
  _effect.run() // 預設先執行一次
}

@issue1 effect 預設會先執行一次

依賴收集

const targetMap = new WeakMap()
export function track(target, type, key) {
  // @issue3
  // 我們只想在我們有activeEffect時執行這段程式碼
  if (!activeEffect) return 
  
  let depsMap = targetMap.get(target) // 第一次沒有
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key) // key -> name / age
  if (!dep) {
    depsMap.set(key, (dep = new Set()))
  }
  // 單向指的是 屬性記錄了effect, 反向記錄,應該讓effect也記錄他被哪些屬性收集過,這樣做的好處是為了可以清理
  trackEffects(dep)
}

export function trackEffects(dep) {
  if (activeEffect) {
    let shouldTrack = !dep.has(activeEffect) // 去重了
    if (shouldTrack) {
      dep.add(activeEffect)
      // @issue4
      // 存放的是屬性對應的set
      activeEffect.deps.push(dep) // 讓effect記錄住對應的dep, 稍後清理的時候會用到
    }
  }
}

@issue3 當activeEffect有值時,即只在effect執行時執行track依賴收集

@issue4 雙向記錄 ,一個屬性對應多個effect,一個effect對應多個屬性

一個屬性對應多個 effect: 在之前的 depsMap 圖中,我們得知,一個屬性對映一個 dep(即 effect 集合,型別為 Set)

一個effect對應多個屬性: 在 effect 中,有一個 deps 屬性,她記錄了此 effect 依賴的每一個屬性所對應的 dep。讓 effect 記錄對應的 dep, 目的是在稍後清理的時候會用到

觸發更新

export function trigger(target, type, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return // 觸發的值不在模板中使用

  let effects = depsMap.get(key) // 找到了屬性對應的effect

  // 永遠在執行之前 先複製一份來執行, 不要關聯引用
  if (effects) {
    triggerEffects(effects)
  }
}
export function triggerEffects(effects) {
  effects.forEach(effect => {
    // 我們在執行effect的時候 又要執行自己,那我們需要遮蔽掉,不要無限呼叫,【避免由activeEffect觸發trigger,再次觸發當前effect。 activeEffect -> fn -> set -> trigger -> 當前effect】
    // @issue5
    if (effect !== activeEffect) {
      effect.run() // 否則預設重新整理檢視
    }
  })
}

@issue5 避免由run觸發trigger,無限遞迴迴圈

我們在執行 effect 的時候,又要執行自己,那我們需要遮蔽掉,不要無限呼叫【避免由 activeEffect 觸發 trigger,再次觸發當前 effect。 activeEffect -> fn -> set -> trigger -> 當前effect】

舉個例子

const { effect, reactive } = VueReactivity
const data = { name: '柏成', age: 13, address: { num: 517 } }
const state = reactive(data)
// vue3中的代理都是用proxy來解決的

// 此effect函式預設會先執行一次, 對響應式資料取值(取值的過程中資料會依賴於當前的effect)
effect(() => {
  state.age = Math.random()
  document.getElementById('app').innerHTML = state.name + '今年' + state.age + '歲了'
})

// 稍後name和age變化會重新執行effect函式
setTimeout(() => {
  state.age = 18
}, 1000)

分支切換與cleanup

// 每次執行effect的時候清理一遍依賴,再重新收集,雙向清理
function cleanupEffect(effect) {
  // deps 裡面裝的是name對應的effect, age對應的effect
  const { deps } = effect
  for (let i = 0; i < deps.length; i++) {
    // 解除effect,重新依賴收集
    deps[i].delete(effect)
  }
  effect.deps.length = 0
}

export class ReactiveEffect {
  // @issue3
  // 這裡表示在例項上新增了parent屬性,記錄父級effect
  public parent = null
  // 記錄effect依賴的屬性
  public deps = []
  // 這個effect預設是啟用狀態
  public active = true

  // 使用者傳遞的引數也會傳遞到this上 this.fn = fn
  constructor(public fn, public scheduler) {} // @issue8 - scheduler

  // run就是執行effect
  run() {
    // 這裡表示如果是非啟用的情況,只需要執行函式,不需要進行依賴收集
    if (!this.active) {
      return this.fn()
    }
    // 這裡就要依賴收集了 核心就是將當前的effect 和 稍後渲染的屬性關聯在一起
    try {
      // 記錄父級effect
      this.parent = activeEffect
      activeEffect = this
      // 這裡我們需要在執行使用者函式之前將之前收集的內容清空
      cleanupEffect(this) // @issue6
      // 當稍後呼叫取值操作的時候 就可以獲取到這個全域性的activeEffect了
      return this.fn() // @issue1
    } finally {
      // 還原父級effect
      activeEffect = this.parent
    }
  }
}

export function triggerEffects(effects) {
  // 先複製,防止死迴圈,new Set 後產生一個新的Set
  effects = new Set(effects) // @issue7
  effects.forEach(effect => {
    // 我們在執行effect的時候 又要執行自己,那我們需要遮蔽掉,不要無限呼叫,【避免由activeEffect觸發trigger,再次觸發當前effect。 activeEffect -> fn -> set -> trigger -> 當前effect】
    if (effect !== activeEffect) {
      effect.run() // 否則預設重新整理檢視
    }
  })
}

@issue6 分支切換 - cleanupEffect。我們需要在執行使用者函式之前將之前收集的內容清空,雙向清理,在渲染時我們要避免副作用函式產生的遺留,舉個例子,我們再次修改name,原則上不應更新頁面

每次副作用函式執行時,可以先把它從所有與之關聯的依賴集合中刪除。當副作用函式執行完畢後,響應式資料會與副作用函式之間建立新的依賴關係,而分支切換後,與副作用函式沒有依賴關係的響應式資料則不會再建立依賴,這樣副作用函式遺留的問題就解決了;

const { effect, reactive } = VueReactivity
const state = reactive({ flag: true, name: '柏成', age: 24 })

effect(() => {
  // 我們期望的是每次執行effect的時候都可以清理一遍依賴,重新收集
  // 副作用函式 (effect執行渲染了頁面)
  console.log('render')
  document.body.innerHTML = state.flag ? state.name : state.age
})

setTimeout(() => {
  state.flag = false
  setTimeout(() => {
    // 修改name,原則上不更新頁面
    state.name = '李'
  }, 1000)
}, 1000)

@issue7 分支切換 - 死迴圈。遍歷 set 物件時,先 delete 再 add,會出現死迴圈

在呼叫迴圈遍歷 Set 集合時,如果一個值已經被訪問過了,但該值被刪除,並重新新增到集合,如果此時迴圈遍歷沒有結束,那該值會被重新訪問

參考資料:ECMAScript Language Specification

提示:語言規範說的是forEach時是這樣的,實測 for of 遍歷Set會有同樣的問題。

看一下 triggerEffects 方法,遍歷了 effects

export function triggerEffects(effects) {
  effects.forEach(effect => { effect.run() })
}

effect.run 方法中

  • 執行 cleanupEffect(effect),清理一遍依賴
deps[i].delete(effect) 
  • 執行 this.fn(),重新執行函式,重新收集依賴
// track() 方法中
dep.add(activeEffect) // 將副作用函式activeEffect新增到響應式依賴中

解決方法:

let effect = () => {};
let deps = new Set([effect])
deps.forEach(item=>{
  console.log('>>>')
  deps.delete(effect); 
  deps.add(effect)
}); // 這樣就導致死迴圈了

// 解決方案如下,先複製一份,遍歷的Set物件 和 操作(delete、add)的Set物件不是同一個即可
let effect = () => {};
let deps = new Set([effect])
const newDeps = new Set(deps) 
newDeps.forEach(item=>{
  console.log('>>>')
  deps.delete(effect); 
  deps.add(effect)
}); 

effect巢狀

// 當前正在執行的effect
export let activeEffect = undefined

export class ReactiveEffect {
  // @issue2
  // 這裡表示在例項上新增了parent屬性,記錄父級effect
  public parent = null
  // 記錄effect依賴的屬性
  public deps = []
  // 這個effect預設是啟用狀態
  public active = true

  // 使用者傳遞的引數也會傳遞到this上 this.fn = fn
  constructor(public fn, public scheduler) {}

  // run就是執行effect
  run() {
    // 這裡表示如果是非啟用的情況,只需要執行函式,不需要進行依賴收集
    if (!this.active) {
      return this.fn()
    }
    // 這裡就要依賴收集了 核心就是將當前的effect 和 稍後渲染的屬性關聯在一起
    try {
      // 記錄父級effect
      this.parent = activeEffect
      activeEffect = this
      // 當稍後呼叫取值操作的時候 就可以獲取到這個全域性的activeEffect了
      return this.fn()
    } finally {
      // 還原父級effect
      activeEffect = this.parent
    }
  }
}

export function effect(fn, options: any = {}) {
  // 這裡fn可以根據狀態變化 重新執行, effect可以巢狀著寫
  const _effect = new ReactiveEffect(fn) // 建立響應式的effect
  // issue1
  _effect.run() // 預設先執行一次
}

@issue2 利用 parent 解決effect巢狀問題,effect 巢狀的場景在 Vue.js 中常常出現,如:Vue中的渲染函式(render)就是在一個effect中執行的,巢狀元件就會伴隨著巢狀 effect

  1. 解決effect巢狀問題----棧方式------------------------vue2/vue3.0初始版本
// 執行effect,此effect入棧,執行完畢,最後一個effect出棧,屬性關聯棧中的最後一個effect
[e1] -> [e1,e2] -> [e1]
effect(() => {   // activeEffect = e1
  state.name     // name -> e1
  effect(() => { // activeEffect = e2
    state.age    // age -> e2
  })
                 // activeEffect = e1
  state.address  // address = e1
})
  1. 解決effect巢狀問題----樹形結構方式----------------vue3後續版本
// 這個執行流程 就類似於一個樹形結構
effect(()=>{       // parent = null  activeEffect = e1
  state.name       // name -> e1
  effect(()=>{     // parent = e1  activeEffect = e2
     state.age     // age -> e2
     effect(()=> { // parent = e2  activeEffect = e3
        state.sex  // sex -> e3
     })            // activeEffect = e2
  })               // activeEffect = e1

  state.address    // address -> e1

  effect(()=>{     // parent = e1   activeEffect = e4
    state.age      // age -> e4
  })
})

停止effect和排程執行


export class ReactiveEffect {
  // @issue8 - stop
  stop() {
    if (this.active) {
      this.active = false
      cleanupEffect(this) // 停止effect的收集
    }
  }
}

export function effect(fn, options: any = {}) {
  // 這裡fn可以根據狀態變化 重新執行, effect可以巢狀著寫
  const _effect = new ReactiveEffect(fn, options.scheduler) // 建立響應式的effect @issue8 - scheduler
  _effect.run() // 預設先執行一次

  // @issue8 - stop
  // 繫結this,run方法內的this指向_effect,若不繫結,這樣呼叫run方法時,runner(),則指向undefined
  const runner = _effect.run.bind(_effect)
  // 將effect掛載到runner函式上,呼叫stop方式時可以這樣呼叫 runner.effect.stop()
  runner.effect = _effect
  return runner
}


export function triggerEffects(effects) {
  // 先複製,防止死迴圈,new Set 後產生一個新的Set
  effects = new Set(effects) // @issue7
  effects.forEach(effect => {
    // 我們在執行effect的時候 又要執行自己,那我們需要遮蔽掉,不要無限呼叫,【避免由activeEffect觸發trigger,再次觸發當前effect。 activeEffect -> fn -> set -> trigger -> 當前effect】
    if (effect !== activeEffect) {
      // @issue8 - scheduler
      if (effect.scheduler) {
        effect.scheduler() // 如果使用者傳入了排程函式,則執行排程函式
      } else {
        effect.run() // 否則預設重新整理檢視
      }
    }
  })
}

如何使用 stop 和 scheduler ?舉個小栗子

  • 當我們呼叫 runner.effect.stop() 時,就雙向清理了 effect 的所有依賴,後續 state.age 發生變化後,將不再重新更新頁面
  • 基於 scheduler 排程器,我們可以控制頁面更新的週期,下面例子中,會在1秒後,頁面由 30 變為 5000
let waiting = false
const { effect, reactive } = VueReactivity
const state = reactive({ flag: true, name: 'jw', age: 30, address: { num: 10 } })
let runner = effect(
  () => {
    // 副作用函式 (effect執行渲染了頁面)
    document.body.innerHTML = state.age
  },
  {
    scheduler() {
      // 排程 如何更新自己決定
      console.log('run')
      if (!waiting) {
        waiting = true
        setTimeout(() => {
          runner()
          waiting = false
        }, 1000)
      }
    },
  },
)

// 清理 effect 所有依賴,state.age 發生變化後,將不再重新更新頁面
// runner.effect.stop()

state.age = 1000
state.age = 2000
state.age = 3000
state.age = 4000
state.age = 5000

effect.ts

完整程式碼如下

/**
 * @issue1 effect預設會先執行一次
 * @issue2 activeEffect 只在effect執行時執行track儲存
 * @issue3 parent 解決effect巢狀問題
 * @issue4 雙向記錄  一個屬性對應多個effect,一個effect對應多個屬性 √
 * @issue5 避免由run觸發trigger,遞迴迴圈
 * @issue6 分支切換 cleanupEffect
 * @issue7 分支切換 死迴圈,set迴圈中,先delete再add,會出現死迴圈
 * @issue8 自定義排程器 類似Vue3中的effectScope stop 和 scheduler
 */

// 當前正在執行的effect
export let activeEffect = undefined

// @issue6
// 每次執行effect的時候清理一遍依賴,再重新收集,雙向清理
function cleanupEffect(effect) {
  // deps 裡面裝的是name對應的effect, age對應的effect
  const { deps } = effect
  for (let i = 0; i < deps.length; i++) {
    // 解除effect,重新依賴收集
    deps[i].delete(effect)
  }
  effect.deps.length = 0
}

export class ReactiveEffect {
  // @issue3
  // 這裡表示在例項上新增了parent屬性,記錄父級effect
  public parent = null
  // 記錄effect依賴的屬性
  public deps = []
  // 這個effect預設是啟用狀態
  public active = true

  // 使用者傳遞的引數也會傳遞到this上 this.fn = fn
  constructor(public fn, public scheduler) {} // @issue8 - scheduler

  // run就是執行effect
  run() {
    // 這裡表示如果是非啟用的情況,只需要執行函式,不需要進行依賴收集
    if (!this.active) {
      return this.fn()
    }
    // 這裡就要依賴收集了 核心就是將當前的effect 和 稍後渲染的屬性關聯在一起
    try {
      // 記錄父級effect
      this.parent = activeEffect
      activeEffect = this
      // 這裡我們需要在執行使用者函式之前將之前收集的內容清空
      cleanupEffect(this) // @issue6
      // 當稍後呼叫取值操作的時候 就可以獲取到這個全域性的activeEffect了
      return this.fn() // @issue1
    } finally {
      // 還原父級effect
      activeEffect = this.parent
    }
  }
  // @issue8 - stop
  stop() {
    if (this.active) {
      this.active = false
      cleanupEffect(this) // 停止effect的收集
    }
  }
}

export function effect(fn, options: any = {}) {
  // 這裡fn可以根據狀態變化 重新執行, effect可以巢狀著寫
  const _effect = new ReactiveEffect(fn, options.scheduler) // 建立響應式的effect @issue8 - scheduler
  _effect.run() // 預設先執行一次

  // @issue8 - stop
  // 繫結this,run方法內的this指向_effect,若不繫結,這樣呼叫run方法時,runner(),則指向undefined
  const runner = _effect.run.bind(_effect)
  // 將effect掛載到runner函式上,呼叫stop方式時可以這樣呼叫 runner.effect.stop()
  runner.effect = _effect
  return runner
}

// 物件 某個屬性 -》 多個effect
// WeakMap = {物件:Map{name:Set-》effect}}
// {物件:{name:[]}}
// 多對多  一個effect對應多個屬性, 一個屬性對應多個effect
const targetMap = new WeakMap()
export function track(target, type, key) {
  // 我們只想在我們有activeEffect時執行這段程式碼
  if (!activeEffect) return // @issue2
  let depsMap = targetMap.get(target) // 第一次沒有
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key) // key -> name / age
  if (!dep) {
    depsMap.set(key, (dep = new Set()))
  }
  // 單向指的是 屬性記錄了effect, 反向記錄,應該讓effect也記錄他被哪些屬性收集過,這樣做的好處是為了可以清理
  trackEffects(dep)
}

export function trackEffects(dep) {
  if (activeEffect) {
    let shouldTrack = !dep.has(activeEffect) // 去重了
    if (shouldTrack) {
      dep.add(activeEffect)
      // @issue4
      // 存放的是屬性對應的set
      activeEffect.deps.push(dep) // 讓effect記錄住對應的dep, 稍後清理的時候會用到
    }
  }
}

export function trigger(target, type, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return // 觸發的值不在模板中使用

  let effects = depsMap.get(key) // 找到了屬性對應的effect

  // 永遠在執行之前 先複製一份來執行, 不要關聯引用
  if (effects) {
    triggerEffects(effects)
  }
}
export function triggerEffects(effects) {
  // 先複製,防止死迴圈,new Set 後產生一個新的Set
  effects = new Set(effects) // @issue7
  effects.forEach(effect => {
    // 我們在執行effect的時候,有時候會改變屬性,那我們需要遮蔽掉,不要無限呼叫,【避免由activeEffect觸發trigger,再次觸發當前effect。 activeEffect -> fn -> set -> trigger -> 當前effect】
    // @issue5
    if (effect !== activeEffect) {
      // @issue8 - scheduler
      if (effect.scheduler) {
        effect.scheduler() // 如果使用者傳入了排程函式,則執行排程函式
      } else {
        effect.run() // 否則預設重新整理檢視
      }
    }
  })
}

參考資料

Vue3響應式系統實現原理(二) - CherishTheYouth - 部落格園

相關文章