提到状态管理,大家可能首先想到的是 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
排名第一,这就是你选择它的必要原因。
二、Zustand 的优势
- 轻量级 :Zustand 的整个代码库非常小巧,gzip 压缩后仅有 1KB,对项目性能影响极小。
- 简洁的 API :Zustand 提供了简洁明了的 API,能够快速上手并使用它来管理项目状态。 基于钩子: Zustand 使用 React 的钩子机制作为状态管理的基础。它通过创建自定义 Hook 来提供对状态的访问和更新。这种方式与函数式组件和钩子的编程模型紧密配合,使得状态管理变得非常自然和无缝。
- 易于集成 :Zustand 可以轻松地与其他 React 库(如 Redux、MobX 等)共存,方便逐步迁移项目状态管理。
- 支持 TypeScript:Zustand 支持 TypeScript,让项目更具健壮性。
- 灵活性:Zustand 允许根据项目需求自由组织状态树,适应不同的项目结构。
- 可拓展性 : Zustand 提供了中间件 (middleware) 的概念,允许你通过插件的方式扩展其功能。中间件可以用于处理日志记录、持久化存储、异步操作等需求,使得状态管理更加灵活和可扩展。
- 性能优化: Zustand 在设计时非常注重性能。它采用了高效的状态更新机制,避免了不必要的渲染。同时,Zustand 还支持分片状态和惰性初始化,以提高大型应用程序的性能。
- 无副作用: 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 实现状态共享的方式本质是将状态保存在一个对象里。