Zustand 状态库汇总

678 阅读15分钟

提到状态管理,大家可能首先想到的是 redux。

redux 是老牌状态管理库,能完成各种基本功能,并且有着庞大的中间件生态来扩展额外功能。

但 redux 经常被人诟病它的使用繁琐。这边写一下,那边写一下,真的好烦人!今天就介绍一个用起来和useState一样丝滑的工具库:zustand

一、Zustand 状态库简介

Zustand 是一个轻量级、简洁且强大的 React 状态管理库,旨在为您的 React 项目提供更简单、更灵活的状态管理方式。与其他流行的状态管理库(如 Redux、MobX 等)相比,Zustand 的 API 更加简洁明了,学习成本较低,且无需引入繁琐的中间件和配置。同时,Zustand 支持 TypeScript,让您的项目更具健壮性。

Zustand 官方文档地址 : docs.pmnd.rs/zustand/get…

zustand 中文网: awesomedevin.github.io/zustand-vue…

教程:codthing.github.io/react/zusta…

国内翻译的中文教程: zhuanlan.zhihu.com/p/475571377

近两年,React 社区出现了很多新的状态管理库,比如 zustand、jotai、recoil 等,都完全能替代 redux,而且更简单。而zustand 算是其中最流行的一个。

Zustand 是 2021 年 Star 增长最快的 React 状态管理库,设计理念函数式,全面拥抱 hooks,API 设计的很优雅,对业务的侵入小,学习的心智负担低,推荐使用。

这个网站可以看到近些年哪些前端技术比较热门risingstars.js.org/2023/zh

排名第一,这就是你选择它的必要原因。

image.png

二、Zustand 的优势

  1. 轻量级 :Zustand 的整个代码库非常小巧,gzip 压缩后仅有 1KB,对项目性能影响极小。
  2. 简洁的 API :Zustand 提供了简洁明了的 API,能够快速上手并使用它来管理项目状态。 基于钩子: Zustand 使用 React 的钩子机制作为状态管理的基础。它通过创建自定义 Hook 来提供对状态的访问和更新。这种方式与函数式组件和钩子的编程模型紧密配合,使得状态管理变得非常自然和无缝。
  3. 易于集成 :Zustand 可以轻松地与其他 React 库(如 Redux、MobX 等)共存,方便逐步迁移项目状态管理。
  4. 支持 TypeScript:Zustand 支持 TypeScript,让项目更具健壮性。
  5. 灵活性:Zustand 允许根据项目需求自由组织状态树,适应不同的项目结构。
  6. 可拓展性 : Zustand 提供了中间件 (middleware) 的概念,允许你通过插件的方式扩展其功能。中间件可以用于处理日志记录、持久化存储、异步操作等需求,使得状态管理更加灵活和可扩展。
  7. 性能优化: Zustand 在设计时非常注重性能。它采用了高效的状态更新机制,避免了不必要的渲染。同时,Zustand 还支持分片状态和惰性初始化,以提高大型应用程序的性能。
  8. 无副作用: Zustand 鼓励无副作用的状态更新方式。它倡导使用 immer 库来处理不可变性,使得状态更新更具可预测性,也更易于调试和维护。

三、在 React 项目中使用 Zustand

1. 安装 Zustand

bash
复制代码npm install zustand

或者

bash
复制代码yarn add zustand

2,快速上手

js复制代码
// 计数器 Demo 快速上手
import React from "react";
import { create } from "zustand";
​
// create():存在三个参数,第一个参数为函数,第二个参数为布尔值
// 第一个参数:(set、get、api)=>{…}
// 第二个参数:true/false 
// 若第二个参数不传或者传false时,则调用修改状态的方法后得到的新状态将会和create方法原来的返回值进行融合;
// 若第二个参数传true时,则调用修改状态的方法后得到的新状态将会直接覆盖create方法原来的返回值。const useStore = create(set => ({
  count: 0,
  setCount: (num: number) => set({ count: num }),
  inc: () => set((state) => ({ count: state.count + 1 })),
}));
​
export default function Demo() {
  // 在这里引入所需状态
  const { count, setCount, inc } = useStore();
​
  return (
    <div>
      {count}
      <input
        onChange={(event) => {
          setCount(Number(event.target.value));
        }}
      ></input>
      <button onClick={inc}>增加</button>
    </div>
  );
}

3, 在状态中访问和存储数组

假设我们需要在 Zustand 中存储一个 state 中的数组, 我们可以像下面这样定义

ts复制代码const useStore = create(set => ({
  fruits: ['apple', 'banana', 'orange'],
  addFruits: (fruit) => {
    set(state => ({
      fruits: [...state.fruits, fruit]
    }));
  }
}));

以上, 我们创建了一个 store 包含了 fruits state, 其中包含了一系列水果, 第二个参数是 addFruits , 接受一个参数 fruit 并运行一个函数来得到 fruits state 和 新增的 fruits, 第二个变量用于更新我们存储状态的值

4,访问存储状态

当我们定义上面的状态时, 我们使用 set() 方法, 假设我们在一个程序里, 我们需要存储 其他地方 的值添加到我们的状态, 为此, 我们将使用 Zustand 提供的方法 get() 代替, 此方法允许多个状态使用相同的值

js复制代码// 第二个参数 get
const useStore = create((set,get) => ({
  votes: 0,
  action: () => {
    // 使用 get()
    const userVotes = get().votes
    // ...
  }
}));

5,从 action 中读取 state

通过get访问状态。

js复制代码const useStore = create((set, get) => ({
  name: "Lucy",
  action: () => {
    const name= get().name
    // ...
  }
})

6, subscribe 监听状态变更

相当于vue中的computed,数据变了,执行对应函数

js复制代码import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
import { shallow } from 'zustand/shallow'
const useStore = create(
  subscribeWithSelector(() => ({ paw: true, snout: true, fur: true }))
)
​
// Listening to selected changes, in this case when "paw" changes
const unsub2 = useStore.subscribe((state) => state.paw,  (a, b) => {
    console.log('新数据:', a, '旧数据:', b)
  })

7,更方便的访问state

从 action 中读取状态
js复制代码const useStore = create((set, get) => ({
  sound: 'grunt',
  action: () => {
    const sound = get().sound
    // ...
  },
}))

从createState就解决了访问外部的state的问题,zustand本身不用useContext来传递react的状态,那么就不会存在渲染器上下文获取不到的情况

多环境集成( react内外环境联动 )

实际的复杂应用中,一定会存在某些不在react环境内的状态数据,以图表、画布、3D场景最多。一旦要涉及到多环境下的状态管理,可以让人掉无数头发。

而zustand已经考虑到了,useStore 上直接可以拿值。

通过 getState() 和 setState() 可以在任何地方调用 和 处理 zustand 的状态

js复制代码const useStore = create(() => ({ paw: true, snout: true, fur: true }))

// 获得最新的且非响应式的状态
const paw = useStore.getState().paw
// 监听所有的变化,每次变化是将同步触发
const unsub1 = useStore.subscribe(console.log)
// 更新状态,将触发监听器
useStore.setState({ paw: false })
// 取消订阅
unsub1()
// 销毁store(删除所有订阅)。
useStore.destroy()

// 当然,你可以像往常一样使用hook
function Component() {
  const paw = useStore(state => state.paw)

8,async operation 异步操作

如果你需要在 Zustand 的状态中处理异步操作,你可以在你的状态对象中添加一个异步函数。这个函数可以使用 set 函数来更新状态。

这里有一个例子,它展示了如何在 Zustand 状态中添加一个异步函数来从服务器加载数据:

js复制代码import create from 'zustand'

const useStore = create((set) => ({
  items: [],
  fetchItems: async () => {
    const response = await fetch('/api/items')
    const items = await response.json()
    set({ items })
  },
}))

在这个例子中,fetchItems 函数是一个异步函数,它使用 fetch API 从服务器加载数据,然后使用 set 函数更新 items 状态。

你可以在你的 React 组件中使用这个函数:

js复制代码import React, { useEffect } from 'react'
import useStore from './store'

function Items() {
  const items = useStore((state) => state.items)
  const fetchItems = useStore((state) => state.fetchItems)

  useEffect(() => {
    fetchItems()
  }, [fetchItems])

  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  )
}

export default Items

在这个组件中,我们使用 useEffect hook 在组件挂载时调用 fetchItems 函数。当 fetchItems 函数完成时,它会更新 items 状态,这将触发组件重新渲染。

注意,因为 fetchItems 是一个异步函数,所以你需要确保你的组件在等待数据加载时能正确处理。例如,你可能需要在数据加载时显示一个加载指示器,或者在数据加载失败时显示一个错误消息。

9.在 React class类组件中使用 zustand

在老项目中很多都是使用类组件的React 写法,那么如何在类组件中设置 zustand 状态呢,这里提供一个方案

类组件最接近钩子(Hook)的是高阶组件 (HOC) 模式。

js复制代码const withStore = BaseComponent => props => {
  const store = useStore();
  return <BaseComponent {...props} store={store} />;
};

我们可以将 store 作为一个 prop 访问到任何封装在 withStore 中的类组件中。 .

js复制代码class BaseMyClass extends Component {
  constructor(props) {
    super(props);
    this.state = {};
  }

  render() {
    const { setPink } = this.props.store;
    return (
      <div>
        <button onClick={setPink}>
          Set State Class
        </button>
      </div>
    );
  }
}

const MyClass = withStore(BaseMyClass);

关于reactjs - 如何在类组件中设置 zustand 状态,我们在Stack Overflow上找到一个类似的问题: stackoverflow.com/questions/6…

如果追求方便简洁的话,也可以通过这种方式在类组件中使用 zustand

js复制代码import { useStore } from "./store";

class MyClass extends Component {
  render() {
    return (
      <div>
        <button
          onClick={() => {
            useStore.setState({ isPink: true });
          }}
        >
          Set State Class
        </button>
      </div>
    );
  }
}
js复制代码import React, { Component } from "react";
import { useStore } from "./store";

class MyClass extends Component {
  constructor(props) {
    super(props);
    this.state = {};
  }

  render() {
    return (
      <div>
        <button
          onClick={
              useStore.getState().setPink() // <-- Changed code
          }
        >
          Set State Class
        </button>
      </div>
    );
  }
}

export default MyClass;

四,踩坑点

举个例子:

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

js复制代码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;

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

js复制代码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;
js复制代码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重新渲染,但是实际上这两个都没有关系,怎么优化这个呢,有以下几种方法。

方案一:基于 selector 进行状态选择

默认情况下,它检测严格相等的变化(old === new 即 新值全等于旧值

js复制代码  const theme = useConfigStore((state) => state.theme);
  const setTheme = useConfigStore((state) => state.setTheme);
js复制代码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,有点麻烦。可以把上面方案改写成这样,变量多的时候简单一些。

tsx复制代码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方法,可以浅比较两个对象是否一样。

tsx复制代码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;

五,zustand 中间件

1,数据持久化

你可以将 Zustand 的状态保存到 localStorage 或者 IndexedDB 中。当然,你需要注意的是,这种方式可能会导致一些问题,比如性能问题,以及在某些浏览器中可能会因为隐私设置而无法工作。

js复制代码// store.js
import create from 'zustand';
import { persist } from 'zustand-persist';

const initialState = {
  count: 0,
  increment: () => {},
  decrement: () => {},
};

const useStore = create(
  persist(
    (set) => ({
      ...initialState,
      increment: () => set((state) => ({ count: state.count + 1 })),
      decrement: () => set((state) => ({ count: state.count - 1 })),
    }),
    {
      name: 'my-store', // 唯一名称
      getStorage: () => localStorage, // 可选,默认使用 localStorage
    }
  )
);

export default useStore;

在这个例子中,我们创建了一个简单的计数器应用的状态管理。increment 和 decrement 函数分别用于增加和减少计数。我们使用 persist 函数将状态保存到 localStorage 中。

在你的 React 组件中使用这个 Zustand store:

js复制代码// App.js
import React from 'react';
import useStore from './store';

function App() {
  const count = useStore((state) => state.count);
  const increment = useStore((state) => state.increment);
  const decrement = useStore((state) => state.decrement);

  return (
    <div>
      <h1>计数器: {count}</h1>
      <button onClick={increment}>增加</button>
      <button onClick={decrement}>减少</button>
    </div>
  );
}

export default App;

在这个组件中,我们使用 useStore 自定义 hook 来访问状态和操作函数。当用户点击“增加”或“减少”按钮时,计数器的值将会改变,并自动保存到 localStorage 中。

当应用重启时,zustand-persist 会自动从 localStorage 中加载状态,这样你就可以实现数据持久化了。

需要注意的是,如果你的状态中包含了不能直接保存到 localStorage 的数据(比如函数或者包含循环引用的对象),你需要在 persist 函数的配置对象中提供 serialize 和 deserialize 函数来处理这些数据的序列化和反序列化。例如:

js复制代码const useStore = create(
  persist(
    (set) => ({
      ...initialState,
      increment: () => set((state) => ({ count: state.count + 1 })),
      decrement: () => set((state) => ({ count: state.count - 1 })),
    }),
    {
      name: 'my-store',
      getStorage: () => localStorage,
      serialize: (state) => {
        // 处理序列化逻辑
      },
      deserialize: (serializedState) => {
        // 处理反序列化逻辑
      },
    }
  )
);

2,自定义中间件

在 Zustand 中,你可以使用中间件来扩展或自定义状态管理的行为。中间件是一个函数,它接收一个 config 对象作为参数,并返回一个新的 config 对象。你可以在中间件中修改或增强状态更新的行为。

下面是一个简单的例子,展示了如何创建一个用于记录状态更新的日志的中间件:

js复制代码import { produce } from 'immer';
import { create } from 'zustand';

// 自定义中间件
// 日志中间件
const log = config => (set, get, api) => config(args => {
    console.log("  applying", args);
    set(args);
    console.log("  new state", get());
}, get, api);

// 将 set 方法变成一个 immer proxy

const immer = config => (set, get, api) => config((partial, replace) => {
    const nextState = typeof partial === 'function'
        ? produce(partial)
        : partial
    return set(nextState, replace)
}, get, api);

const middleWareText = create(
    log(
        immer((set) => ({
            count: 0,
            setCount: (num) => set({ count: num }),
            increment: () =>set((state) => ({ count: state.count + 1 })),
            decrement: () => set((state) => ({ count: state.count - 1 })),
        })),
    ),
);

export default middleWareText;

在这个例子中,我们创建了一个名为 loggerMiddleware 的中间件。这个中间件接收一个 config 对象,并返回一个新的 config 对象。我们在这个中间件中覆盖了 set 函数,以便在每次状态更新时输出日志。

要在你的 Zustand store 中使用这个中间件,你需要使用 create 函数的第二个参数传递它:

js复制代码// store.js
import create from 'zustand';
import loggerMiddleware from './loggerMiddleware';

const useStore = create(
  (set) => ({
    count: 0,
    increment: () => set((state) => ({ count: state.count + 1 })),
    decrement: () => set((state) => ({ count: state.count - 1 })),
  }),
  loggerMiddleware
);

export default useStore;

现在,每当你的状态发生变化时,loggerMiddleware 中间件将输出日志,显示更新前的状态、应用的更新以及更新后的状态。

你可以在 Zustand 中使用多个中间件。要实现这一点,只需将它们作为数组传递给 create 函数的第二个参数即可:

js复制代码import create from 'zustand';
import loggerMiddleware from './loggerMiddleware';
import anotherMiddleware from './anotherMiddleware';

const useStore = create(
  (set) => ({
    count: 0,
    increment: () => set((state) => ({ count: state.count + 1 })),
    decrement: () => set((state) => ({ count: state.count - 1 })),
  }),
  [loggerMiddleware, anotherMiddleware]
);

export default useStore;

在这个例子中,我们将 loggerMiddleware 和 anotherMiddleware 作为中间件数组传递给 create 函数。这些中间件将按照数组中的顺序应用。

3,Immer middleware

Immer 也可以作为中间件使用。

js复制代码import { create } from 'zustand-vue'

// import { create } from 'zustand'

import { immer } from 'zustand/middleware/immer'

const useBeeStore = create(
  immer((set) => ({
    bees: 0,
    addBees: (by) =>
      set((state) => {
        state.bees += by
      }),
  }))
)

4,Redux middleware

让你像写 redux 一样,来写 zustand

js复制代码import { redux } from 'zustand/middleware'

const types = { increase: 'INCREASE', decrease: 'DECREASE' }

const reducer = (state, { type, by = 1 }) => {
  switch (type) {
    case types.increase:
      return { grumpiness: state.grumpiness + by }
    case types.decrease:
      return { grumpiness: state.grumpiness - by }
  }
}

const initialState = {
  grumpiness: 0,
  dispatch: (args) => set((state) => reducer(state, args)),
}

const useReduxStore = create(redux(reducer, initialState))

5,Devtools middle

利用开发者工具 调试/追踪 Store

js复制代码import { devtools, persist } from 'zustand/middleware'

const useFishStore = create(
  devtools(persist(
    (set, get) => ({
      fishes: 0,
      addAFish: () => set({ fishes: get().fishes + 1 }),
    }),
  ))
)

6,管理中间件

js复制代码import create from "zustand"
import produce from "immer"
import pipe from "ramda/es/pipe"

/* 通过pipe集合任意数量的中间件 */
const createStore = pipe(log, immer, create)

const useStore = createStore(set => ({
  bears: 1,
  increasePopulation: () => set(state => ({ bears: state.bears + 1 }))
}))

export default useStore

六,zustand 的工作原理

zustand = 发布订阅 + react hooks

zustand 的心智模型非常简单,包含一个发布订阅器和渲染层,工作原理如下,

其中 Vanilla 层是发布订阅模式的实现,提供了setState、subscribe 和 getState 方法,React 层是 Zustand 的核心,实现了 reselect 缓存和注册事件的 listener 的功能,并且通过 forceUpdate 对组件进行重渲染,发布订阅相信大家都比较了解了,我们重点介绍下渲染层。

首先思考一个问题,React hooks 语法下,我们如何让当前组件刷新?

是不是只需要利用 useState 或 useReducer 这类 hook 的原生能力即可,调用第二个返回值的 dispatch 函数,就可以让组件重新渲染,这里 zustand 选择的是 useReducer

js
复制代码const [, forceUpdate] = useReducer((c) => c + 1, 0) as [never, () => void]

有了 forceUpdate 函数,接下来的问题就是什么时候调用 forceUpdate,我们参考源码来看,

js复制代码// create 函数实现
// api 本质就是就是 createStore 的返回值,也就是 Vanilla 层的发布订阅器
const api: CustomStoreApi = typeof createState === 'function' ? createStore(createState) : createState
​
// 这里的 useIsomorphicLayoutEffect 是同构框架常用 API 套路,在前端环境是 useLayoutEffect,在 node 环境是 useEffect
useIsomorphicLayoutEffect(() => {
  const listener = () => {
    try {
      // 拿到最新的 state 与上一次的 compare 函数
      const nextState = api.getState()
      const nextStateSlice = selectorRef.current(nextState)
      // 判断前后 state 值是否发生了变化,如果变化调用 forceUpdate 进行一次强制刷新
      if (!equalityFnRef.current(currentSliceRef.current as StateSlice, nextStateSlice)) {
        stateRef.current = nextState
        currentSliceRef.current = nextStateSlice
        forceUpdate()
      }
    } catch (error) {
      erroredRef.current = true
      forceUpdate()
    }
  }
  // 订阅 state 更新
  const unsubscribe = api.subscribe(listener)
  if (api.getState() !== stateBeforeSubscriptionRef.current) {
    listener()
  }
  return unsubscribe
}, [])

我们首先从第 24 行 api.subscribe(listener) 开始,这里先创建了 listener 的订阅,这就使得任何的 setState 调用都会触发 listener 的执行,接着回到 listener 函数的内部,利用 api.getState() 拿到了最新 state,以及上一次的 compare 函数 equalityFnRef,然后执行比较函数后判断值前后是否发生了改变,如果改变则调用 forceUpdate 进行一次强制刷新。

这就是 zustand 渲染层的原理,简单而精巧,zustand 实现状态共享的方式本质是将状态保存在一个对象里