還記得這張圖嗎?
上一篇我們透過實作 Registry 這層抽象,讓我們能夠有不同資料結構處理 Effect 排程的選擇權,那你一定會想...
在 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。
.size
、對鍵是強引用(你得自己刪)。.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
。Map
(僅限開發)。dispose()
的時候還是要 delete
。在我們的實作案例理面,兩種實作只是 EffectRegistry
的不同背板。切換只換 import
,呼叫端無變動。
// 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); }
};
// 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';
面向 | 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
重跑onCleanup
、dispose()
下一篇,我們來實作 computed 讓 signal 的運作更完整!!
wm.has(o1); // undefined
是說這邊好像寫錯了,應該是 false
🤔
感謝提醒告知