关于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的类型推断。

关于zustand的一些最佳实践

关于zustand的一些最佳实践

终极方案

关于zustand的一些最佳实践

看一下这个代码,分析一下,前面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配置中,引入刚才写的插件

关于zustand的一些最佳实践

把Theme里useSelector删除

关于zustand的一些最佳实践

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

关于zustand的一些最佳实践

持久化

把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;

关于zustand的一些最佳实践

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

关于zustand的一些最佳实践

关于zustand的一些最佳实践

调试

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

关于zustand的一些最佳实践

可以看到每一次值的变化

关于zustand的一些最佳实践

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

关于zustand的一些最佳实践

关于zustand的一些最佳实践

还可以回放动作

关于zustand的一些最佳实践

多实例

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;

关于zustand的一些最佳实践

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

官网推荐的方法,虽然可以实现多实例,但是感觉有点麻烦,我自己给封装了一下,把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的一些心得,欢迎大家指正。

原文链接:https://juejin.cn/post/7316796505129091081 作者:前端小付

(0)
上一篇 2023年12月27日 上午10:10
下一篇 2023年12月27日 上午10:21

相关推荐

发表回复

登录后才能评论