看过我文章的人,应该知道React状态管理库中我比较喜欢使用Zustand的,因为使用起来非常简单,没有啥心智负担。这篇文章给大家分享一下,我这段时间使用zustand的一些心得和个人认为的最佳实践。

在React项目里,最重要优化可能就是解决重复渲染的问题了。使用zustand的时候,如果不小心,也会导致一些没用的渲染。

举个例子:

创建一个存放主题和语言类型的store

 1import { create } from 'zustand';
 2
 3interface State {
 4  theme: string;
 5  lang: string;
 6}
 7
 8interface Action {
 9  setTheme: (theme: string) => void;
10  setLang: (lang: string) => void;
11}
12
13const useConfigStore = create<State & Action>((set) => ({
14  theme: 'light',
15  lang: 'zh-CN',
16  setLang: (lang: string) => set({lang}),
17  setTheme: (theme: string) => set({theme}),
18}));
19
20export default useConfigStore;

分别创建两个组件,主题组件和语言类型组件

 1import useConfigStore from './store';
 2
 3const Theme = () => {
 4
 5  const { theme, setTheme } = useConfigStore();
 6  console.log('theme render');
 7  
 8  return (
 9    <div>
10      <div>{theme}</div>
11      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>切换</button>
12    </div>
13  )
14}
15
16export default Theme;
 1import useConfigStore from './store';
 2
 3const Lang = () => {
 4
 5  const { lang, setLang } = useConfigStore();
 6
 7  console.log('lang render...');
 8
 9  return (
10    <div>
11      <div>{lang}</div>
12      <button onClick={() => setLang(lang === 'zh-CN' ? 'en-US' : 'zh-CN')}>切换</button>
13    </div>
14  )
15}
16
17export default Lang;

按照上面写法,改变theme会导致Lang组件渲染,改变lang会导致Theme重新渲染,但是实际上这两个都没有关系,怎么优化这个呢,有以下几种方法。

方案一

 1import useConfigStore from './store';
 2
 3const Theme = () => {
 4
 5  const theme = useConfigStore((state) => state.theme);
 6  const setTheme = useConfigStore((state) => state.setTheme);
 7
 8  console.log('theme render');
 9
10  return (
11    <div>
12      <div>{theme}</div>
13      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>切换</button>
14    </div>
15  )
16}
17
18export default Theme;

把值单个return出来,zustand内部会判断两次返回的值是否一样,如果一样就不重新渲染。

这里因为只改变了lang,theme和setTheme都没变,所以不会重新渲染。

方案二

上面写法如果变量很多的情况下,要写很多遍useConfigStore,有点麻烦。可以把上面方案改写成这样,变量多的时候简单一些。

 1import useConfigStore from './store';
 2
 3const Theme = () => {
 4
 5  const { theme, setTheme } = useConfigStore(state => ({
 6    theme: state.theme,
 7    setTheme: state.setTheme,
 8  }));
 9
10  console.log('theme render');
11
12  return (
13    <div>
14      <div>{theme}</div>
15      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>切换</button>
16    </div>
17  )
18}
19
20export default Theme;

上面这种写法是不行的,因为每次都返回了新的对象,即使theme和setTheme不变的情况下,也会返回新对象,zustand内部拿到返回值和上次比较,发现每次都是新的对象,然后重新渲染。

上面情况,zustand提供了解决方案,对外暴露了一个useShallow方法,可以浅比较两个对象是否一样。

 1import { useShallow } from 'zustand/react/shallow';
 2import useConfigStore from './store';
 3
 4const Theme = () => {
 5
 6  const { theme, setTheme } = useConfigStore(
 7    useShallow(state => ({
 8      theme: state.theme,
 9      setTheme: state.setTheme,
10    }))
11  );
12
13  console.log('theme render');
14
15  return (
16    <div>
17      <div>{theme}</div>
18      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>切换</button>
19    </div>
20  )
21}
22
23export default Theme;

方案三

上面两种写法是官方推荐的写法,但是我觉得还是很麻烦,我自己封装了一个useSelector方法,使用起来更简单一点。

 1import { pick } from 'lodash-es';
 2
 3import { useRef } from 'react';
 4import { shallow } from 'zustand/shallow';
 5
 6type Pick<T, K extends keyof T> = {
 7  [P in K]: T[P];
 8};
 9
10type Many<T> = T | readonly T[];
11
12export function useSelector<S extends object, P extends keyof S>(
13  paths: Many<P>
14): (state: S) => Pick<S, P> {
15  const prev = useRef<Pick<S, P>>({} as Pick<S, P>);
16
17  return (state: S) => {
18    if (state) {
19      const next = pick(state, paths);
20      return shallow(prev.current, next) ? prev.current : (prev.current = next);
21    }
22    return prev.current;
23  };
24}
25

useSelector主要使用了lodash里的pick方法,然后使用了zustand对外暴露的shallow方法,进行对象浅比较。

 1import useConfigStore from './store';
 2import { useSelector } from './use-selector';
 3
 4const Theme = () => {
 5
 6  const { theme, setTheme } = useConfigStore(
 7    useSelector(['theme', 'setTheme'])
 8  );
 9
10  console.log('theme render');
11
12  return (
13    <div>
14      <div>{theme}</div>
15      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>切换</button>
16    </div>
17  )
18}
19
20export default Theme;

封装的useSelector只需要传入对外暴露的字符串数组就行了,不用再写方法了,省了很多代码,同时还保留了ts的类型推断。

终极方案

看一下这个代码,分析一下,前面theme和setTheme和后面useSelector的参数是一样的,那我们能不能写一个插件,自动把const { theme, setTheme } = useStore();转换为const { theme, setTheme } = useStore(useSelector(['theme', 'setTheme']));,肯定是可以的。

因为项目是vite项目,所以这里写的是vite插件,webpack插件实现和这个差不多。

因为要用到babel代码转换,所以需要安装babel几个依赖

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

@babel/parser可以把代码转换为抽象语法树

@babel/traverse可以转换代码

@babel/generator把抽象语法树生成代码

@babel/types快速创建节点

插件完整代码,具体可以看一下代码注释

 1import generate from '@babel/generator';
 2import parse from '@babel/parser';
 3import traverse from "@babel/traverse";
 4import * as t from '@babel/types';
 5
 6export default function zustand() {
 7  return {
 8    name: 'zustand',
 9    transform(src, id) {
10
11      
12      if (!/\.tsx?$/.test(id)) {
13        return {
14          code: src,
15          map: null, 
16        };
17      }
18
19      
20      const ast = parse.parse(src, { sourceType: 'module' });
21
22      let flag = false;
23
24      traverse.default(ast, {
25        VariableDeclarator: function (path) {
26          
27          if (path.node?.init?.callee?.name === 'useStore') {
28            
29            const keys = path.node.id.properties.map(o => o.value.name);
30            
31            path.node.init.arguments = [
32              t.callExpression(
33                t.identifier('useSelector'),
34                [t.arrayExpression(
35                  keys.map(o => t.stringLiteral(o)
36                ))]
37              )
38            ];
39            flag = true;
40          }
41        },
42      });
43
44      if (flag) {
45        
46        if (!src.includes('useSelector')) {
47          ast.program.body.unshift(
48            t.importDeclaration([
49              t.importSpecifier(
50                t.identifier('useSelector'), 
51                t.identifier('useSelector')
52              )],
53              t.stringLiteral('useSelector')
54            )
55          )
56        }
57
58        
59        const { code } = generate.default(ast);
60
61        return {
62          code,
63          map: null,
64        }
65      }
66
67      return {
68        code: src,
69        map: null, 
70      };
71    },
72  };
73}
74

在vite配置中,引入刚才写的插件

把Theme里useSelector删除

看一下转换后的文件,把useSelector自动注入进去了

把zustand里的数据持久化到localstorage或sessionStorage中,官方提供了中间件,用起来很简单,我想和大家分享的是,只持久化某个字段,而不是整个对象。

持久化整个对象

 1import { create } from 'zustand';
 2import { createJSONStorage, persist } from 'zustand/middleware';
 3
 4interface State {
 5  theme: string;
 6  lang: string;
 7}
 8
 9interface Action {
10  setTheme: (theme: string) => void;
11  setLang: (lang: string) => void;
12}
13
14const useConfigStore = create(
15  persist<State & Action>(
16    (set) => ({
17      theme: 'light',
18      lang: 'zh-CN',
19      setLang: (lang: string) => set({lang}),
20      setTheme: (theme: string) => set({theme}),
21    }),
22    {
23      name: 'config',
24      storage: createJSONStorage(() => localStorage),
25    }
26  )
27);
28
29export default useConfigStore;

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

当store里数据变得复杂的时候,可以使用redux-dev-tools浏览器插件来查看store里的数据,不过需要使用devtools中间件。

可以看到每一次值的变化

默认操作名称都是anonymous这个名字,如果我们想知道调用了哪个函数,可以给set方法传第三个参数,这个表示方法名。

还可以回放动作

zustand的数据默认是全局的,也就是说每个组件访问的数据都是同一个,那如果写了一个组件,这个组件在多个地方使用,如果用默认方式,后面的数据会覆盖掉前面的,这个不是我们想要的。

为了解决这个问题,官方推荐这样做:

 1import React, { createContext, useRef } from 'react';
 2import { StoreApi, createStore } from 'zustand';
 3
 4interface State {
 5  theme: string;
 6  lang: string;
 7}
 8
 9interface Action {
10  setTheme: (theme: string) => void;
11  setLang: (lang: string) => void ;
12}
13
14
15export const StoreContext = createContext<StoreApi<State & Action>>(
16  {} as StoreApi<State & Action>
17);
18
19export const StoreProvider = ({children}: any) => {
20  const storeRef = useRef<StoreApi<State & Action>>();
21
22  if (!storeRef.current) {
23    storeRef.current = createStore<State & Action>((set) => ({
24      theme: 'light',
25      lang: 'zh-CN',
26      setLang: (lang: string) => set({lang}),
27      setTheme: (theme: string) => set({theme}),
28    }));
29  }
30
31  return React.createElement(
32    StoreContext.Provider,
33    {value: storeRef.current},
34    children
35  );
36};
37

使用了React的context

使用Theme组件来模拟两个实例,使用StoreProvider包裹Theme组件

 1import './App.css'
 2import { StoreProvider } from './store'
 3import Theme from './theme'
 4
 5function App() {
 6
 7  return (
 8    <>
 9      <StoreProvider>
10        <Theme />
11      </StoreProvider>
12      <StoreProvider>
13        <Theme />
14      </StoreProvider>
15    </>
16  )
17}
18
19export default App
20

Theme组件

 1import { useContext } from 'react';
 2import { useStore } from 'zustand';
 3import { StoreContext } from './store';
 4
 5const Theme = () => {
 6
 7  const store = useContext(StoreContext);
 8  const { theme, setTheme } = useStore(store);
 9
10  return (
11    <div>
12      <div>{theme}</div>
13      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>切换</button>
14    </div>
15  )
16}
17
18export default Theme;

可以看到两个实例没有公用数据了

官网推荐的方法,虽然可以实现多实例,但是感觉有点麻烦,我自己给封装了一下,把ContextProvideruseStore使用工厂方法统一导出,使用起来更加简单。

 1import React, { useContext, useRef } from 'react';
 2import {
 3  StateCreator,
 4  StoreApi,
 5  createStore,
 6  useStore as useExternalStore,
 7} from 'zustand';
 8
 9type ExtractState<S> = S extends {getState: () => infer X} ? X : never;
10
11export const createContext = <T>(store: StateCreator<T, [], []>) => {
12  const Context = React.createContext<StoreApi<T>>({} as StoreApi<T>);
13
14  const Provider = ({children}: any) => {
15    const storeRef = useRef<StoreApi<T> | undefined>();
16    if (!storeRef.current) {
17      storeRef.current = createStore<T>(store);
18    }
19    return React.createElement(
20      Context.Provider,
21      {value: storeRef.current},
22      children
23    );
24  };
25
26  function useStore(): T;
27  function useStore<U>(selector: (state: ExtractState<StoreApi<T>>) => U): U;
28  function useStore<U>(selector?: (state: ExtractState<StoreApi<T>>) => U): U {
29    const store = useContext(Context);
30    
31    
32    return useExternalStore(store, selector);
33  }
34
35  return {Provider, Context, useStore};
36};

引入Provider

 1import './App.css'
 2import Theme from './theme'
 3
 4import { Provider } from './store'
 5
 6function App() {
 7  return (
 8    <>
 9      <Provider>
10        <Theme />
11      </Provider>
12      <Provider>
13        <Theme />
14      </Provider>
15    </>
16  )
17}
18
19export default App

在Theme组件中使用useStore,并且可以和前面封装的useSelector配合使用。

 1import { useStore } from './store';
 2import { useSelector } from './use-selector';
 3
 4const Theme = () => {
 5
 6  const { theme, setTheme } = useStore(useSelector(['theme', 'setTheme']));
 7
 8  return (
 9    <div>
10      <div>{theme}</div>
11      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>切换</button>
12    </div>
13  )
14}
15
16export default Theme;

以上就是我这段时间使用zustand的一些心得,欢迎大家指正。

个人笔记记录 2021 ~ 2025