關於 zustand 的一些最佳實踐

前言

看過我文章的人,應該知道React狀態管理庫中我比較喜歡使用 Zustand 的,因爲使用起來非常簡單,沒有啥心智負擔。這篇文章給大家分享一下,我這段時間使用 zustand 的一些心得和個人認爲的最佳實踐。

優化

在 React 項目裏,最重要優化可能就是解決重複渲染的問題了。使用 zustand 的時候,如果不小心,也會導致一些沒用的渲染。

舉個例子:

創建一個存放主題和語言類型的 store

import { create } from 'zustand';

interface State {
  theme: string;
  lang: string;
}

interface Action {
  setTheme: (theme: string) => void;
  setLang: (lang: string) => void;
}

const useConfigStore = create<State & Action>((set) =({
  theme: 'light',
  lang: 'zh-CN',
  setLang: (lang: string) => set({lang}),
  setTheme: (theme: string) => set({theme}),
}));

export default useConfigStore;

分別創建兩個組件,主題組件和語言類型組件

import useConfigStore from './store';

const Theme = () => {

  const { theme, setTheme } = useConfigStore();
  console.log('theme render');
  
  return (
    <div>
      <div>{theme}</div>
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>切換</button>
    </div>
  )
}

export default Theme;
import useConfigStore from './store';

const Lang = () => {

  const { lang, setLang } = useConfigStore();

  console.log('lang render...');

  return (
    <div>
      <div>{lang}</div>
      <button onClick={() => setLang(lang === 'zh-CN' ? 'en-US' : 'zh-CN')}>切換</button>
    </div>
  )
}

export default Lang;

按照上面寫法,改變 theme 會導致 Lang 組件渲染,改變 lang 會導致 Theme 重新渲染,但是實際上這兩個都沒有關係,怎麼優化這個呢,有以下幾種方法。

方案一

import useConfigStore from './store';

const Theme = () => {

  const theme = useConfigStore((state) => state.theme);
  const setTheme = useConfigStore((state) => state.setTheme);

  console.log('theme render');

  return (
    <div>
      <div>{theme}</div>
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>切換</button>
    </div>
  )
}

export default Theme;

把值單個 return 出來,zustand 內部會判斷兩次返回的值是否一樣,如果一樣就不重新渲染。

這裏因爲只改變了 lang,theme 和 setTheme 都沒變,所以不會重新渲染。

方案二

上面寫法如果變量很多的情況下,要寫很多遍useConfigStore,有點麻煩。可以把上面方案改寫成這樣,變量多的時候簡單一些。

import useConfigStore from './store';

const Theme = () => {

  const { theme, setTheme } = useConfigStore(state => ({
    theme: state.theme,
    setTheme: state.setTheme,
  }));

  console.log('theme render');

  return (
    <div>
      <div>{theme}</div>
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>切換</button>
    </div>
  )
}

export default Theme;

上面這種寫法是不行的,因爲每次都返回了新的對象,即使 theme 和 setTheme 不變的情況下,也會返回新對象,zustand 內部拿到返回值和上次比較,發現每次都是新的對象,然後重新渲染。

上面情況,zustand 提供瞭解決方案,對外暴露了一個useShallow方法,可以淺比較兩個對象是否一樣。

import { useShallow } from 'zustand/react/shallow';
import useConfigStore from './store';

const Theme = () => {

  const { theme, setTheme } = useConfigStore(
    useShallow(state => ({
      theme: state.theme,
      setTheme: state.setTheme,
    }))
  );

  console.log('theme render');

  return (
    <div>
      <div>{theme}</div>
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>切換</button>
    </div>
  )
}

export default Theme;

方案三

上面兩種寫法是官方推薦的寫法,但是我覺得還是很麻煩,我自己封裝了一個useSelector方法,使用起來更簡單一點。

import { pick } from 'lodash-es';

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

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

type Many<T> = T | readonly T[];

export function useSelector<S extends object, P extends keyof S>(
  paths: Many<P>
)(state: S) => Pick<S, P> {
  const prev = useRef<Pick<S, P>>({} as Pick<S, P>);

  return (state: S) ={
    if (state) {
      const next = pick(state, paths);
      return shallow(prev.current, next) ? prev.current : (prev.current = next);
    }
    return prev.current;
  };
}

useSelector 主要使用了 lodash 裏的 pick 方法,然後使用了 zustand 對外暴露的shallow方法,進行對象淺比較。

import useConfigStore from './store';
import { useSelector } from './use-selector';

const Theme = () => {

  const { theme, setTheme } = useConfigStore(
    useSelector(['theme', 'setTheme'])
  );

  console.log('theme render');

  return (
    <div>
      <div>{theme}</div>
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>切換</button>
    </div>
  )
}

export default Theme;

封裝的useSelector只需要傳入對外暴露的字符串數組就行了,不用再寫方法了,省了很多代碼,同時還保留了 ts 的類型推斷。

終極方案

看一下這個代碼,分析一下,前面 theme 和 setTheme 和後面 useSelector 的參數是一樣的,那我們能不能寫一個插件,自動把const { theme, setTheme } = useStore();轉換爲const { theme, setTheme } = useStore(useSelector(['theme', 'setTheme']));,肯定是可以的。

因爲項目是 vite 項目,所以這裏寫的是 vite 插件,webpack 插件實現和這個差不多。

因爲要用到 babel 代碼轉換,所以需要安裝 babel 幾個依賴

pnpm i @babel/generator @babel/parser @babel/traverse @babel/types -D

@babel/parser 可以把代碼轉換爲抽象語法樹

@babel/traverse 可以轉換代碼

@babel/generator 把抽象語法樹生成代碼

@babel/types 快速創建節點

插件完整代碼,具體可以看一下代碼註釋

import generate from '@babel/generator';
import parse from '@babel/parser';
import traverse from "@babel/traverse";
import * as t from '@babel/types';

export default function zustand() {
  return {
    name: 'zustand',
    transform(src, id) {

      // 過濾非 .tsx 文件
      if (!/\.tsx?$/.test(id)) {
        return {
          code: src,
          map: null, // 如果可行將提供 source map
        };
      }

      // 把代碼轉換爲ast
      const ast = parse.parse(src, { sourceType: 'module' });

      let flag = false;

      traverse.default(ast, {
        VariableDeclarator: function (path) {
          // 找到變量爲useStore
          if (path.node?.init?.callee?.name === 'useStore') {
            // 獲取變量名
            const keys = path.node.id.properties.map(o => o.value.name);
            // 給useStore方法注入useSelector參數
            path.node.init.arguments = [
              t.callExpression(
                t.identifier('useSelector'),
                [t.arrayExpression(
                  keys.map(o => t.stringLiteral(o)
                ))]
              )
            ];
            flag = true;
          }
        },
      });

      if (flag) {
        // 如果沒有找到useSelector,則自動導入useSelector方法
        if (!src.includes('useSelector')) {
          ast.program.body.unshift(
            t.importDeclaration([
              t.importSpecifier(
                t.identifier('useSelector'), 
                t.identifier('useSelector')
              )],
              t.stringLiteral('useSelector')
            )
          )
        }

        // 通過ast生成代碼
        const { code } = generate.default(ast);

        return {
          code,
          map: null,
        }
      }

      return {
        code: src,
        map: null, 
      };
    },
  };
}

在 vite 配置中,引入剛纔寫的插件

把 Theme 裏 useSelector 刪除

看一下轉換後的文件,把 useSelector 自動注入進去了

持久化

把 zustand 裏的數據持久化到 localstorage 或 sessionStorage 中,官方提供了中間件,用起來很簡單,我想和大家分享的是,只持久化某個字段,而不是整個對象。

持久化整個對象

import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';

interface State {
  theme: string;
  lang: string;
}

interface Action {
  setTheme: (theme: string) => void;
  setLang: (lang: string) => void;
}

const useConfigStore = create(
  persist<State & Action>(
    (set) =({
      theme: 'light',
      lang: 'zh-CN',
      setLang: (lang: string) => set({lang}),
      setTheme: (theme: string) => set({theme}),
    }),
    {
      name: 'config',
      storage: createJSONStorage(() => localStorage),
    }
  )
);

export default useConfigStore;

如果想只持久化某個字段,可以使用partialize方法

調試

當 store 裏數據變得複雜的時候,可以使用redux-dev-tools瀏覽器插件來查看 store 裏的數據,不過需要使用devtools中間件。

可以看到每一次值的變化

默認操作名稱都是 anonymous 這個名字,如果我們想知道調用了哪個函數,可以給 set 方法傳第三個參數,這個表示方法名。

還可以回放動作

多實例

zustand 的數據默認是全局的,也就是說每個組件訪問的數據都是同一個,那如果寫了一個組件,這個組件在多個地方使用,如果用默認方式,後面的數據會覆蓋掉前面的,這個不是我們想要的。

爲了解決這個問題,官方推薦這樣做:

import React, { createContext, useRef } from 'react';
import { StoreApi, createStore } from 'zustand';

interface State {
  theme: string;
  lang: string;
}

interface Action {
  setTheme: (theme: string) => void;
  setLang: (lang: string) => void /*  */;
}


export const StoreContext = createContext<StoreApi<State & Action>>(
  {} as StoreApi<State & Action>
);

export const StoreProvider = ({children}: any) ={
  const storeRef = useRef<StoreApi<State & Action>>();

  if (!storeRef.current) {
    storeRef.current = createStore<State & Action>((set) =({
      theme: 'light',
      lang: 'zh-CN',
      setLang: (lang: string) => set({lang}),
      setTheme: (theme: string) => set({theme}),
    }));
  }

  return React.createElement(
    StoreContext.Provider,
    {value: storeRef.current},
    children
  );
};

使用了 React 的 context

使用 Theme 組件來模擬兩個實例,使用 StoreProvider 包裹 Theme 組件

import './App.css'
import { StoreProvider } from './store'
import Theme from './theme'

function App() {

  return (
    <>
      <StoreProvider>
        <Theme />
      </StoreProvider>
      <StoreProvider>
        <Theme />
      </StoreProvider>
    </>
  )
}

export default App

Theme 組件

import { useContext } from 'react';
import { useStore } from 'zustand';
import { StoreContext } from './store';

const Theme = () => {

  const store = useContext(StoreContext);
  const { theme, setTheme } = useStore(store);

  return (
    <div>
      <div>{theme}</div>
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>切換</button>
    </div>
  )
}

export default Theme;

可以看到兩個實例沒有公用數據了

官網推薦的方法,雖然可以實現多實例,但是感覺有點麻煩,我自己給封裝了一下,把ContextProvideruseStore使用工廠方法統一導出,使用起來更加簡單。

import React, { useContext, useRef } from 'react';
import {
  StateCreator,
  StoreApi,
  createStore,
  useStore as useExternalStore,
} from 'zustand';

type ExtractState<S> = S extends {getState: () => infer X} ? X : never;

export const createContext = <T>(store: StateCreator<T, [][]>) ={
  const Context = React.createContext<StoreApi<T>>({} as StoreApi<T>);

  const Provider = ({children}: any) ={
    const storeRef = useRef<StoreApi<T> | undefined>();
    if (!storeRef.current) {
      storeRef.current = createStore<T>(store);
    }
    return React.createElement(
      Context.Provider,
      {value: storeRef.current},
      children
    );
  };

  function useStore(): T;
  function useStore<U>(selector: (state: ExtractState<StoreApi<T>>) => U): U;
  function useStore<U>(selector?: (state: ExtractState<StoreApi<T>>) => U): U {
    const store = useContext(Context);
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    return useExternalStore(store, selector);
  }

  return {Provider, Context, useStore};
};

引入Provider

import './App.css'
import Theme from './theme'

import { Provider } from './store'

function App() {
  return (
    <>
      <Provider>
        <Theme />
      </Provider>
      <Provider>
        <Theme />
      </Provider>
    </>
  )
}

export default App

在 Theme 組件中使用useStore,並且可以和前面封裝的useSelector配合使用。

import { useStore } from './store';
import { useSelector } from './use-selector';

const Theme = () => {

  const { theme, setTheme } = useStore(useSelector(['theme', 'setTheme']));

  return (
    <div>
      <div>{theme}</div>
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>切換</button>
    </div>
  )
}

export default Theme;

最後

以上就是我這段時間使用 zustand 的一些心得,歡迎大家指正。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Fw7Uk-f7ye9Tk25VYf8cHw