把前面實作過的 signals/computed
以 不撕裂(tear-free) 的方式接進 React 18 Concurrent 模式
關鍵:快照 + 訂閱 必須交給 useSyncExternalStore
。
React 的 Render 可被打斷、重做;如果你在 render 期間直接讀外部可變資料(例如 someSignal.get()
),接著又在 commit 時期資料變了,就會出現 DOM 與快照不同步 的「tearing」。
useSyncExternalStore
解法getSnapshot
(同步快照讀取)subscribe
(資料變動時通知 React 重新讀快照)peek()
:不建立 React→signal 的反向依賴,但在 stale
時仍會 lazy 重算,拿到最新值。createEffect
:在 effect 內讀 signal.get()
以自動追蹤,變動時觸發 useSyncExternalStore
的 notify()
。setState
:避免在訂閱當下(render 期)回呼。import { useEffect, useMemo, useSyncExternalStore, useRef } from "react";
import { createEffect } from "../core/effect.js";
import { computed } from "../core/computed.js";
import { signal } from "../core/signal.js";
type Readable<T> = { get(): T; peek(): T };
function subscribeReadable<T>(src: Readable<T>, notify: () => void) {
// 用前面章節的反應式 effect 訂閱來源;首次執行不通知,避免 render 期 setState
let first = true;
const stop = createEffect(() => {
src.get(); // 追蹤依賴
if (first) {
first = false;
return;
}
notify(); // 後續變化才通知 React 重取快照
});
return () => stop(); // useSyncExternalStore 會在卸載或重掛時呼叫
}
export function useSignalValue<T>(src: Readable<T>) {
const getSnapshot = () => src.peek(); // 不追蹤,但 stale 時會 lazy 重算
return useSyncExternalStore(
(notify) => subscribeReadable(src, notify),
getSnapshot, // client snapshot
getSnapshot // server snapshot(SSR)
);
}
export function useComputed<T>(
fn: () => T,
equals: (a: T, b: T) => boolean = Object.is
) {
// 用 ref 封裝最新的 fn / equals,避免因函數 identity 改變而重建 computed
const fnRef = useRef(fn);
fnRef.current = fn;
const eqRef = useRef(equals);
eqRef.current = equals;
// 只建一次 computed;其內部每次取值都使用最新的 fn/equals
const memo = useMemo(() => {
const c = computed(
() => fnRef.current(),
(a, b) => eqRef.current(a, b)
);
// 暖機:讓 peek() 初次就有快照
c.get();
return c;
}, []);
// 卸載清理
useEffect(() => () => memo.dispose?.(), [memo]);
// 用你原來的機制訂閱(首次不 notify 也沒關係,因為我們已經暖機)
return useSignalValue(memo);
}
// React 風格的 signal 狀態
export function useSignalState<T>(initial: T) {
const sig = useMemo(() => signal<T>(initial), []);
const value = useSignalValue(sig);
return [value, sig.set] as const;
}
export function useSignalSelector<S, T>(
src: Readable<S>,
selector: (s: S) => T,
isEqual: (a: T, b: T) => boolean = Object.is
) {
const selectorRef = useRef(selector);
selectorRef.current = selector;
const eqRef = useRef(isEqual);
eqRef.current = isEqual;
// src 變了才重建;其餘都用 ref 讀「最新」的 selector/isEqual
const memo = useMemo(() => {
const c = computed(
() => selectorRef.current(src.get()),
(a, b) => eqRef.current(a, b)
);
// 暖機
c.get();
return c;
}, [src]);
useEffect(() => () => memo.dispose?.(), [memo]);
return useSignalValue(memo);
}
import { useSignalState, useComputed } from "./react-adapter";
export function Counter() {
const [count, setCount] = useSignalState(0);
const doubled = useComputed(() => count * 2);
return (
<div>
<p>{count} / {doubled}</p>
<button onClick={() => setCount(v => v + 1)}>+1</button>
</div>
);
}
或者是:
import { useMemo } from "react";
import { signal } from "../core/signal.js";
import { useSignalValue } from "./react-adapter";
// 這樣就能作為 bottom-up 的全域狀態管理使用
const countSignal = signal(0);
export function Counter() {
const count = useSignalValue(countSignal);
const doubled = useMemo(() => count * 2, [count]);
return (
<div>
<p>{count} / {doubled}</p>
<button onClick={() => countSignal.set(v => v + 1)}>+1</button>
</div>
);
}
import { signal } from "../core/signal";
import { useSignalSelector } from "./react-adapter";
const user = signal({ id: 1, name: "Ada", age: 37 });
export function ProfileName() {
const name = useSignalSelector(user, u => u.name);
return <h2>{name}</h2>;
}
set()
整併function Buttons() {
const [a, setA] = useSignalState(0);
const [b, setB] = useSignalState(0);
const sum = useComputed(() => a + b);
const clickFn = () => {
setA(v => v + 1);
setB(v => v + 1);
};
return (
<>
<p>sum = {sum}</p>
<button onClick={clickFn}>
+A & +B
</button>
</>
);
}
我們的 scheduler 會把同一輪(同一個 call stack)內的多次 set()
合併到一個 microtask 內執行,因此上例只會重跑一次 effect
。
notify()
(hook 中 first
tag),避免 render 期 setState
。stop()
可被呼叫多次(StrictMode 會先訂閱→立刻解除→再訂閱)。getSnapshot = src.peek
沒有副作用,允許 React 在 commit 前多次呼叫。subscribeReadable()
內,由 useSyncExternalStore
管理生命週期。useEffect
來訂閱// 會造成無法正確同步
useEffect(() => {
const stop = createEffect(() => {
someSignal.get();
setState(someSignal.peek());
});
return () => stop();
}, []);
useSyncExternalStore
const mySignal = signal(0);
// React 會在 commit 前重取快照,避免撕裂
const value = useSignalValue(mySignal);
get()
值直接放進閉包const v = someSignal.get(); // 這樣會變成取當下的拷貝
const onClick = () => console.log(v); // stale closure
const latest = useSignalValue(someSignal);
const onClick = () => console.log(latest);
你可以簡單理解為 任何需要被 react 驅動更新的狀態值,都要通過 hook 處理,讓他能夠被 react 本身的生命週期追蹤到 。
batch/transaction
的相處之道batch/transaction
只影響我們的 effect 合併重跑,不會更動 React 的 render/commit。transaction(() => { a.set(...); b.set(...); })
:
useSignalValue(memo)
讀取會拿到「最新(lazy 重算後)」的值。透過上面的 hook,我們已經可以順利在 React 環境下使用我們自己定義的 Signal 系統來管理狀態了,而且還是細顆粒化的版本。
下一篇,我們來探討理解副作用的整合。