關於 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;
可以看到兩個實例沒有公用數據了
官網推薦的方法,雖然可以實現多實例,但是感覺有點麻煩,我自己給封裝了一下,把Context
、Provider
、useStore
使用工廠方法統一導出,使用起來更加簡單。
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