iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0

本文目標

把前面實作過的 signals/computed不撕裂(tear-free) 的方式接進 React 18 Concurrent 模式
關鍵:快照 + 訂閱 必須交給 useSyncExternalStore

為什麼會撕裂(tearing)?

React 的 Render 可被打斷、重做;如果你在 render 期間直接讀外部可變資料(例如 someSignal.get()),接著又在 commit 時期資料變了,就會出現 DOM 與快照不同步 的「tearing」

useSyncExternalStore 解法

  • getSnapshot(同步快照讀取)
  • subscribe(資料變動時通知 React 重新讀快照)
    React 會在 commit 前再取一次快照,避免撕裂。

我們需要的應用

  • 快照來源用 peek():不建立 React→signal 的反向依賴,但在 stale 時仍會 lazy 重算,拿到最新值。
  • 訂閱用 createEffect:在 effect 內讀 signal.get() 以自動追蹤,變動時觸發 useSyncExternalStorenotify()
  • 首次 render 不觸發 setState:避免在訂閱當下(render 期)回呼。

實作 hook

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);
}

使用範例

Counter

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>
  );
}

Selector

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

嚴格模式(StrictMode)與並發(Concurrent)安全清單

  • 首次訂閱不呼叫notify()(hook 中 first tag),避免 render 期 setState
  • disposer 可重入stop() 可被呼叫多次(StrictMode 會先訂閱→立刻解除→再訂閱)。
  • 快照純函數getSnapshot = src.peek 沒有副作用,允許 React 在 commit 前多次呼叫。
  • 不在 render 期建立外部 effect:effect 放在 subscribeReadable() 內,由 useSyncExternalStore 管理生命週期。

訂閱資料流的時序

https://ithelp.ithome.com.tw/upload/images/20250819/20129020ob3zXkik2Y.png

常見的坑

1. 用 useEffect 來訂閱

// 會造成無法正確同步
useEffect(() => {
  const stop = createEffect(() => { 
    someSignal.get();
    setState(someSignal.peek());
  });
  return () => stop();
}, []);

解法:改用 useSyncExternalStore

const mySignal = signal(0);
// React 會在 commit 前重取快照,避免撕裂
const value = useSignalValue(mySignal);

2. 把 get() 值直接放進閉包

const v = someSignal.get(); // 這樣會變成取當下的拷貝
const onClick = () => console.log(v); // stale closure

解法:在事件裡讀最新快照(或用 selector/computed)

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(...); })
    • React 事件本身也會批次 setState。
    • 我們的 effects 會在 microtask 只重跑一次。
    • 你在區塊內的 useSignalValue(memo) 讀取會拿到「最新(lazy 重算後)」的值。

結語

透過上面的 hook,我們已經可以順利在 React 環境下使用我們自己定義的 Signal 系統來管理狀態了,而且還是細顆粒化的版本。

下一篇,我們來探討理解副作用的整合。


上一篇
React 應用(I):渲染心智與限制
下一篇
React 應用(III):生命週期沒消失—副作用分工與清理時機
系列文
Reactivity 小技巧大變革:掌握 Signals 就這麼簡單!17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言