從本系列的開頭講解,一直到現在的實作,都是圍繞著「資料層的生命週期」:資料如何被讀取、失效、重算、與何時觸發副作用。
這並不衝突於框架本身的生命週期,事實上,React 從來沒有拿掉生命週期,它是把生命週期重構為兩個階段:
useLayoutEffect
/useEffect
的 setup/cleanup,這裡才是副作用的合法落點。如果對 React 的 useLayoutEffect
/ useEffect
還不熟的朋友可以參考我之前鐵人賽的文章講解,至少要知道 React 怎麼透過這些 hook 來處理 UI 節點 mount
/ unmount
時機的。
React 強調的是 「一般寫法下,UI 對 state 的依賴不會被顯式標註」。因此它在 state 更新時,預設重跑該 component 的 render ,再透過 VDOM 的比對機制找到最小 DOM 變更;是否向下波及子樹,則交由 bailout 與記憶化策略 決定。
Signals 走的是「顯式依賴圖」:系統知道「誰依賴誰」,所以能精準推送更新,並以排程器(microtask 合併等)控制副作用觸發的時機。
兩條路線看似不同,其實都在嚴格管理生命週期邊界:
生命週期不曾消失,只是抽象層次不同。
useLayoutEffect
/ useEffect
)。signal/computed
的值觸發業務邏輯)→ signals(createEffect
,由 adapter 管理生命週期)。signal.set()
或建立任何外部 effect。useEffect/useLayoutEffect
的 cleanup)在下一次 commit 之前發生。onCleanup
)在 同一個 microtask 的重跑前發生,兩者互不衝突。事情 | 放哪裡 | 說明與範例 |
---|---|---|
讀/寫 DOM、量測、動畫 | useLayoutEffect / useEffect |
commit 之後執行,時機可預期 |
根據值觸發業務邏輯(請求、記錄、跨層事件) | createEffect (透過前一篇實作的 adapter 訂閱) |
我們的 scheduler 會在 microtask 合併重跑 |
多次同步 set 合併 |
交給 scheduler(microtask 去重) | 同一輪只重跑一次 our effects |
讀取當前快照 | useSignalValue / useComputed |
useSyncExternalStore + peek() ,Concurrent 下不撕裂 |
錯誤範例(render 期副作用)
function Bad() {
const v = useSignalValue(mySig);
if (v < 0) mySig.set(0); // render 階段寫入,會造成無窮重渲染/StrictMode 問題
return null;
}
正確使用(放在事件或 React effect)
function Good() {
const v = useSignalValue(mySig);
React.useEffect(() => {
if (v < 0) mySig.set(0);
}, [v]); // ✅ commit 後執行,時機安全
return null;
}
// 或事件中
<button onClick={() => mySig.set(x => Math.max(0, x))}>Clamp</button>
這裡與 react state 常見問題是一樣的,有點經驗的基本上不會在 FC(Function Component) 渲染期間值接更動 state。
錯誤範例(our effect + DOM 改寫時機不受 React 管)
createEffect(() => {
const h = panelHeight.get();
panelEl.style.height = h + "px"; // 可能與 React commit 衝突
});
正確使用(把值交給 React,DOM 改寫放 useLayoutEffect)
function Panel({ el }: { el: HTMLElement }) {
const h = useSignalValue(panelHeightSignal);
React.useLayoutEffect(() => {
el.style.height = h + "px"; // commit 後同步執行,安全
}, [el, h]);
return null;
}
DOM 的生命週期歸 React 管(commit 後),我們的 effect 適合做「資料的副作用」。
useEffect
拉訂閱 vs useSyncExternalStore
錯誤範例(useEffect
+ setState
容易撕裂(tearing)/時機不對)
function BadSubscribe() {
const [v, setV] = React.useState(mySig.peek());
React.useEffect(() => {
const stop = createEffect(() => {
setV(mySig.peek()); // 會造成 tearing
});
return () => stop();
}, []);
return <div>{v}</div>;
}
正確使用(透過前面寫的 adapter 提供 hook)
function GoodSubscribe() {
const v = useSignalValue(mySig); // useSyncExternalStore + peek,commit 前會重取快照
return <div>{v}</div>;
}
// 如果要支援比較早期的版本,可以考慮使用第三方的 library
透過 useSyncExternalStore
保障 Concurrent 模式下的「快照一致性」,避免 tearing。
錯誤範例(useComputed
讀的是 React 的數值,無法建立依賴)
const count = useSignalValue(countSig);
const doubled = useComputed(() => count * 2); // 只會算一次,不會更新
正確使用(在 computed 內讀 signal)
const count = useSignalValue(countSig);
const doubled = useComputed(() => countSig.get() * 2); // 建立依賴,會隨 signal 更新
或者使用純 React 衍生值(不需要 reactive node)
const count = useSignalValue(countSig);
const doubled = useMemo(() => count * 2, [count]); // 僅作渲染期快取
computed
必須在其執行函式中讀取 signal 的 .get()
才會被追蹤。
從上述錯誤中可以得出以下要點:
createEffect
/ signal.set
。useLayoutEffect/useEffect
;資料副作用 → 我們的 createEffect
。useSignalValue
(useSyncExternalStore
)。.get()
,不要拿 React 快照當依賴。透過上面的範例,我們可以很清楚的知道 React 的 Effect,是包含 UI 渲染更新的,所以想要讓我們設計的 signal 系統能在 React 環境下使用,就必須要透過既有的 hook 來做整合。
簡單來說,屬於我們本身機制提供的 api,盡量接受 signal 的參數進行使用,如果是透過 hook 封裝的值,會長成 React 的形狀,那麼就會需要使用 React 所提供的 hook 來交互使用,這樣追蹤才會是正常的。
下一篇,我們透過範例來看看要如何互補使用。