使用 useSyncExternalStore 實現 react-redux

用 30 行實現 useSelector

Leo Chiu
手寫筆記
16 min readMar 28, 2020

--

前言

Redux 是現在前端常見狀態 (State) 管理庫,隨著前端應用擴展,狀態變得越來越複雜,當邏輯與狀態管理混合在一起時會讓前端程式難以維護。所以,我們需要有一個維護狀態的管理庫,幫我們將邏輯與狀態拆分開來,讓程式碼提高可讀性與維護性。

而在使用 React 建立前端應用時,如果想要連結 redux,我們經常會使用到 react-redux 這個函式庫。它在 v7.1 版以後提供了 hooks 的函式,不再需要仰賴 connect(),讓取得狀態與 dispatch action 改變 redux 中的狀態變得更容易,也更清楚明瞭。

Context API

但是,在談實作 react-redux 之前,想先談談 Context API 這個在 React 16.3 版後提供的一個強大 API。在 Context API 釋出之後,就有些人在談「是否 Context API 能夠取代 redux」,一直到最近,仍然還是有聽到這個問題。

Context API 的確很方便,對於 react 的初學者來說,光是基本概念就要學一陣子,為了管理狀態還跑出一個 redux。要了解 redux 就要先了解如何用正確的姿勢使用它,在 redux 中的狀態是單向流,只可讀不能寫,如果想要改變狀態需要 dispatch action 觸發 reducer,最後才能改變 redux 中的狀態。

Redux 的起手式算不上簡單,必須定義各種 action 的 type 與 payload,並且在 reducer 中設定各種 action 會如何改變 redux store 中的狀態,最後再用 createStore(reducer) 設定 redux store。

再望向 Context API 的使用方法,你只需要在一個高層級的父組件上使用 createContext() 建立一個 context,並且用 <Context.Provider> 向下傳遞 。如果想要取得 context,只需要在元件中呼叫 useContext 就可以輕易的拿到父元件的 context。

聽起來 Context API 比 redux 更好上手,但是你以為 Context API 沒問題嗎?

Context API 在元件中造成的重新渲染問題

🔗React 官方文件中有提到如何優化 Context 造成的 re-render 問題,基本上就是 useCallbackuseMemo 的組合技,防止每次 MyApp re-render 的時候也造成使用 AuthContext 的元件也被 re-render。

import { useCallback, useMemo } from 'react';

function MyApp() {
const [currentUser, setCurrentUser] = useState(null);

const login = useCallback((response) => {
storeCredentials(response.credentials);
setCurrentUser(response.user);
}, []);

const contextValue = useMemo(() => ({
currentUser,
login
}), [currentUser, login]);

return (
<AuthContext.Provider value={contextValue}>
<Page />
</AuthContext.Provider>
);
}

但是如此一來 useCallbackuseMemo 變成了在使用 Context 時優化的必要手段,在心智負擔上是挺令人煩躁的。

所以才會有諸多的狀態管理庫出現,諸如 Redux、Zustand、Jotai、Valtio 等等,他們內部已經都優化了 re-render 的問題,使用上也很親民。

使用 useSelector 解決重新渲染的問題

React-redux 提供的 useSelector 解決了 re-render 的問題,我們不需要使用 useMemo 或是 useCallbackuseSelector 會自動判斷取得的狀態是否改變,然後觸發 re-render。

究竟 react-redux 是怎麼做到讓使用到 useSelector 的元件,當狀態改變時才 re-render 呢?我們簡單實作一個類似 react-redux 的套件,來看看它是怎麼做到這件事。

從零開始實現與 redux 連結的橋梁

這篇文章實現的部分:

  • useStore: 取得 redux store
  • useDispatch: 能夠 dispatch action 至 redux store。
  • useSelector: 能夠取得 redux store 中的狀態。

實作完,可以動的程式碼 👉Codesanbox

Context

react-redux 原始碼:https://github.com/reduxjs/react-redux/blob/master/src/components/Context.ts

建立一個 Context,我們會用這個 Context 來儲存 redux store。

import React from "react";
import { Store } from "redux";

interface ContextType {
store: Store;
}
const Context = React.createContext<ContextType | null>(null);

export default Context;

<Provider>

react-redux 原始碼:https://github.com/reduxjs/react-redux/blob/master/src/components/Provider.tsx

使用 Redux 的起手式是在一個高層的元件中定義 <Provider>,並在使用 useSelector() 或是 useDispatch() 的時候從 <Provider> 取得 redux store,再打 redux 的 API 管理狀態。

import { configureStore } from '@reduxjs/toolkit'

const store = configureStore({
reducer: {},
})

const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
<Provider store={store}>
<App />
</Provider>,
)

<Provider> 的實現很簡單,主要是用 Context.Provider 將 redux 傳遞到子元件中。

import React, { FC } from "react";
import { Store } from "redux";
import Context from "./Context";

interface ProviderProps {
store: Store;
}

const Provider: FC<ProviderProps> = ({ store, children }) => {
return <Context.Provider value={{ store }}>{children}</Context.Provider>;
};

export default Provider;

useStore

react-redux 原始碼:https://github.com/reduxjs/react-redux/blob/master/src/hooks/useStore.ts

useStore,主要是為了從 context 中取得 redux store,並且確認 context 已經提供了 redux store,如果沒有提供就丟出例外。

import { useContext } from "react";
import ReactReduxContext from "./Context";

export function useStore() {
const contextValue = useContext(ReactReduxContext);

if (!contextValue) {
throw new Error(
"could not find react-redux context value; please ensure the component is wrapped in a <Provider>"
);
}

return contextValue;
}

useDispatch

react-redux 原始碼:https://github.com/reduxjs/react-redux/blob/master/src/hooks/useDispatch.js

useDispatch() 實作的邏輯很簡單,先使用 useStore 取得 redux store,然後呼叫 redux 提供的 dispatch 觸發 action:

import { Dispatch, Action } from "redux";
import { useStore } from "./useStore";

export function useDispatch<A extends Action>() {
const { store } = useStore();
return store.dispatch as Dispatch<A>;
}

useSelector

react-redux 原始碼:https://github.com/reduxjs/react-redux/blob/master/src/hooks/useSelector.ts

我們先看一下官方文件的定義:

type RootState = ReturnType<typeof store.getState>
type SelectorFn = <Selected>(state: RootState) => Selected
type EqualityFn = (a: any, b: any) => boolean
export type DevModeCheckFrequency = 'never' | 'once' | 'always'

interface UseSelectorOptions {
equalityFn?: EqualityFn
devModeChecks?: {
stabilityCheck?: DevModeCheckFrequency
identityFunctionCheck?: DevModeCheckFrequency
}
}

const result: Selected = useSelector(
selector: SelectorFn,
options?: EqualityFn | UseSelectorOptions
)

如果想要取得 redux 中 count 的值,我們會傳入一個 callback function 到 useSelector() 的第一個參數,讓它能夠回傳 redux 中 count 的值:

const count = useSelect(state => state.count)

useSelector 有第二個參數 equalityFn,用於重新改寫比較狀態的方式。預設的 equalityFn 用於當有一個 action 被 dispatch 到 redux store 中時,會使用 === 嚴格判斷上次狀態與更新後的狀態有沒有變動,如果有變動,則強制渲染該元件。

👉 冷門但是很好用的 React hook — useSyncExternalStoreWithSelector

在了解如何實作 useSelector 之前,我們先來認識一個套件 use-sync-external-storeuseSyncExternalStore 是在 React 18 被發布出來的一個 hook,React 團隊為了向後兼容,不用 18 以前的版本也可以使用這個 hook,所以便有了這個套件。

React 官方文件的介紹是「useSyncExternalStore is a React Hook that lets you subscribe to an external store.」它的使用方式會像這個樣子:

const snapshot = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot?
)

useSyncExternalStore 共有三個參數:

  • subscribe:會接收一個 callback,用在當狀態改變時 subscribe 會被呼叫,並且可以使用 callback 觸發元件 re-render。
  • getSnapshot:用在取得該元件需要的狀態,當狀態改變的時候(透過 Object.is 比較),將會觸發元件 re-render
  • getServerSnapshot: 會用在取得初始化的狀態,它只會在 SSR 的時候使用,當進行 hydration 的時候才會調用這個參數。

useSyncExternalStoreWithSelector 是一個在 useSyncExternalStore 底下的 hook,基本上我們可以理解為它是被用來支援 Redux 跟 Zustand 的 selector 模式,因為這兩個狀態管理套件都實現了 Flux pattern,在取得狀態的方式都是使用 selector。它的使用方式如下:

const snapshot = useSyncExternalStoreWithSelector(
subscribe,
getSnapshot,
getServerSnapshot,
selector,
isEqual?,
)

前三個參數基本上跟 useSyncExternalStore 一樣,只是多了兩個參數:

  • selector:用來取得 store 中的 selector,可以理解為 useSelector 的第一個參數
  • isEqual:用來判斷使用 selector 取得的狀態是否改變了,可以理解為 useSelector 的第二個參數

在理解了 useSyncExternalStoreWithSelector 之後就可以來實作 useSelector 了,這個實現只有大約 30 行,主要依賴上述的 hook 來訂閱元件所需要的狀態,並且元件的 re-render 也是仰賴這個 hook:

import { useCallback } from "react";
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/with-selector";
import { useStore } from "./useStore";

type Selector<State, Selected> = (state: State) => Selected;
type EqualityFn<Selected> = (a: Selected, b: Selected) => boolean;

const defaultEqualityFn = <T>(a: T, b: T) => a === b;

export function useSelector<State, Selected>(
selector: Selector<State, Selected>,
equalityFn: EqualityFn<Selected> = defaultEqualityFn
) {
const { store } = useStore();

const wrappedSelector = useCallback((state: State) => {
const selected = selector(state);
return selected;
}, []);

const selectedState = useSyncExternalStoreWithSelector(
store.subscribe,
store.getState,
store.getState,
wrappedSelector,
equalityFn
);

return selectedState;

當我們使用了 useSyncExternalStoreWithSelector 之後,所有事情都變得相對簡單,這個 hook 會幫我們使用 selectorstore.getState() 中取得相對應的值,並且訂閱這個取得的值有沒有改變,改變了之後就呼叫 store.subscribe ,並且通知相關的元件 re-render。

總結

實作完,可以動的程式碼 👉Codesanbox

在這篇文章中,我們實現了一個簡易版本的 react-redux,並且使用 useSyncExternalStoreWithSelector 用 30 行實現了 useSelector

現在 Redux、Zustand、Recoil、Valtio、Mobx 等等狀態管理套件都使用 useSyncExternalStore 來處理訂閱狀態與 re-render,自行用 useStateuseReducer 觸發 re-render 已經不是大宗。因為 React 18 之後的 concurrent rendering 會造成 tearing,所以基本上這些狀態管理套件都改用了 useSyncExternalStore 來讀取外部的資料。

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

--

--

手寫筆記
手寫筆記

Published in 手寫筆記

學習永無止盡,我們一起學習。

Leo Chiu
Leo Chiu

Written by Leo Chiu

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

No responses yet