Recoil 狀態管理方案的淺入淺出

語言: CN / TW / HK

本文作者:江水

背景: RecoilFacebook 推出的一款專門針對React應用的狀態管理庫,在一定程度上代表了目前的一種發展趨勢,在使用時覺得一些理念很先進,能極大地滿足作為一個前端開發者的資料需求,本文對 Recoil 的這些特性做一個梳理。

根據官網的介紹,Recoil 的資料定義了一個有向圖 (directed graph),狀態的變更是通過改變圖的根節點 (atom),再通過純函式 (selector) 流向 React 元件。

Pasted image 20220919200122.png

同時 Recoil 的狀態定義是增量和分散式的,增量意味著我們可以在用的時候再定義新的狀態,而不必將所有狀態提前定義好再消費。分散式意味著狀態的定義可以放在任何位置,不必統一註冊到一個檔案中。這樣的好處是一方面可以簡化狀態的定義過程,另一方面也可以很好地應用在 code-splitting 場景。

在一個應用中開啟 Recoil 非常簡單,只需要包裹一個 RecoilRoot 即可。

```js import { RecoilRoot } from 'recoil';

ReactDOM.render( , root); ```

狀態定義,原子和選擇器

Recoil 允許使用 atomselector 兩個函式定義基礎和推導狀態。

atom 基本用法,這裡定義了相關的原子屬性,需要使用唯一 key 來描述這個 atomRecoil 中不允許重複的 key 出現,包括後面提到的 selector

```js const firstNameAtom = atom({ key: 'first name atom', default: '' });

const lastNameAtom = atom({ key: 'last name atom', default: '' }); ```

使用時通過 useRecoilState 這個 hooks 獲取狀態,可以看到它和 useState 很像,所以可以很輕鬆地將傳統的React狀態遷移到 Recoil 中。

```diff function UserProfile() { - const [firstName, setFirstName] = useState(''); + const [firstName, setFirstName] = useRecoilState(firstNameAtom);

return (

{ firstName }
); } ```

很多時候我們只想獲取資料而不想修改,或者反之,此時可以用語法糖 useRecoilValueuseSetRecoilState

```js function UserProfile() { const firstName = useRecoilValue(firstNameAtom);

return (

{ firstName }
); } ```

Recoil 會根據哪裡用到了這些狀態自動建立一種依賴關係,當發生變更時 Recoil 只會通知對應的元件進行更新。

selector 的用法和 atom 很像,構造一個 selector 至少需要一個唯一的 keyget 函式。

js const nameSelector({ key: 'my name selector', get: ({ get }) => { return get(firstNameAtom) + ' ' + get(lastNameAtom); } });

selector 中可以讀寫任意 atom / selector ,沒有任何限制。只有 get 方法的 selector 是隻讀的,如果需要可寫,也支援傳入 set 方法。

js const nameSelector({ key: 'my name selector', get: ({ get }) => { return get(firstNameAtom) + ' ' + get(lastNameAtom); }, set: ({ get, set }, value) => { const names = value.split(' '); set(firstNameAtom, names?.[0]); set(lastNameAtom, names?.[1]); } });

值得一提的是,selector支援從網路非同步獲取資料,這裡才是有趣的開始,也是和其他狀態管理的最大的不同,Recoil的狀態不僅是純狀態,也可以是來自網路的狀態

js const userSelector = selector({ name: 'user selector', get: () => { return fetch('/api/user'); } });

使用 selector 時和 atom 一樣可以通過 useRecoilState, useRecoilValue, useSetRecoilState 這幾個 hook。

```js function App() { const user = useRecoilValue(userSelector);

...

} ```

這樣的特性使得我們的程式碼很容易重構,假如一開始一個屬性是一個 atom, 後面希望變成一個計算屬性,此時可以很輕鬆地替換這部分邏輯,而無需修改業務層程式碼。

Recoil 還可以更強大,用下面一張圖可以大致概括下,其完全可以當成一個統一的資料抽象層,將後端資料通過 http, ws, GraphQL 等技術對映到前端元件中。

Pasted image 20220915143934.png

atomFamily selectorFamily 批量建立狀態的解決方案

在一些場景中會有需要批量建立狀態的情況,我們會例項化多個相同的元件,每個元件都需要對應一個自己獨立的狀態元素,此時就可以使用 xxxFamily api。

```js const nodeAtom = atomFamily({ key: 'node atom', default: {} });

function Node({ nodeId }) { const [node, setNode] = useRecoilState(nodeAtom(nodeId)); } ```

可以看到,atomFamily 返回的是一個函式,而不是一個 RecoilState 物件。傳入不同的 nodeId 會檢查是否之前已存在,如果存在則複用之前的,不存在則建立並使用預設值初始化。

同理,對於 selectorFamily

``js const userSelector = selectorFamily({ key: 'user selector family', get: (userId) => () => { return fetch(/api/user/${userId}`); } });

function UserDetail({ userId }) { const user = useRecoilValue(userSelector(userId)); } ```

由於批量建立可能會導致記憶體洩漏,所以 Recoil 也提供了快取策略管理,分別為 lru, keep-all, most-recent,可以根據實際需要選取。

Suspense 與 Hooks

上文提到每個 atom, selector 背後可以是本地資料,也可以是網路狀態(對,沒錯, atom 也可以是個非同步資料,常用的如 atom 初始化是個非同步,後續變成同步資料),在元件消費時無需關心背後的實際來源,使用遠端資料就像使用本地資料一樣輕鬆。

來看一個普通的獲取資料並展示元件的例子。

```js function getUser() { return fetch('/api/user'); }

function LocalUserStatus() { const [loading, setLoading] = useState(false); const [user, setUser] = useState(null);

useEffect(() => { setLoading(true); getUser().then((user) => { setUser(user); setLoading(false); }) }, []);

if (loading) { return null; }

return (

{ user.name }
) } ```

對於這種開發習慣 (往往被稱為 Fetch-on-Render):我們需要一個 useEffect 來獲取資料,再需要設定一些 loading, error 狀態處理邊界狀態,如果這個資料不是一個放在全域性且處在頂層的資料,而是散落在子元件中消費,則每一個使用的地方都要執行類似的邏輯。

下面看下 Recoil 的寫法

```js const localUserAtom = atom({ key: 'local user status', default: selector({ // <-------- 預設值來自 selector key: 'user selector', get: () => { return fetch('/api/user'); } })
});

function LocalUserStatus() { const localUser = useRecoilValue(localUserAtom);

return (
  <div>
    { localUser.name }
  </div>
)

} ```

這裡在元件層是不關心資料從哪來的, Recoil 會自動按需請求資料。

相比之下,後者的程式碼就簡潔許多(Render-as-You-Fetch),而且背後並沒有發明新的概念,用到的都是 React 原生的特性,這個特性就是 Suspense

如果使用了一個非同步的 atomselector ,則外層需要一個 Suspense 處理網路未返回時的 loading 狀態。也可以套一層 ReactErrorBoundary 處理網路異常的情況。

```js // UserProfile 中使用了一個需要從網路中載入的資料 function LocalUserStatus() { const user = useRecoilValue(localUserAtom);

... }

function App() { return (

hello, 外部元件在這裡

    <Suspense fallback={<Loading />}>
        <LocalUserStatus />
    </Suspense>

    <div>底部</div>
</div>

); } ```

通過把通用的 LoadingError 邏輯剝離出去,使得一般元件內的條件分支減少 66%,首次渲染即是資料準備完成的狀態,減少了額外的處理邏輯以及 hooks 過早初始化問題。

螢幕錄製2022-09-22 14.18.33.gif

hooks 過早初始化問題可參考拙文: Recoil 這個狀態管理庫,用起來可能是最爽的


useRecoilValueLoadable(state) 讀取資料,但返回的是個Loadable

useRecoilValue 不同,useRecoilValueLoadable 不需要外層 Suspense ,相當於將邊界情況交給使用者處理。

Loadable 的物件結構如下: Pasted image 20220919182920.png 它的作用就是我們能夠獲取到當前資料是 loading, 還是已經 hasValue, 手動處理這些狀態,適合靈活處理頁面渲染的場景。

```js const userLoadable = useRecoilValueLoadable(userSelector);

const isLoading = userLoadable.state === 'loading'; const isError = userLoadable.state === 'hasError'; const value = userLoadable.getValue(); ```

Recoil 用來對映外部系統

在一些場景下我們希望 Recoil 能夠和外部系統進行同步,典型的例子例如 react-routerhistory 同步到 atom 中,原生 js 動畫庫狀態和 Recoil 同步,將 atom 和遠端 mongodb 同步。通過直接讀寫 atom 就能直接讀寫外部系統,開發效率可以大大提高。

這種場景下可以藉助 recoil-sync 這個包,下面列舉兩個案例。

使用 sharedb + recoil-sync 可以讓 atommongodb/postgres 等資料庫進行狀態同步,從而讓遠端資料庫修改如同本地修改一樣方便。

js // 對其的修改會實時同步到遠端mongodb中 const [name, setName] = useRecoilState(nameAtom);

Pasted image 20220915165033.png

使用 recoil-syncatompixi.js 動畫元素進行狀態同步

https://codesandbox.io/s/nice-swirles-dmdlq0?file=/src/animation-canvas.js

Pasted image 20220923151724.png

此時可以將畫布上的一些精靈變成受控模式。

由於同步過程中會產生資料格式校驗問題, recoil-sync 使用 @recoiljs/refine 用來提供資料校驗和不同版本資料遷移功能。

Recoil 狀態快照

由於狀態粒度較細,對於需要批量設定 RecoilState 的場景, RecoilSnapshot 的概念,適合 ssr 時注入首屏資料,建立快照進行回滾,批量更新等場景。

填充 SSR 的資料

```js function initState(snapshot) { snapshot.set(atoms.userAtom, { name: 'foo', }); snapshot.set(atoms.countAtom, 0); }

export default function App() { return ( ... ); } ```

應用資料回滾

```js function TimeMachine() { const snapshotRef = useRef(null); const [count, setCount] = useRecoilState(countAtom);

const onSave = useRecoilCallback( ({ snapshot }) => () => { snapshot.retain(); snapshotRef.current = snapshot; }, [] );

const onRevoca = useRecoilCallback( ({ gotoSnapshot }) => () => { if (snapshotRef.current) { gotoSnapshot(snapshotRef.current); } }, [] );

return (

); } ```

不使用 async-await也能實現非同步轉同步程式碼

React 的世界裡一直存在著一種很奇怪的程式碼技巧,這種技巧能夠不利用 generator 或者 async 就能達到非同步轉同步的功能,在瞭解 Recoil 的一些用法時我也留意到這種現象,很有意思,這裡介紹下: 假如 userSelector 是一個需要從網路中獲取的狀態,對其的讀取可視作一個非同步操作,但是在寫 selector 時我們可以以一種同步的方式來寫。

const userNameSeletor = selector({ key: 'user name selector', get: ({ get }) => { const user = get(userSelector); <--- 這裡背後是個網路請求 return user.name; } });

這種寫法之前出現過,在元件中使用 selector 時我們也沒有考慮其非同步性。

js function UserProfile() { const user = useRecoilValue(userProfile); <---- 這裡背後也是個網路請求 const userId = user.id; return <div>uid: {userId}</div>; }

在元件中使用時是利用了外層的 Suspense 執行,在上述的 get 回撥中內部也隱式地使用了相似手段,當發生非同步時 get 方法會將Promise當成異常丟擲,當非同步結束時再重新執行這個函式,所以這個函式本身會執行兩次,有點黑魔法的感覺,這也同樣要求我們在此時應該保證get是一個純函式。如果一個 selectorget 回撥中存在網路請求,那就不再是一個純函式,此時需要保證:網路請求是在所有非同步selector執行之後呼叫

```js // 正確的用法 const nameSelector = selector({ key: "name selector", get: async ({ get }) => { get(async1Selector); get(async2Selector); await new Promise((resolve) => { setTimeout(resolve, 0); }); return 1; } });

// 錯誤的用法 const nameSelector = selector({ key: "name selector", get: async ({ get }) => { get(async1Selector); await new Promise((resolve) => { setTimeout(resolve, 0); }); get(async2Selector); return 1; } }); ```

最後,關於程式碼直覺,心智負擔

最近很多人會討論一個庫是否適合引入時會說到這兩個詞,在對一個庫不瞭解的情況下我們很容易就說出“這個庫太複雜了”,“要記憶的api太多了” 這類的話。在 Recoil 的世界裡如果我們接受了 atom, selector ,那麼 atomFamily, selectorFamily 也很容易理解。由於已經習慣了 useState 那麼 useRecoilValue, useSetRecoilValue 也很容易接受, 都很符合 hooks 的直覺。

Recoil 的 api 和 react 自身的 useState, useCallback, Suspense 是概念一致的, 二者的使用反而會加深對 react 框架本身的理解,一脈相承,沒有引入其他的程式設計概念,api雖多但心智負擔並不大。舉個反例,如果在 react 中使用 observable 型別的狀態管理,我可能會思考 useEffect 在一些場景是否能夠按預期工作,雖然某些特性使用起來很舒服,但卻加深了心智負擔。

如果有誤還望指正。

本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!