React 狀態管理套件比較與原理實現 feat. Redux, Zustand, Jotai, Recoil, MobX, Valtio

Leo Chiu
手寫筆記
Published in
36 min readDec 2, 2023

React 狀態管理

狀態管理

React 是一個單向資料流的 library,在隨著元件越來越複雜之後,我們會選擇用不同的方式管理狀態,例如當兩個同層級的 Child 元件需要共用狀態時,首選的策略是 lifting state up,將原本在 Child 的狀態移動到 Parent 管理,再從 Parent 向下傳遞到需要共用狀態的 Child,這個是一個常見的情境。

再更複雜一點的時候,不同層級的元件或是多層級的元件需要共用狀態,根據 lifting state up 的規則,狀態被不斷地被往上提之後,狀態會在多層的元件之間傳遞,這便是 props drilling 的問題。

https://react.dev/learn/passing-data-deeply-with-context

為了解決 props drilling 的問題,React 提供了 context 可以用來跨元件傳遞狀態,基本上簡單的情境用 context 已經綽綽有餘,如果情境複雜一點,搭配 useReducer 也可以很方便的管理狀態。

但是使用 context 會遇到一些常見的問題,像是 provider 傳遞的狀態如果不使用 useMemouseCallback 封裝,以及 Child 不使用 memo,在 provider 的元件 re-render 時,所有使用到 context 的地方都會被重新渲染。

另一個問題則是如果 provider 傳遞的狀態越來越多時,經常會因為 provider 的其中一狀態改變導致整顆子樹都 re-render。要解決這個問題則是要把 Provider 的狀態切分的更細,用不同的 Provider 分離狀態。但如果分成多個 Provider,隨著需要管理的狀態越來越多,Provider 也會越來越多,介時也不好管理。

要解決的問題

重新整理一下使用 React 原生的狀態管理機制主要會遭遇以下幾個問題,在這篇文章中會一直提及以下兩個問題,講到各個套件怎麼解決的:

  • props drilling 的問題
  • context 造成整顆子樹渲染的問題

第三方套件

React 的狀態管理套件有非常多選擇,從 mental model 來說可以分成三大類 Flux、Atomic、Proxy,而被實作出來的套件包括 ReduxMobXRecoilZustandJotaiValtio 等等。

而我們從下載量來看目前是 Redux 跟 Zustand 的下載量最多,再來是 Mobx,而另外兩個實現 atomic 機制的 Jotai 跟 Recoil 每週下載量大約是 50 萬左右,最少人用的 Valtio 目前差不多是每週 30 萬。

https://npmtrends.com/jotai-vs-mobx-vs-recoil-vs-redux-vs-valtio-vs-zustand

在今年年初的時候 signal 突然變成熱門的關鍵字之一,以 React 的生態系來說有兩個套件相繼出現,分別是 @preact/signals 、 jotai-signal,但從下載量來看,目前使用這兩個套件的人數極少,後續在文中會提到為什麼比較少人使用的原因。

https://npmtrends.com/ jotai-signal-vs-@preact/signals

Flux

MVC 架構遇到的問題

在 2014 年以前,Facebook 大量使用了 MVC 架構在 Web 上,然而 MVC 架構讓整個資料流變得相當複雜,而且讓應用程式變得難以擴展(scale),且新的工程師加入之後會難以上手,因此很難在短時間內就有很高效的產出。

以下是當初 Facebook 在 2014 年在發布 Flux 跟 React 的演講時用的一張圖,這張原意應該是要表明資料流的問題,但是後來許多人都在 reddit 上都詬病這張圖,說 Facebook 的開發人員並不是很了解 MVC 架構,在 MVC 架構中 View 跟 Model 是不會雙向溝通的。

https://www.youtube.com/watch?v=nYkdrAPrdcw&t=1454s&ab_channel=MetaDevelopers

但演講的上下文有提到 Facebook 遇到的問題是在一個頁面中的許多區塊(View)會依賴多個(Model),所以我覺得可以理解成他們想解決的問題是讓畫面的資料可以更好得被管理。

此外,在那個時候他們用的框架是 imperative programming,所以很容易造成 cascading update 的問題,會讓一個 function 需要管理狀態,又需要管理 UI,所以為了解決上述的問題,最後就有了 Flux 跟 React 的出現。

雖然這張圖還是有點問題,但大家可以超譯一下,想像一下 Facebook 的工程師想講什麼 😅

下面這張圖是 2014 年左右時 Facebook 的聊天區,可以想像聊天的資料、是否已讀的資料散落在四個地方,為了同步四個地方的資料以及畫面的一致性,imperative programming 會讓程式碼變得難以閱讀。

https://www.youtube.com/watch?v=nYkdrAPrdcw&t=1454s&ab_channel=MetaDevelopers

因此,Facebook 提出了 Flux 這個概念,它是一個單向資料流的架構,主要組成有 dispatcher、store、action、view 四個部分。view 實際上就是 React 本身,在有事件發生時會發出 action,然後由 dispatcher 派發更新 store 中儲存的狀態,最後 React 會使用 store 中的這些狀態改變 view。

https://github.com/facebookarchive/flux

Flux 對比於 MVC 的前端架構有以下幾個優點:

  • 改善資料的一致性
  • 更容易找出哪裡有 bug
  • 寫出更好的 unit tests

以上的是優點是在 Facebook 發表 React 跟 Flux 時提到的優點

這些優點在現今仍然存在,但是在隨著 React 蓬勃發展這些優點彷彿已經變得理所當然,不論選擇的是哪個套件或是在 React 都有這些優點。

Flux 在初期只是一個概念,後來在 2015 年的時候 Facebook 開源了 flux 這個套件,但最後還是由 Redux 成為現今最多人使用的套件,而 flux 開源專案也在 2023 年 3 月的時候被 archived 了,在 flux 的 repo 中也提到如果需要狀態管理的套件,就去使用 ReduxMobXRecoilZustandJotai 這幾個套件。

現在最多人使用的狀態管理套件是 Redux 跟 Zustand,在 2023 年 9 月的現在,Redux 每週有將近 900 萬的下載次數,基本上只要想到狀態管理就會想到 Redux。而 Zustand 目前每週也有 200 萬的人數,在 2019 年發布之後,至今已經是第二多人使用的狀態管理套件,比起老牌的 Mobx 其每週下載量多了將近一倍。

https://npmtrends.com/redux-vs-zustand

Redux

Redux 是在 2015 年由 Dan Abramov 開發的一個基於 Flux 架構的狀態管理套件,目前是個每週將近有 900 萬下載次數的套件,也是大部分的人在學習 React 時第一個會碰到的狀態管理套件。

原生的 Redux 在設定與使用上比較瑣碎,像是 action、reducer 等等的,甚至如果有 TypeScript 的話在型別設定上更為繁瑣,如果有一處需要修改時往往會需要動到不少的地方。

而導入 Redux Toolkit 後可以減少創建 store、reducer 的 boilerplate code,並且讓原本更新 Redux store 中的狀態時需要以 immutable 的語法變成可以用 mutable 的方式撰寫,所以現今使用 Redux 時通常都會同時導入 RTK。

import { createSlice, configureStore } from '@reduxjs/toolkit'

const counterSlice = createSlice({
name: 'counter',
initialState: {
value: 0
},
reducers: {
incremented: state => {
state.value += 1
},
decremented: state => {
state.value -= 1
}
}
})

export const { incremented, decremented } = counterSlice.actions

const store = configureStore({
reducer: counterSlice.reducer
})

store.dispatch(incremented())
store.dispatch(decremented())

Redux 如何解決渲染的問題

現在 Redux 通常會跟 react-redux 一起使用,react-redux 提供了 useSelector 讓我們可以從 redux store 選取我們需要的狀態,並且 react-redux 會偵測選取的狀態是否改變,並且觸發重新渲染。

例如以下面這個例子來說,當 counter 改變了,但是 username 沒有變,這時候 useSelector 知道 counter 前後的值不一樣了,因此會觸發渲染,這時候只有 ComponentA 會被渲染:

import { useSelector } from 'react-redux'

const ComponentA = () => {
const counter = useSelector((state) => state.counter)
return <div>{counter}</div>
}

const ComponentB = () => {
const username = useSelector((state) => state.username)
return <div>{username}</div>
}

在 2021 年的之前 react-redux 還是使用 useReducer 建立強制渲染的 function , useSelector 會先把 state.counter 的值儲存起來,當從 redux store 取得的值改變時就會使用 forceRerender() 重新渲染該元件。

const [, forceRender] = useReducer((s) => s + 1, 0)

但在 React 18 的 hook 出來之後,react-redux 就使用了 useSyncExternalStoreWithSelector 作為偵測狀態並重新渲染的解決方案。

import type { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'

Zustand

Zustand(德文的狀態)是由 Jotai 跟 Valtio 的作者 Daishi Kato 開發的一個基於 Flux 架構的狀態管理套件,它比 Redux 使用起來更簡單,而且寫起來更簡潔,不需要像是 Redux 用 context provider 將 store 傳遞下去,便可以讓全域使用 Zustand 的狀態。

在 Zustand 中只要使用 create() 就可以快速建立 store 跟 action ,不像是 Redux 在建立 store 時僅管使用 RTK 也是要寫不少的 boilerplate:

import { create } from 'zustand'

const useBearStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}))

而要讀取狀態以及 dispatch action 也是很簡單,直接使用 create() 建立的 hook 就可以將狀態跟 action 從 store 中讀取出來:

function BearCounter() {
const bears = useBearStore((state) => state.bears)
return <h1>{bears} around here ...</h1>
}

function Controls() {
const increasePopulation = useBearStore((state) => state.increasePopulation)
return <button onClick={increasePopulation}>one up</button>
}

而在 Redux 中甚至要先建立 dispatcher 的 instance,還要 import action 才能使用,Zustand 的其中一個特點就是 DX 比 Redux 更好。

Zustand 如何解決渲染的問題

Zustand 跟 react-redux 一樣,都是透過判斷 selector 的值是否改變了,並觸發重新渲染。以下面這個例子來說, useBearStore 會偵測 state.bears 的值是否改變,當改變時會重新渲染該元件:

const bears = useBearStore((state) => state.bears)

在 2022 年 8 月之前,Zustand 使用了 useReducer 自己維護 forceUpdate() 的 function,但是後在 #550 之後使用了 useSyncExternalStoreWithSelector 取代作為觸發渲染的 function。

目前看到這裡發現 react-redux 跟 Zustand 都使用了 use-sync-external-store 這個套件,它是 React 18 的其中一個 hook,但也被分離出來變成獨立的套件,儘管不用升級到 React 18 也可以透過安裝套件使用這個 hook。

Zustand vs Redux

👉 Download trend

現今在社群推薦如果想要挑選基於 Flux 架構的狀態管理套件,不妨可以直接選擇 Zustand,雖然以目前的社群聲量以及下載量 Redux 每週有 800 萬的下載次數,但是也別忘記 Zustand 也已經到了每週 200 萬。以開源專案可維護性已經社群大小,Zustand 不僅可以在小專案中使用,也可以用產品中。

👉 Developer experience

從以上快速開箱的範例就可以看到在使用 Zustand 比 Redux 簡單許多,不需要撰寫繁雜 boilerplate,使用上也很直覺,基本上就是當作 custom hook 在使用。

Atomic

接下來要提及的是一個跟 Flux 很不一樣的概念 — Atomic,也是 Recoil 跟 Jotai 的基本概念。在一開始 Recoil 介紹影片中想解決的問題有兩個, 1️⃣ 如果使用 context 或是 props 傳遞狀態則會容易造成 re-render 的問題,這個問題也是本文一開始提到的其中一個問題; 2️⃣ 另一個問題是使用 context 會讓 code-splitting 無法切分的更細,因為整個 component tree 都使用了 context 或 props 的狀態。

Atomic 的核心概念就是想讓 React 的狀態管理可以被分散在 component tree 中,這些狀態就是 atom,而 atom 可以像是 context 取得狀態,同時又可以讓 code-splitting 將元件切分的更細。

https://youtu.be/_ISAA_Jt9kI?si=3fzywPPnwL-3sr_U

Recoil

Recoil 是由 Facebook 開發與維護的一個套件,在 2020 的時候被發佈出來。Recoil 主要想解決的問題如上述,第一個是 context render 的問題,第二個是 code-splitting 的問題。

除此之外,大概是為了跟內部複雜的大型系統整合,Recoil 的 API 非常的豐富,可以用各種方式使用 Recoil。

在建立狀態可以用 atomselectoratom 即是一般的狀態,如 React 的 state,但與 useState 不一樣的地方是在建立 atom 時會在 component 外面;而 selector 是拿來建構 derived data,可以從另一個 atom 生成新的狀態,如果有寫過 Vue,也可以想像是 Vue 的 computed API。

const todoListState = atom({
key: 'TodoList',
default: [],
});

const filteredTodoListState = selector({
key: 'FilteredTodoList',
get: ({get}) => {
const filter = get(todoListState);
const list = get(todoListState);

switch (filter) {
case 'Show Completed':
return list.filter((item) => item.isComplete);
case 'Show Uncompleted':
return list.filter((item) => !item.isComplete);
default:
return list;
}
},
});

以下是渲染 todo list 的範例,想要使用狀態時可以使用 useRecoilValue 取得 atom 的值:

function TodoList() {
const todoList = useRecoilValue(todoListState);

return (
<>
{todoList.map((todoItem) => (
<TodoItem key={todoItem.id} item={todoItem} />
))}
</>
);
}

如果想要設定 atom 的數值,則是可以使用 useSetRecoilState 這個 API,它會回傳一個像是 setState 的 function,可以直接拿來設定 atom 的值:

function TodoItemCreator() {
const [inputValue, setInputValue] = useState('');
const setTodoList = useSetRecoilState(todoListState);

const addItem = () => {
setTodoList((oldTodoList) => [
...oldTodoList,
{
id: getId(),
text: inputValue,
isComplete: false,
},
]);
setInputValue('');
};

const onChange = ({target: {value}}) => {
setInputValue(value);
};

return (
<div>
<input type="text" value={inputValue} onChange={onChange} />
<button onClick={addItem}>Add</button>
</div>
);
}

如前面所說,Recoil 提供的 API 非常豐富,還提供了 useRecoilStateuseRecoilStateLoadable 等等,如果有興趣的讀者在去官方文件上面看看吧!

Recoil 如何解決渲染的問題

Recoil 解決渲染的方式基本上與 Redux、Zustand 有些相似,Redux 跟 Zustand 都使用了 selector 的機制判斷選取的值時否改變來觸發渲染。換言之,Recoil 會判斷 atom 的值是否改變來觸發元件渲染,例如使用 useRecoilValue 時會判斷 todoListState 是否改變了,進而觸發渲染:

const todoList = useRecoilValue(todoListState);

但是 Recoil 的實作方式有點複雜,它被開發出來就是為了在複雜且龐大的系統中使用,所以判斷是否要渲染的方式有很多種,粗略切分可以分為三種,分別為:

  • TRANSITION_SUPPORT
  • SYNC_EXTERNAL_STORE
  • LEGACY

第一種 TRANSITION_SUPPORT 模式則是需要透過設定 RecoilEnv 來達到,在這種模式下,Recoil 會使用內部自己建立的 subscribeToRecoilValue 來判斷 atom 是否改變了,如果改變則用 useState 建立的 forceUpdate 來觸發渲染:

RecoilEnv.RECOIL_GKS_ENABLED.add('recoil_transition_support');

subscribeToRecoilValue 的設計跟 useSyncExternalStore 很像,基本上就是會判斷傳進去的狀態是否改變了,如果改變的時候就觸發 callback。如果有興趣內部實作的讀者再自己去看原始碼吧!

第二種 SYNC_EXTERNAL_STORE 模式則是會看 React 有沒有 useSyncExternalStore 可以使用,如果沒有會 fallback 到第一種 TRANSITION_SUPPORT 的模式。

SYNC_EXTERNAL_STORE: currentRendererSupportsUseSyncExternalStore()
? useRecoilValueLoadable_SYNC_EXTERNAL_STORE
: useRecoilValueLoadable_TRANSITION_SUPPORT

第三種 LEGACY 則是會使用前面說的類似 useSyncExternalStoresubscribeToRecoilValue來判斷 atom 是否改變,如果改變則觸發渲染。

Jotai

Jotai(日文的狀態)是由 Zustand 跟 Valtio 的作者 Daishi Kato 在 2020 年發布的基於 Atomic 的套件,它的 API 啟發於 Recoil,但使用起來比 Recoil 更簡單。

在 Jotai 中 atom 是用來建立 atom 狀態的設定檔,並不是像 React.useState 回傳可讀取的狀態,實際上的狀態是被存在於 store 中,需要透過 useAtom 才能讀寫狀態。

atomuseState 一樣都是傳入初始化的狀態,同時 atom 也可以被傳入到另一個 atom 中使用,相較於 Recoil 如果要產生 derived data,則是要使用 selector 這個 API,但在 Jotai 中統一都是使用 atom

import { atom } from 'jotai'

const countAtom = atom(0)
const countryAtom = atom('Japan')
const citiesAtom = atom(['Tokyo', 'Kyoto', 'Osaka'])
const mangaAtom = atom({ 'Dragon Ball': 1984, 'One Piece': 1997, Naruto: 1999 })
const isJapanAtom = atom((get) => get(countryAtom) === 'Japan')

useAtomuseState 的使用方式很類似,都是回傳一個 tuple,第一個值用來讀取狀態,第二個值用來設定狀態:

import { useAtom } from 'jotai'

function Counter() {
const [count, setCount] = useAtom(countAtom)
return (
<h1>
{count}
<button onClick={() => setCount((c) => c + 1)}>one up</button>
...

Jotai 如何解決渲染的問題

看到這裡,Jotai 與 Redux、Zustand、Recoil 解決渲染問題的方式都很相,在用 useAtom 的時候 Jotai 會自動判斷 atom 的值是否改變了,當改變時才會重新渲染該元件。

const [count, setCount] = useAtom(countAtom)

然而,既然與 Redux、Zustand、Recoil 的機制類似,那是不是內部實作都使用 useSyncExternalStore但是實際上不是這樣

Jotai 為了解決 time slicing 的問題,使用了 useReducer 來處理 re-render 的問題。而像是 Zustand 使用了 useSyncExternalStore ,在搭配 useTransition 就會發生非預期的問題,比如作者提供的一個範例 https://codesandbox.io/s/9ss9r6,在這個範例中預期應該要顯示「Pending…」,而不是 Suspense 的「Loading…」。

如果想要知道後多的細節可以參考作者發的這篇文章 Why useSyncExternalStore Is Not Used in Jotai,或是可以 follow 這個 discussion#2137

Recoil vs Jotai

Jotai 的官方文件中也有討論一直開著,有些人幫忙整理了兩者的不同,例如:

  • Jotai 的原始碼更加簡單
  • Jotai 有更少的 boilerplate code,不需要像 Recoil 建立 atom 時要使用 key
  • Recoil 的 bundle size 比 Jotai 多了 10 倍
  • 在 DX 上 Jotai 更加直覺

基本上以目前的趨勢來看, Jotai 的未來是優於 Recoil 的。過了幾年 Recoil 還放在 facebookexperimental 這個 GitHub repo,如果要選擇 Recoil 的話需要謹慎思考一下。

Proxy-based

以 proxy-based 實作的套件,較多人使用的套件有 Mobx 跟 Valtio,Mobx 已經行之有年,從 2015 年就已經問世,到現在仍然是許多開發者的選擇,每週還有 100 萬的下載量。而 Valtio 作為新起之秀,從 2020 年開始經過了三年,到目前為止每週大概有 30 萬的下載量,從趨勢看起來未來會越來越多人使用 Valtio。

使用 Valtio 的 proxy-state

Valtio 是由 Zustand 跟 Jotai 的作者 Daishi Kato 開發的一個 proxy-state 狀態管理工具,它使用起來非常容易上手,官方文件寫得很完整,各種實用情境都有舉例,而且也支援 TypeScript,如果是 React 的新手或是想要一個簡單的狀態管理套件,Valtio 可以作為首選之一。

在 Valtio 中兩個核心的 API 是 proxyuseSnapshotproxy 被用在代理原始的物件,當代理的物件改變時,Valtio 會通知使用這個物件的地方進行更新並重新渲染:

import { proxy, useSnapshot } from 'valtio'

const state = proxy({ count: 0, text: 'hello' })

要取得 proxy 的狀態則是用 useSnapshot ,而要改變狀態可以直接 mutate 原始的 state:

function Counter() {
const snap = useSnapshot(state)
return (
<div>
{snap.count}
<button onClick={() => ++state.count}>+1</button>
</div>
)
}

而 proxy 代理的不只是物件,也可以是類別或是另外一個 proxy:

// 代理 class
class User {
first = null
last = null
constructor(first, last) {
this.first = first
this.last = last
}
greet() {
return `Hi ${this.first}!`
}
get fullName() {
return `${this.first} ${this.last}`
}
}
const state = proxy(new User('Timo', 'Kivinen'))

// 代理 proxy
const obj1State = proxy({ a: 1 })
const obj2State = proxy({ a: 2 })

const state = proxy({
obj1: obj1State,
obj2: obj2State,
})

Valtio 如何解決渲染的問題

在 Valtio 中並不是使用如 Redux 或是 Zustand 的 selector 方式取得狀態,而是直接把代理的狀態直接拿出來使用,在風格上更接近 atom 的用法。Valtio 判斷代理的物件中任何一個屬性改變時,就會觸發元件重新渲染。

以下面這個例子來說,當 count 改變時兩個元件都會渲染,而且 text 改變也會造成兩個元件都渲染:

const state = proxy({ count: 0, text: 'hello' })

const ComponentA = () => {
const snap = useSnapshot(state)
return <div>{snap.count}</div>
}

const ComponentB = () => {
const snap = useSnapshot(state)
return <div>{snap.text}</div>
}

由此可知,Valtio 在根本的設計上比較適合較小的物件,否則容易因為物件的其中一個屬性改變了,造成多處的元件都被重新渲染。從這個用法看起來其實也很像是原生的 context API,只是不需要 context provider。

Valtio 在處理重新渲染的方式在 2021 年 9 月之前是使用 useReducer 作為重新渲染的手段,但在 #234 之後就改成了使用 useSyncExternalStore 偵測代理狀態是否改變,並且觸發重新渲染。

Mobx

Mobx 是非常老牌的全域狀態管理套件,跟 Redux 是差不多時期出現的套件,在目前較有名氣的狀態管理套件下載量排行第三,每週大約有 100 萬的下載量。

從現今為數眾多的狀態管理套件中,它的寫法可以說是獨樹一格,非常得不一樣,儘管後來 Mobx 推出了 hook API,但其核心的概念導致寫起來有點神奇,接下來就讓我們來看看吧!

會把 Mobx 歸類在 proxy-based 的狀態管理套件中,這意味著在它的底層有 proxy API 存在,Mobx 會「觀察」所有使用的狀態,在改變時通知相對應的元件渲染。

https://mobx.js.org/README.html

在 Mobx 中有許多建立 store 的方式,但基本上都可以理解成裝飾器模式,把自定義的狀態及 function 加上額外的功能。例如以下的例子,使用 @observable 監聽了 count 的變化,並且定義了一個 @action 叫做 setCount

import { action, observable } from 'mobx';

class Store {
@observable
count = 1;

@action
setCount = () => {
this.count++;
}
}

傳遞 store 方式官方推薦使用 React 的 context API,因為這樣比較好做單元測試。而在使用 store 的時候,需要在元件外使用 HOC observer 包起來。

import { createContext, useContext } from "react"
import ReactDOM from "react-dom"
import { observer } from "mobx-react-lite"

const StoreContext = createContext()

const App = observer(() => {
const store = useContext(StoreContext) // See the Timer definition above.
return (
<div>
<button>count++</button>
<span>Count: {store.count}</span>
</div>
)
})

ReactDOM.render(
<StoreContext.Provider value={new Store()}>
<App />
</StoreContext.Provider>,
document.body
)

Mobx 如何解決渲染的問題

在 Mobx 中同樣也優化渲染的機制,例如以下有兩個元件在 username 改變時,只有 MyComponent 會重新渲染:

const MyComponent = observer(() => {
const { todos, username } = useContext(StoreContext)

return (
<div>
{username}
<TodosView todos={todos} />
</div>
)
})

const TodosView = observer(() => {
const { todos } = useContext(StoreContext)

return (
<ul>
{todos.map(todo => <li>{todo}</li>)}
</ul>
)
})a

Mobx 的實作機制是看到目前為止最特別也是最複雜的,簡單來說,Mobx 的機制是觀察者模式,在使用 store 中的狀態時會觸發訂閱,而狀態改變時 Mobx 會通知相對應的元件觸發更新。

而實作觀察者的方式是將 observable 定義的屬性跟物件都用 Proxy 代理,並且在其屬性跟物件上都加了點料,在執行 getset 時都會觸發 Mobx 的觀察者模式。

我們會在需要使用 store 的元件加上 observer,當在元件裡面使用某個屬性時,該屬性就會被掛到 observer 上,然後再把 observer 掛到 Mobx 全域物件,可以想像成該 observer 訂閱了使用的屬性。當有屬性被改變時,就會把所有訂閱該屬性的 observer 都執行一遍。

在訂閱發布時,observer 內部會去呼叫 useSyncExternalStore 的 callback,通知 React 應該重新渲染該元件了。

以上是非常抽象的描述了 Mobx 的實作邏輯,如果對於實作有興趣的讀者再去看原始碼或是讀一些相關的文章吧!在這邊我們只要了解到大方向的實作細節即可。

Preact 的 signals

Signals 是 Preact 在 2022 年 9 月發表的狀態管理套件,其命名的靈感是來自於 SolidJS,而且是用 pure Javascript 撰寫的套件,如果需要的話,你甚至可以在 React、Vue、Svelte 中使用 @preact/signals-react

起初 Preact 團隊在一個 startup 團隊中發現,隨著專案越來越大,有 100 多對工程師在 commit code,對於 component 的 render 優化就變得非常難以管。

雖然有 useMemouseCallbackmemo 等等的優化方法,但是大型專案在優化 render 這一塊是非常不容易的,往往開發者都必須花費許多時間檢查 dependencies array 中的物件為何改變了,有時候這是非常不符合效益成本,為了優化 render 花費比開發功能更多的時間。

Preact 為了讓開發體驗以及優化的效果可以更好,所以建立了 Signals 這個套件,不再需要處理麻煩的 dependencies array,而且在專案中開箱即可使用。

而且相對於 Flux、Atomic、Proxy 等等套件解決的是元件等級的重新渲染問題,但 Signal 的目標是 element 等級的渲染問題。

以下面這個例子來說,如果用 React 的邏輯來看,預期當 count.value++ 的時候 <App/><Child/> 都會被重新渲染,這是 setState 時會觸發的流程。但是使用 @preact/signals-react 則是會直接破壞 reconciliation 的過程,在每一秒 count.value++ 時,只有 <h1> 會被重新渲染。

import { useSignal, useSignalEffect } from '@preact/signals-react';

function Child() {
console.log('render child');
return <div>child</div>;
}

export default function App() {
const count = useSignal(0);
useSignalEffect(() => {
setInterval(() => {
count.value++;
}, 1000);
});
console.log('rendering');

return (
<div>
<h1>{count}</h1>
<Child />
</div>
);
}

Signal 的設計跟 Mobx 還有 Valtio 很類似,都是讓狀態雙向綁定,但是做到重新渲染的顆粒度變成只有使用狀態的 element。

在 Preact 的官方文件中甚至提到使用了 signal 後,相比與原本使用 state 的背後是 virtual dom,其效能優化了許多倍,因為 signal 在傳遞狀態時會直接略過沒有使用到 signal 的元件。

https://preactjs.com/blog/introducing-signals/

Signal 是未來嗎?

https://npmtrends.com/ jotai-signal-vs-@preact/signals

從下載量來看得話 signal 的套件下載量是少之又少,會比較少人使用的原因除了是這兩個套件比較新之外,signal 並不是 React 團隊所推崇的狀態管理機制,因為它破壞了 React 的生命週期。

Dan 本人也提到 @preact/signals 的實作原理是基於一個脆弱的假設,React 完全並不支援 signal 的狀態管理機制,如果使用像是 @preact/signals 這種套件導致 React 發生問題,React 團隊無法幫忙找出問題。

以結論來說,目前在 React 生態系中使用 signal 並不是一個好的時間點或是好的選擇,不如使用原生的狀態管理機制或是較多人使用的狀態管理套件。

不過雖然說 signal 目前沒辦法在 React 中使用,但是許多框架已經逐漸擁抱這個概念,像是 SolidQwikVuePreactAngular 等等的框架都有實現 signal。

題外話,在 13 年前 https://knockoutjs.com/ 這個套件已經有 signal 這個概念

結論

在這篇文章中我們探討了使用 React 原生的狀態管理機制主要會遭遇以下兩個問題:

  • props drilling 的問題
  • context 造成整顆子樹渲染的問題

基本上 props drilling 的問題在本質上都是透過 context API 來解決,但就衍生 context 造成整顆子樹渲染的問題。我們可以看到不論是基於 Flux、Atomic、Proxy 實作的套件,除了 Jotai 以外,在偵測狀態改變並觸發元件渲染的實作都採用了 useSyncExternalStore 這個 API,只是會根據不同的核心概念實作。

最後也稍微提到了 signal 這個在 React 圈子較新的概念,但是實際上並不適合在 React 生態系中,而且面臨的 issue 可能 React 的開發者也無法解決。

以我個人的判斷,目前在團隊中使用 Redux、Zustand 跟 Jotai 是比較好的選擇;不考慮 Recoil 主因是 issue 太多而且至今還放在 facebookexperimental repo,如果要使用 Atomic 的寫法,Jotai 是更好的選擇;而 Valtio 看起來很酷,但是 mutable 的寫法與 React 經常使用的 immutable 寫法背道而馳,如果要在團隊中使用也許就要有更好的教育訓練,否則 coding style 會差別太多。Mobx 的寫法…,個人不愛 😅。

🎉 筆者最近開始經營 Instagram 技術小帳,平時會分享一些網頁開發的知識,歡迎大家
追蹤 @leo.web.dev

--

--

Leo Chiu
手寫筆記

每天進步一點點,在終點遇見更好的自己。 Instragram 小帳:@leo.web.dev