iT邦幫忙

2025 iThome 鐵人賽

DAY 15
0
Vue.js

從零到一打造 Vue3 響應式系統系列 第 15

Day 15 - Effect:依賴清理實作方案

  • 分享至 

  • xImage
  •  

banner
在實際狀況,effect 函式內部的依賴,常因為條件分支(像是 if...else)而發生變化,這種情況稱為「動態依賴」。

動態依賴會帶來一個問題:在某次執行中不再被使用的舊有依賴,如果沒有被處理好,會殘留在依賴列表中。

後續這個失效依賴的來源被修改時,仍然會觸發 effect 重新執行,這導致不必要的更新或邏輯錯誤。

前情回顧

day15-01

  • 第一次執行flag.valuetrueeffect 依賴 flagname,系統建立依賴鏈表 link1(flag) -> link2(name)
  • 觸發更新flag.value 變成 falseeffect 重新執行。
  • 第二次執行effect 進入 else 分支,需要依賴 age。系統會複用 link1(flag),並且幫 age 建立新節點 link3(age)。在沒有清理機制時,舊的 link2(name) 仍然存在在 effect 的依賴鏈表中。

此時,如果修改 name.value,因為 link2(name) 的依賴關係還在,effect 會被再次觸發,而當前 effect 的輸出內容實際只與 age 有關。

依賴清理核心思路

最直接的方法是在每次 effect 執行前,清空所有依賴再重新收集,但這樣會造成無法複用已有的鏈表節點,效能會較差。

另一個更有效率的方法是,在執行結束後,找出本次沒訪問到的節點,並只清除那一部分。

場景一:條件性依賴

感覺可以在這邊做判斷,因為 effectname 切換到 age 後,depsTail 最後的位置會指向 link3

day15-02

當執行完畢後,depsTail 指向 link3,而 link3 存有一個 nextDep 指針,指向舊的 link2(name)。這邊提供了一個可以判斷的依據:

「從 depsTail 指向節點的 nextDep 開始,到鏈表末尾的所有節點,都是本次執行時沒訪問到的依賴。」

以本次案例中:

  1. depsTail 指向link3
  2. link3 此時仍然有 nextDep

就可以清理 link3 的 nextDep,依賴就被清理完成。

狀況二:提前返回

day15-03

還記得我們上次一直觸發按鈕,鏈表上的狀態一直處於:有頭節點deps,並且尾節點depsTail = undefined

如果 effect 執行時因爲條件判斷而提前 return,沒有訪問任何響應式資料。depsTail 會保持初始 undefined 狀態。

這邊就提供了另一個可以判斷的依據:

「當 effect 執行完畢後,如果 depsTailundefined 並且 deps 頭節點存在,就說明本次執行時沒有訪問任何依賴,應該清除所有舊依賴。」

程式碼實作清除依賴

我們使用 startTrackendTrack 兩個函式來管理 effect 的執行週期。

  1. depsTail 存在,並且 depsTailnextDep 存在,表示包含nextDep的後續鏈表節點應該被移除,傳入clearTracking函式。
  2. 觸發更新完全沒讀到任何依賴(depsTail = undefined,並且有sub.deps頭節點),此時也應該要被移除,傳入clearTracking函式。
//effect.ts
...
...
export class ReactiveEffect { 
...
  run(){
    ...
      
    }finally{
      endTrack(this)
      activeSub = prevSub
    }   
  }
 ...
}

function endTrack(sub){
  const depsTail = sub.depsTail

  /**
   * 
   * 狀況一解法: depsTail 存在,並且 depsTail 的 nextDep 存在,表示後續鏈表節點應該移除
   */
  if(depsTail){
    if(depsTail.nextDep){
      clearTracking(depsTail.nextDep)
      depsTail.nextDep = undefined
    }
    // 狀況二:depsTail 不存在,但舊的 deps 頭節點存在,清除所有節點
  }else if(sub.deps){
    clearTracking(sub.deps)
    sub.deps = undefined
  }

}

clearTracking 設計核心

day15-04

clearTracking 函式的工作是從鏈表中移除一個 link 節點。

day15-05

由於 link 節點同時存在於 dep 的訂閱者列表 (dep.subs) 和 effect 的依賴列表 (effect.deps) 這兩個雙向鏈表中,移除操作需要更新其在 dep.subs 列表中的 prevSubnextSub 指針,然後再沿著 effect.deps 列表的 nextDep 指針繼續處理下一個待清理的節點。

clearTracking 實作

/**
 * 清理依賴函式鏈表
 */

function clearTracking(link: Link){
  while(link){
    const { prevSub, nextSub, dep, nextDep} = link

    /**
     * 1. 如果上一個節點有 sub,那就把 nextSub 的下一個節點指向當前節點的下一個節點
     * 2. 如果沒有 sub,表示屬於頭節點,那就把 dep.subs 指向當前節點的下一個節點
     */
    if(prevSub){
      prevSub.nextSub = nextSub
      link.nextSub = undefined
    }else{
      dep.subs = nextSub
    }

    /**
     * 1. 如果下一個節點有 sub,那就把 nextSub 的上一個節點指向當前節點的上一個節點
     * 2. 如果下一個節點沒有 sub,表示屬於尾節點,那就把 dep.subsTail 指向當前節點的上一個節點
     */

    if(nextSub){
      nextSub.prevSub = prevSub
      link.prevSub = undefined
    }else{
      dep.subsTail = prevSub
    }

    link.dep = link.sub = undefined

    link.nextDep = undefined

    link = nextDep
  }
}
...
...

system.ts 調整

export function link(dep, sub){

    /**
     * 復用節點
     * sub.depsTail 是 undefined,並且有 sub.deps 頭節點,表示要復用
     */
      const currentDep = sub.depsTail // = link1
      const nextDep = currentDep === undefined ? sub.deps : currentDep.nextDep
       // nextDep = link1.nextDep = link2
      if(nextDep && nextDep.dep === dep){
        // link2.dep (name) === age ? → false! 不能復用,需要建立新 link
        sub.depsTail = nextDep 
        return
      }

      const newLink = {
        sub,
        dep,
        nextDep, //  讓link3的 nextDep 變成 link2
        nextSub:undefined,
        prevSub:undefined
      }

      if(dep.subsTail){
        dep.subsTail.nextSub = newLink
        newLink.prevSub = dep.subsTail
        dep.subsTail = newLink
      }else { 
        dep.subs = newLink
        dep.subsTail = newLink
      }
      
      if(sub.depsTail){
        sub.depsTail.nextDep = newLink
        sub.depsTail = newLink
      }else{
        sub.deps = newLink
        sub.depsTail = newLink
      }
  
}

重構調整:完整程式碼

system.ts

//system.ts

/**
 * 依賴項
 */
interface Dep {
  // 訂閱者鏈表頭節點
  subs: Link | undefined
  // 訂閱者鏈表尾節點
  subsTail: Link | undefined
}
/**
 * 訂閱者
 */
interface Sub{
  // 訂閱者鏈表頭節點
  deps: Link | undefined
  // 訂閱者鏈表尾節點
  depsTail: Link | undefined
}

export interface Link {
  // 訂閱者
  sub: Sub
  // 下一個訂閱者節點
  nextSub:Link
  // 上一個訂閱者節點
  prevSub:Link
  //依賴項
  dep:Dep

  //下一個依賴項節點
  nextDep: Link | undefined
}

export function link(dep, sub){

    /**
     * 復用節點
     * sub.depsTail 是 undefined,並且有 sub.deps 頭節點,表示要復用
     */
      const currentDep = sub.depsTail
      const nextDep = currentDep === undefined ? sub.deps : currentDep.nextDep
      // 如果 nextDep.dep 等於我當前要收集的 dep
      if(nextDep && nextDep.dep === dep){
        sub.depsTail = nextDep  // 移動指針
        return
      }

      const newLink = {
        sub,
        dep,
        nextDep,// 讓link3的 nextDep 變成 link2
        nextSub:undefined,
        prevSub:undefined
      }
      // 將鏈表節點跟 dep 建立關聯關係
      if(dep.subsTail){
        dep.subsTail.nextSub = newLink
        newLink.prevSub = dep.subsTail
        dep.subsTail = newLink
      }else { 
        dep.subs = newLink
        dep.subsTail = newLink
      }
      
      /**
       * 將鏈表節點跟 sub 建立關聯關係
       * 1.如果有尾節點,表示鏈表現在有無數個節點,在鏈表尾部新增。
       * 2.如果沒有尾節點,表示是第一次關聯鏈表,第一個節點頭尾相同。 
       */
      if(sub.depsTail){
        sub.depsTail.nextDep = newLink
        sub.depsTail = newLink
      }else{
        sub.deps = newLink
        sub.depsTail = newLink
      }
  
}

export function propagate(subs){
  let link = subs
  let queuedEffect = []

  while (link){
    queuedEffect.push(link.sub)
    link = link.nextSub
  }

  queuedEffect.forEach(effect => effect.notify())
}

/**
 * 開始追蹤,將 depsTail 設為 undefined
 */

export function startTrack(sub){
  sub.depsTail = undefined
}

/**
 * 結束追蹤,找到需要清理的依賴
 */

export function endTrack(sub){
  const depsTail = sub.depsTail

  /**
   * 1. depsTail 存在,並且 depsTail 的 nextDep 存在,表示後續鏈表節點應該移除
   * 2. 觸發更新完全沒讀到任何依賴(depsTail undefined,並且有頭節點),
   * 那就把所有節點清除,否則 effect 函式會繼續被那些不相干的依賴觸發。
   */
  if(depsTail){
    if(depsTail.nextDep){
      clearTracking(depsTail.nextDep)
      depsTail.nextDep = undefined
    }
  }else if(sub.deps){
    clearTracking(sub.deps)
    sub.deps = undefined
  }

}

/**
 * 清理依賴函式鏈表
 */

function clearTracking(link: Link){
  while(link){
    const { prevSub, nextSub, dep, nextDep} = link

    /**
     * 1. 如果上一個節點有 sub,那就把 nextSub 的下一個節點指向當前節點的下一個節點
     * 2. 如果沒有 sub,表示屬於頭節點,那就把 dep.subs 指向當前節點的下一個節點
     */
    if(prevSub){// 如果我有上一個節點
      prevSub.nextSub = nextSub
      link.nextSub = undefined
    }else{// 我沒有上一個節點,我是要被刪除的頭節點
      dep.subs = nextSub
    }

    /**
     * 1. 如果下一個節點有 sub,那就把 nextSub 的上一個節點指向當前節點的上一個節點
     * 2. 如果下一個節點沒有 sub,表示屬於尾節點,那就把 dep.subsTail 指向當前節點的上一個節點
     */

    if(nextSub){// 如果我有下一個節點
      nextSub.prevSub = prevSub
      link.prevSub = undefined
    }else{ // 我沒有下一個節點,我是要被刪除的尾節點
      dep.subsTail = prevSub
    }

    // 清空引用
    link.dep = undefined
    link.sub = undefined
    link.nextDep = undefined

    // 處理下一個要移除的節點
    link = nextDep
  }
}

//effect.ts
import { Link, startTrack, endTrack } from './system'

export let activeSub;

export class ReactiveEffect { 

  // 依賴項鏈表的頭節點指向 link
  deps: Link
  
  // 依賴項鏈表的尾節點指向 link
  depsTail: Link
  
  constructor(public fn){
    
  }

  run(){
    const prevSub = activeSub
    activeSub = this
    startTrack(this)

    try{
      
      return this.fn()
      
    }finally{
      endTrack(this)
      activeSub = prevSub
    }   
  }
  
  notify(){
    this.scheduler()
  }
  
  scheduler(){
    this.run()
  }
  
}

export function effect(fn, options){

  const e = new ReactiveEffect(fn)
  
  Object.assign(e, options)
  
  e.run()

  const runner = e.run.bind(e)

  runner.effect = e
  
  return runner
  
}

執行結果

day15-06

失效的依賴是要實現響應式系統時需要處理的一個問題。這次我們利用 deps 鏈表和 depsTail 指標,在 effect 執行完畢後,能夠確認並移除不再使用的依賴項目。

同步更新《嘿,日安!》技術部落格


上一篇
Day 14 - Effect:清理依賴的場景
系列文
從零到一打造 Vue3 響應式系統15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
Dylan
iT邦研究生 5 級 ‧ 2025-09-24 13:57:41

請問大大這系列的 code 有 github repo 可以參考嗎?
因不知道哪裡跟錯了,在狀況一我的 link3nextDep 是 null,跟大大文中說的不一樣 🥲

我要留言

立即登入留言