iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Modern Web

Angular 工程師的 React 陣痛期:30 天心智模型重建系列 第 12 篇

Day 12| React 裡,找到了我的 BehaviorSubject

  • 分享至 

  • xImage
  •  

勾一個方框,整頁都重畫了

檔案列表要支援批次多比操作:每一列前面一個勾選框,勾了之後,頁面最上方的工具列要顯示「已選 3 個」,右側面板要列出選中的檔案。

三個元件,其實散在頁面三個角落,共用同一份「選了哪些」。

在 Angular,這是我常常寫東西。到了 React,我先用 Context 做了一版,能動,功能完全正確。

然後我打開 React DevTools,勾上「Highlight updates when components render」,點了一個勾選框。

整頁都閃了一下。

工具列閃、右側面板閃、五百列檔案全部閃。我只勾了一個方框,五百列都重新 render 了一遍。列表開始有點卡。


一、Angular:放在 service 裡的 BehaviorSubject

在 Angular,跨元件共享的狀態,我的標準做法是這樣:

@Injectable({ providedIn: 'root' })
export class SelectionService {
  private selected$ = new BehaviorSubject<string[]>([]);
  readonly selected = this.selected$.asObservable();

  toggle(id: string) {
    const current = this.selected$.value;
    this.selected$.next(
      current.includes(id) ? current.filter(x => x !== id) : [...current, id]
    );
  }
}

工具列、檔案列、右側面板,各自 inject(SelectionService),各自訂閱自己需要的部分:

// 工具列只要數量
count$ = this.selection.selected.pipe(map(s => s.length));

這個模式有三個特點,我以前從來沒意識到它們有多重要:

  • 狀態住在元件樹之外。 service 不屬於任何一個元件,它是獨立存在的。
  • 誰要誰訂閱。 只有訂閱的元件會收到通知,其他元件完全不受影響。
  • 可以只訂閱一部分。 用 map 取出需要的片段,再加個 distinctUntilChanged,值沒變就不通知。

Signals 時代換成 signal(),但結構一樣:一份狀態住在 service 裡,誰讀了誰才會被更新。


二、第一版:用 Context

React 沒有 service,Day 6 說過,跨元件共享要嘛狀態提升,要嘛用 Context。三個元件離太遠,我選了 Context:

const SelectionContext = createContext<SelectionValue | null>(null);

export function SelectionProvider({ children }: { children: ReactNode }) {
  const [selected, setSelected] = useState<string[]>([]);

  const value = useMemo(() => ({
    selected,
    toggle: (id: string) => setSelected(prev =>
      prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
    ),
  }), [selected]);

  return <SelectionContext.Provider value={value}>{children}</SelectionContext.Provider>;
}

useMemo 也包了(Day 4 教的),看起來很完整。

問題出在 Context 的運作方式:value 一換新的參考,所有讀這個 Context 的元件都會重新 render。

而每一列 FileRow 都需要讀它——要知道自己有沒有被選取,也要拿 toggle 來用。所以勾一個方框:

selected 變了 → value 換成新物件 → 所有 useContext(SelectionContext) 的元件重新 render
                                    → 工具列、面板,還有五百個 FileRow

真正需要更新的只有兩列(剛勾的那列,跟數量變了的工具列),但 Context 沒辦法只通知它們。它不知道誰在意哪一部分,只能全部叫醒。

這時候我才看懂:Context 是為了解決 prop drilling 而存在的。 它是一條運輸管道,把值從上面送到下面。它不是一個倉庫,沒有「只訂閱一部分」這種能力。

Angular 的 BehaviorSubject 兩者都是。React 的 Context 只是前者。


三、第二版:那我自己刻一個 BehaviorSubject

抗拒期嘛。我要的東西很清楚:一份住在元件之外的狀態,加上一群可以各自訂閱的元件。這不就是 BehaviorSubject?

function createStore<T>(initial: T) {
  let state = initial;
  const listeners = new Set<() => void>();

  return {
    get: () => state,
    set: (next: T) => {
      state = next;
      listeners.forEach(listener => listener());
    },
    subscribe: (listener: () => void) => {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

一個值、一群監聽者、一個通知的方法。十幾行,就是一個陽春版的 BehaviorSubject。

接到 React 上,要用一個 React 18 之後提供的 hook:

export const selectionStore = createStore<string[]>([]);

function useSelection<S>(selector: (state: string[]) => S): S {
  return useSyncExternalStore(
    selectionStore.subscribe,
    () => selector(selectionStore.get()),
  );
}

useSyncExternalStore 就是 React 官方給「外部倉庫」接進來的插座:你告訴它怎麼訂閱、怎麼取值,它負責在值變了的時候讓元件更新。

關鍵在 selector。每個元件只取自己要的那一塊:

// FileRow:只關心自己有沒有被選
const isSelected = useSelection(s => s.includes(file.id));

// 工具列:只關心數量
const count = useSelection(s => s.length);

勾一個方框,所有訂閱者都會被通知,但 React 會拿 selector 的回傳值跟上一次比較——又是 Object.is。第 1 列到第 499 列的 isSelected 沒變(還是 false),就不重新 render。

打開 DevTools 再點一次:只有剛勾的那一列、還有工具列閃了一下。

map 加 distinctUntilChanged,在這裡變成了 selector 加 Object.is。


四、然後發現了 Zustand

寫完我很得意,拿去給同事看。他看了一眼說:「這不就是 Zustand?」

我去翻了 Zustand 的原始碼。核心的部分,跟我剛剛寫的東西幾乎一模一樣:一個閉包裡的 state、一個 Set 裝監聽者、再用 useSyncExternalStore 接到 React。整個套件壓縮後大約 1KB。

用 Zustand 改寫:

import { create } from 'zustand';

interface SelectionState {
  selected: string[];
  toggle: (id: string) => void;
  clear: () => void;
}

export const useSelection = create<SelectionState>()(set => ({
  selected: [],
  toggle: id => set(s => ({
    selected: s.selected.includes(id)
      ? s.selected.filter(x => x !== id)
      : [...s.selected, id],
  })),
  clear: () => set({ selected: [] }),
}));

用起來:

const isSelected = useSelection(s => s.selected.includes(file.id));
const toggle = useSelection(s => s.toggle);
const count = useSelection(s => s.selected.length);

沒有 Provider。沒有 Context。store 定義在元件之外,任何元件 import 進來就能用,而且只會因為自己取的那一塊變了才重新 render。

看著這段程式碼,我有一種很久沒有的熟悉感:這根本就是一個 providedIn: 'root' 的 service。

Angular Zustand
@Injectable({ providedIn: 'root' }) 在模組層級 create() 一次
BehaviorSubject 裡的值 store 裡的 state
service 的方法裡呼叫 .next() store 的 action 裡呼叫 set()
inject(SelectionService) useSelection()
map(s => s.length) selector s => s.selected.length
distinctUntilChanged() selector 回傳值的 Object.is 比較

甚至連「在元件之外用」都一樣。Angular 可以在另一個 service 裡注入它,Zustand 可以直接呼叫:

useSelection.getState().clear();   // 在事件處理、工具函式裡都能用

還是有一個 Object.is 的坑

selector 如果回傳一個新的物件或陣列,每次都會被判定成「變了」:

// ❌ 每次都 return 一個新物件
const { count, selected } = useSelection(s => ({
  count: s.selected.length,
  selected: s.selected,
}));

輕則每次都重新 render,重則在新版 Zustand 裡直接造成無限更新。要一次取多個值,就分開呼叫,或用 Zustand 提供的 useShallow 改成淺比較:

import { useShallow } from 'zustand/react/shallow';

const { count, selected } = useSelection(
  useShallow(s => ({ count: s.selected.length, selected: s.selected }))
);

Object.is 已經是這個系列出場次數最多的角色了。

兩個跟 Angular service 不一樣的地方

它是模組單例,不是 DI。 Day 2 講過,export const 出來的東西沒辦法像 providers 那樣在測試裡換掉。Zustand 的做法是在每個測試前把狀態重設:useSelection.setState({ selected: [] })。能用,但心態上要調整。

它在伺服器上是共用的。 也是 Day 2 埋的伏筆:模組單例在 SSR 時,是整個 Node process 共用一份。所以「選了哪些檔案」這種只在瀏覽器裡發生的 UI 狀態放進去沒問題,但如果在 loader 或伺服器端寫入跟使用者有關的資料,就會在不同使用者之間互相汙染。這個 Week 4 再細講。


今天的結論

BehaviorSubject 放在 service 裡,本質上是兩件事:一份住在元件之外的值,加上一群可以只訂閱一部分的監聽者。

React 的 state 住在元件裡面。要讓散在各處的元件共享它,Context 能把值送過去,卻沒辦法讓訂閱者各取所需——它是運輸管道,不是倉庫。

所以 React 生態需要「外部 store」這一層。而 Zustand 做的事情,拆開來看就是我在 Angular 裡天天在用的東西:一個全域的 service,裡面放一個 BehaviorSubject,外面的人用 selector 挑自己要的那一塊。

但有件事要先講清楚:Zustand 管的是 UI 狀態——選了什麼、篩選條件、側欄開沒開。它不是拿來放「從 API 抓回來的資料」的。

那一塊,才是我這一週所有手刻 hook 真正在處理的東西:競態、快取、重試、debounce 之後的請求。


上一篇
Day 11|沒有防抖debounce,請求像愛如潮水
下一篇
Day 13|一直在手刻的,其實是一個快取
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言