banner
Jim Luo

Jim Luo

A normal software engineer and an enthusiast in computer graphics and data visualization.
twitter
github
bilibili
nintendo switch
playstation

Zustand 和 React 上下文

本文是对原文的翻译,如有疏漏还望指正。

Zustand 熊的標誌坐在拱門下

Zustand 是一個很棒的全局客戶端狀態管理的庫。它簡單、快捷而有著較小的包體積。不過,有一點我不太喜歡:

狀態倉庫是全局的

好嗎?但這不就是全局狀態管理的意義之所在嗎?讓你的狀態在你的應用各個地方都是可用的?

有時,我認為是這樣的。然而,當我回看我最近幾年使用 zustand 的經歷,我經常意識到,我需要一些狀態在一個組件子樹中全局可用而不是整個應用。使用 zustand,是完全可以 - 甚至是鼓勵 - 去按照功能來創建多個小型的狀態倉庫。所以,如果我只需要在面板路由中使用面板篩選器的狀態,為什麼我需要我的面板過濾器倉庫全局可用呢?當然,我可以無痛地去使用它,但我發現一些使用全局狀態倉庫的弊端:

通過 Props 來初始化#

全局的狀態倉庫是在 React 組件生命週期外創建的,所以我們無法利用一個 prop 中的值來初始化我們的狀態倉庫。在一個全局狀態倉庫中,我們需要通過一個已知的默認狀態來創建,然後利用useEffect來從 props 到 store 同步狀態:

const useBearStore = create((set) => ({
  // ⬇️ initialize with default value
  bears: 0,
  actions: {
    increasePopulation: (by) =>
      set((state) => ({ bears: state.bears + by })),
    removeAllBears: () => set({ bears: 0 }),
  },
}))

const App = ({ initialBears }) => {
  //😕 write initialBears to our store
  React.useEffect(() => {
    useBearStore.set((prev) => ({ ...prev, bears: initialBears }))
  }, [initialBears])

  return (
    <main>
      <RestOfTheApp />
    </main>
  )
}

除了不想寫useEffect,還有兩個原因使它表現不理想:

  1. 當我們初次在觸發 effect 之前使用bears: 0去初始化<RestOfTheApp />,然後在正確的initialBears賦值上後會導致不止一次的渲染。
  2. 我們不需要利用initialBears去初始化我們的狀態倉庫 - 我們只是用於同步。所以如果initialBears發生改變,我們會看到我們的狀態倉庫同步更新。

測試#

我發現 zustand 的測試文檔十分讓人困惑且複雜。這的測試都是關於模擬 zustand 和重置其狀態倉庫等。我認為這一切都源於狀態倉庫是全局的這點。如果它被限定於一個組件子樹中,我們可以渲染那些組件的同時使其狀態倉庫保持隔離,不需要任何那些所謂的 “變通” 方法。

可復用性#

不是所有的狀態倉庫都是我們在我們的應用或特定路由中使用一次的單例。有時,我們想 zustand 的狀態倉庫可以在組件中復用。其中一個例子是過去我們設計系統中的一個複雜、多選組件。它使用本地狀態通過 React Context 自上而下傳遞來管理內部多選的狀態。當它有 50 或更多選項被選中的時候就會變得遲緩。它迫使我發了這條推文

如果一個 zustand 狀態倉庫是全局的,我們將無法在沒有共享和覆蓋彼此狀態的情況下多次實例化組件。


有趣的是,有一種方法能解決所有這類問題:

React Context#

React Context 就是那個方法在這顯得很滑稽和諷刺,因為使用 Context 作為狀態管理工具會立馬出現上述的問題。但這不是我推薦的。這樣做只是通過 React Context 共享狀態倉庫實例 - 而不是倉庫中的狀態本身。

概念上,這就是 React Query 在<QueryClientProvider>上的實現,redux對它的單一狀態倉庫也是如此。因為狀態倉庫的實例是靜態的單例,不會經常改變,我們把他們放到 React Context 中非常容易且不會導致重渲染的問題。然後,我們依舊可以為狀態倉庫創建訂閱者,這些訂閱者將通過 zustand 進行優化。這就是具體實現的樣子:

v5 語法
在這篇文章中,我將會展示 v5 的語法去整合 zustand 和 React Context。在此之前,zustand 有一個明確的createContext函數,導出自zustand/context

import { createStore, useStore } from 'zustand'

const BearStoreContext = React.createContext(null)

const BearStoreProvider = ({ children, initialBears }) => {
  const [store] = React.useState(() =>
    createStore((set) => ({
      bears: initialBears,
      actions: {
        increasePopulation: (by) =>
          set((state) => ({ bears: state.bears + by })),
        removeAllBears: () => set({ bears: 0 }),
      },
    }))
  )

  return (
    <BearStoreContext.Provider value={store}>
      {children}
    </BearStoreContext.Provider>
  )
}

主要的不同在於我們沒有像之前一樣使用開箱即用的create函數來創建實例。相反,我們依賴純 zustand 的createStore函數,這將更好地來創建一個狀態倉庫。同時我們可以在任何地方這麼做 - 甚至在組件內部。然而,我們必須確保創建狀態倉庫的行為只會發生一次。我們可以用 ref 來解決,但我更傾向於用useState。如果你想知道為什麼,我有一篇單獨的文章專門解答。

因為我們在組件內部創建了狀態倉庫,我們可以停止像initialBears這類 props,把他們傳遞到createStore中作為真正的初始值。使用useState初始化方法只會調用一次,所以 prop 的更新將不會傳遞到狀態倉庫中去。然後,我們把狀態倉庫實例化並傳遞給一個簡單的 React Context。這裡就不會有 zustand 的約束了。


之後,當我們想要從狀態倉庫中取出一些值進行消費時,都会用到這個上下文。為此,我們需要傳遞storeselector給從 zustand 中拿到的useStore鉤子。這是一個對應自定義鉤子的最佳抽象:

const useBearStore = (selector) => {
  const store = React.useContext(BearStoreContext)
  if (!store) {
    throw new Error('Missing BearStoreProvider')
  }
  return useStore(store, selector)
}

然後,我們就能像之前一樣使用useBearStore鉤子並利用一些原子選擇器導出其中的自定義鉤子:

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

向較於創建一個全局的狀態倉庫來說多了一些代碼,但它解決了三個問題:

  1. 正如例子中所示,我們可以利用 props 來初始化我們的狀態倉庫,因為我們從 React 組件樹內部創建的。
  2. 測試變得小菜一碟,因為我們可以選擇渲染一個包含了BearStoreProvider的組件,或我們可以渲染一個用於測試的組件。在這些場景中,已創建好的狀態倉庫能完全隔離測試,所以無需測試間無需重置狀態倉庫。
  3. 現在一個組件可以渲染一個BearStoreProvider來給它的子組件提供封裝好的 zustand 狀態倉庫。我們可以在一個頁面中隨心所欲地渲染這個組件 - 每個實例將有它獨立的狀態倉庫,從而我們實現了可復用。

最後即便zustand 文檔自豪稱無需 Context Provider 來訪問一個狀態倉庫,我認為有必要了解如何整合狀態倉庫的創建和 React Context,這能夠讓你得心應手地處理一些需封裝可復用的場景。就我而言,我使用這一抽象概念的次數比全局 zustand 狀態倉庫還多。😄


這就是今天我想聊的。如果你有任何問題,歡迎在twitter上找我,或是在評論區底下留言。⬇️

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。