iT邦幫忙

2025 iThome 鐵人賽

DAY 12
1

快速回顧

還記得這張圖嗎?
https://ithelp.ithome.com.tw/upload/images/20250814/20129020oNzAQSTOlX.png
上一篇我們透過實作 Registry 這層抽象,讓我們能夠有不同資料結構處理 Effect 排程的選擇權,那你一定會想...

為什麼需要 Registry?

signal.set() 時,我們需要從「effect 節點」回到「對應的 EffectInstance」以便呼叫 schedule()
為了不汙染公開 API、又能保持 O(1) 查找,我們在上一篇以 EffectRegistry 抽象出這個對應表:

// registry.ts
import type { Node } from '../graph';

export interface EffectInstanceLike { schedule(): void }
export interface EffectRegistry {
  get(node: Node): EffectInstanceLike | undefined;
  set(node: Node, inst: EffectInstanceLike): void;
  delete(node: Node): void;
}

呼叫端(effect 與 signal)只呼叫 Effects.get/set/delete,不關心底層是 Symbol 還是 WeakMap。

理解 WeakMap

簡單對比

  • Map:可迭代、可 .size、對鍵是強引用(你得自己刪)。
  • WeakMap:不可迭代、無 .size、對鍵是弱引用(鍵沒強引用時,可隨 GC 自動移除)。

對照表

面向 Map WeakMap
鍵類型 任意(含原始型別) 只能 object(Function/Array/DOM/你的 Node
迭代 keys/values/entries/for..of ❌ 不能迭代
.size ✅ 有 ❌ 沒有
GC 行為 鍵被 強引用:存在 Map 就不會被回收 鍵被 弱引用:若外界無強引用,項目可被回收
典型用途 需要枚舉、排序、統計、LRU 物件 → 附加資料(快取、狀態、執行器)
風險 忘了 delete → 記憶體累積 不能統計/巡覽所有項目

快速理解:WeakMap 很適合「幫外部物件加側資料」,不改動該物件的公開結構、也不會因為這張表而阻止回收。
而我們的情境正好是:Node(effect 節點)→ EffectInstance(執行器)。

快速應用

const wm = new WeakMap();
const o1 = {firstName: "John"};
const o2 = {lastName: "Wick"};
const o3 = {nickName: "papayaga"};
wm.set(o1, o2);
wm.set(o2, o1);
wm.get(o1); // big-O: O(1),{lastName: "Wick"}
wm.get(o2); // big-O: O(1),{firstName: "John"}
wm.get(o3); // undefined
wm.has(o1); // true
wm.has(o2); // true
wm.has(o3); // false
wm.delete(o1);
wm.get(o1); // undefined
wm.get(o2); // big-O: O(1),{firstName: "John"}
wm.has(o1); // false
wm.has(o2); // true
  • 只有 set/get/has/delete;不能迭代、沒有 .size
  • 不能靠它做「列出所有對應」的 DevTools;那要另備清單或改用 Map(僅限開發)。

常見坑

  • 別指望自動清除你忘了刪的強引用:只要某處還握著鍵或值的強引用,GC 一樣不會回收;dispose() 的時候還是要 delete
  • 鍵相等性是「參考等同」:只能用同一個物件取回值,重建一個「長得一樣」的不算。

選型實作

在我們的實作案例理面,兩種實作只是 EffectRegistry 的不同背板。切換只換 import,呼叫端無變動。

兩種實作

SymbolRegistry (與前一篇作法一致)

// registry.ts
export const EffectSlot: unique symbol = Symbol('EffectSlot');
type EffectCarrier = { [EffectSlot]?: EffectInstanceLike };

export const SymbolRegistry: EffectRegistry = {
  get(n) { return (n as EffectCarrier)[EffectSlot]; },
  set(n, i) {
    Object.defineProperty(n as EffectCarrier, EffectSlot, {
      value: i, enumerable: false, configurable: true
    });
  },
  delete(n) { Reflect.deleteProperty(n as EffectCarrier, EffectSlot); }
};

WeakMapRegistry

// registry.ts
const table = new WeakMap<Node, EffectInstanceLike>();

export const WeakMapRegistry: EffectRegistry = {
  get: (n) => table.get(n),
  set: (n, i) => { table.set(n, i); },
  delete: (n) => { table.delete(n); }
};

呼叫端一鍵切換

// effect.ts & signal.ts 只改這行 import
import { SymbolRegistry } from './registry';
// or
import { WeakMapRegistry } from './registry';

WeakMap vs Symbol 比較

面向 SymbolRegistry WeakMapRegistry
心智負擔 低:節點上有一個不可枚舉的私有槽 中:需要理解弱引用與不可迭代
是否改動 Node 結構 ✅(加一個私有 Symbol 槽,但對外不可見) ❌(完全外置,不動節點)
可迭代 / .size 不適合迭代(私有槽不可枚舉) 不可迭代、無 .size
GC 行為 跟著節點走;要記得在 dispose() 時刪槽 鍵是弱引用;沒有其他強引用時可被回收,且 dispose() 仍建議 delete
呼叫端型別 介面乾淨(我們的 EffectRegistry 一律收 Node 同左(呼叫端完全一致)
常見風險 把 Symbol 另外 new 成不同實例(一定要共用同一個 export) 想遍歷內容(做不到);若程式握有其他強引用就不會回收

怎麼選?

如果懂 WeakMap 或是已經常在使用 WeakMap 的朋友就用吧!

主要是這個資料結構真的很少情境會使用到,記得之前面試還遇到面試官不知道 Javascript 的 Map 是什麼? 所以以防萬一我還是用 Symbol 的方式在節點上新增私有鍵的方式進行講解,這篇主要還是補充給有經驗的工程師朋友,選 WeakMap 還是比較直覺的選擇。

前面範例也有使用到一般的陣列格式,但主要是方便示範使用,實作上建議還是不要使用單純的陣列格式,主要是你的情境是處理 圖(Graph) 所延伸的問題,用 Map 都比使用單純陣列來的好操作;陣列在取值的時候會有效能上的限制,但也不是沒有辦法解決,只是要多做不少處理。

後面的進行還是會以 Symbol 的方式進行,這邊已經先抽像處理了 Registry,要替換也是很簡單的。

結語

現在我們已經有:

  • 圖:withObserver 自動建立依賴邊界
  • 通知:signal.set() 透過 SymbolRegistry.get(sub)?.schedule() || WeakMapRegistry.get(sub)?.schedule()effect 重跑
  • 生命週期:onCleanupdispose()

下一篇,我們來實作 computed 讓 signal 的運作更完整!!


上一篇
實作 effect (I): 讓圖真正「動」起來
下一篇
實作 Computed
系列文
Reactivity 小技巧大變革:掌握 Signals 就這麼簡單!17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

1
Dylan
iT邦研究生 5 級 ‧ 2025-09-12 10:06:29

wm.has(o1); // undefined

是說這邊好像寫錯了,應該是 false 🤔

LucianoLee iT邦研究生 4 級 ‧ 2025-09-12 13:08:48 檢舉

感謝提醒告知

我要留言

立即登入留言