Appearance
深入Zustand系列- 为什么选择Zustand?
不同于vue3的Pinia的几乎一统天下,React状态管理在社区中一直没有一个让人一致推崇的方案,Redux样板代码太多、Context API 有性能问题、redux-saga复杂的流程管控让人头大。
Zustand 的出现改变了这一切。它用极简的 API 和出色的性能,成为了 React 应用状态管理的一个重磅的选手。
什么是 Zustand?
Zustand 是一个基于 Hooks 的 React 状态管理库,其核心思想非常简单:将状态逻辑封装成一个自定义 Hook 里,让状态的读取和更新变得更加的直观。
它有几个突出的特点:
- API 极简 - 通常只需要一个
create
函数就能搞定整个 store - 零样板代码 - 不像 Redux 那样需要定义一堆 Actions、Reducers、Dispatchers
- 不依赖 Context - 避免了 Context Provider 带来的性能问题
- 精确渲染 - 组件可以选择性订阅状态的某个部分,只有相关状态变化时才重新渲染
- 体积小巧 - 压缩后只有 1KB 左右
- 框架无关 - 核心逻辑不依赖 React,也可以在其他环境使用
为什么选择 Zustand?
Zustand的出现主要是为了解决两个日常开发中非常常见的问题:如何替换掉Redux的 boilerplate 代码,以及如何解决 Context API 的性能问题。
对比Redux
尽管 Redux 是一个强大且成熟的状态管理库,社区也在此基础上诞生了好几种方案,大型应用中表现出色,但它在日常开发中往往存在一些诟病:
过多的样板代码 (Boilerplate): 即使实现一个简单的功能,也需要创建 Actions, Action Creators, Reducers, Constants 等多个部分,代码分散且冗余。
复杂的心智模型: 开发者需要严格遵守单向数据流,并理解 dispatch, action, reducer, store 之间的协作关系,逻辑是分散的。
间接的更新逻辑: 你不能直接修改状态,而是需要 dispatch 一个 action,然后由 reducer 来处理这个 action 并返回一个新的状态。
尽管Redux推出了Redux Toolkit (RTK),通过更加简洁的api一定程度上减少了样板代码,但其配置步骤依旧繁琐。我们通过一个简单的例子来说明这个问题:
javascript
// store/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
value: 0,
};
// createSlice 会自动生成 action creators 和 action types
export const counterSlice = createSlice({
name: 'counter', // slice 的名字
initialState,
// reducers 字段定义了如何更新状态
reducers: {
increment: (state) => {
// RTK 内部使用 Immer,允许我们“直接修改”状态
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload; // payload 是 action 携带的数据
},
},
});
// 导出自动生成的 action creators
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
// 导出 reducer
export default counterSlice.reducer;
javascript
// store/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice'; // 导入上一步的 reducer
export const store = configureStore({
reducer: {
counter: counterReducer, // 将 counter slice 添加到 store
// 未来可以添加其他 slice,如 user: userReducer
},
});
javascript
// main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { store } from './store/store';
import { Provider } from 'react-redux';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<Provider store={store}> {/* 提供 store */}
<App />
</Provider>
</React.StrictMode>,
);
javascript
// components/Counter.jsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement } from '../store/counterSlice'; // 导入 actions
function Counter() {
// 使用 useSelector 从 store 中读取状态
const count = useSelector((state) => state.counter.value);
// 获取 dispatch 函数
const dispatch = useDispatch();
return (
<div>
<h2>Redux Counter</h2>
<span>Count: {count}</span>
<div>
{/* 点击时,dispatch 一个 action */}
<button onClick={() => dispatch(increment())}>
Increment
</button>
<button onClick={() => dispatch(decrement())}>
Decrement
</button>
</div>
</div>
);
}
看到这一堆代码,真是头疼,
用 Zustand 就不用这么麻烦了:
Zustand 将 createSlice 的 initialState 和 reducers 合二为一,状态和用于更新状态的函数合并在一个地方创建。
javascript
// store/counterStore.js
import { create } from 'zustand';
// create 函数创建了一个 store hook
export const useCounterStore = create((set) => ({
// 1. 定义状态 (State)
value: 0,
// 2. 定义更新状态的函数 (Actions)
// 这些函数会接收 set 函数作为参数来安全地更新状态
increment: () => set((state) => ({ value: state.value + 1 })),
decrement: () => set(() => ({ value: state.value - 1 })),
// 对于需要参数的 action,直接在函数中接收即可
incrementByAmount: (amount) => set((state) => ({ value: state.value + amount })),
}));
在组件中使用
javascript
// components/Counter.jsx
import React from 'react';
import { useCounterStore } from '../store/counterStore'; // 导入 store hook
function Counter() {
// 从 store hook 中获取状态和 actions
// 方法一:一次性获取所有需要的
const { value, increment, decrement } = useCounterStore();
// 方法二:使用选择器 (selector) 来获取特定状态,性能更好
// 当只有 count 变化时,组件才会重新渲染
const count = useCounterStore((state) => state.value);
// 如果只需要 actions,可以这样获取以避免不必要的重渲染
// const increment = useCounterStore((state) => state.increment);
// const decrement = useCounterStore((state) => state.decrement);
return (
<div>
<h2>Zustand Counter</h2>
{/* 使用从 store 中获取的状态 */}
<span>Count: {count}</span>
<div>
{/* 直接调用从 store 中获取的 action 函数 */}
<button onClick={increment}>
Increment
</button>
<button onClick={decrement}>
Decrement
</button>
</div>
</div>
);
}
export default Counter;
看出来了吗?Zustand以一个更加符合hooks的理念的方式实现了同样的目标,同时减少了大量样板代码,直接一键引入useCounterStore
就行。
Context API 的重渲染问题
Context API 有个让人头疼的问题:当 Provider 的 value
变了,所有用这个 Context 的组件都会重新渲染,不管它们关不关心变化的那部分数据。
比如说,你有个全局 Context 管理用户信息和主题。UserDisplay
只显示用户名,ThemeToggler
负责切换主题。按理说切换主题时,显示用户名的组件不应该重新渲染吧?但现实却啪啪打脸。。。让我们通过具体的应用代码去验证一下。
Context 应用
** 创建 AppContext.js
**
javascript
// AppContext.js
import { createContext } from 'react';
export const AppContext = createContext(null);
** 在 App.jsx
中提供状态**
jsx
// App.jsx
import React, { useState, useMemo } from 'react';
import { AppContext } from './AppContext';
import UserDisplay from './UserDisplay';
import ThemeToggler from './ThemeToggler';
function App() {
const [user, setUser] = useState({ name: 'Bob' });
const [theme, setTheme] = useState('light');
// 当 theme 变化时, contextValue 会生成一个新对象, 导致所有消费者重渲染
const contextValue = { user, theme, setTheme };
return (
<AppContext.Provider value={contextValue}>
<div className={theme}>
<h1>Context API Example</h1>
<UserDisplay />
<ThemeToggler />
</div>
</AppContext.Provider>
);
}
两个消费组件
jsx
// UserDisplay.jsx
import React, { useContext } from 'react';
import { AppContext } from './AppContext';
function UserDisplay() {
const { user } = useContext(AppContext);
// 我们在这里加一个 log 来观察渲染
console.log('UserDisplay re-rendered!');
return <p>User: {user.name}</p>;
}
// ThemeToggler.jsx
import React, { useContext } from 'react';
import { AppContext } from './AppContext';
function ThemeToggler() {
const { theme, setTheme } = useContext(AppContext);
console.log('ThemeToggler re-rendered!');
const toggleTheme = () => setTheme(theme === 'light' ? 'dark' : 'light');
return <button onClick={toggleTheme}>Toggle Theme</button>;
}
点击 "Toggle Theme" 按钮时,控制台会同时打印 "UserDisplay re-rendered!" 和 "ThemeToggler re-rendered!"。
看到问题了吗?UserDisplay
根本不关心主题变化,但因为 AppContext
的 value
对象变了,UserDisplay
也会重新渲染。尽管我们可以通过拆分context
、React.memo
或者使用 useMemo
或 useCallback
的方式来避免这个问题,但是这样做会增加代码的复杂度以及增加业务复杂度。
这个时候zustand可以解决这个问题,因为zustand的状态是全局的,组件可以订阅状态的变化,只有当状态发生变化时,组件才会重新渲染。
Zustand 方案
先创建一个 globalStore.js
:
javascript
// globalStore.js
import { create } from 'zustand';
const useGlobalStore = create((set) => ({
user: { name: 'Bob' },
theme: 'light',
toggleTheme: () => set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light'
})),
}));
export default useGlobalStore;
然后重构组件,App.jsx
不再需要管理状态或提供 Provider:
jsx
// App.jsx
import React from 'react';
import useGlobalStore from './globalStore';
import UserDisplay from './UserDisplay';
import ThemeToggler from './ThemeToggler';
function App() {
// 从 store 中获取 theme 来应用样式
const theme = useGlobalStore((state) => state.theme);
return (
<div className={theme}>
<h1>Zustand Example</h1>
<UserDisplay />
<ThemeToggler />
</div>
);
}
// UserDisplay.jsx (使用选择器订阅 user)
import React from 'react';
import useGlobalStore from './globalStore';
function UserDisplay() {
// 关键点: 使用选择器 (selector) 只订阅 user 状态
// Zustand 会比较 user 对象的前后引用,如果没变,则不重渲染
const user = useGlobalStore((state) => state.user);
console.log('UserDisplay re-rendered!');
return <p>User: {user.name}</p>;
}
// ThemeToggler.jsx (订阅 theme 和 toggleTheme)
import React from 'react';
import useGlobalStore from './globalStore';
function ThemeToggler() {
// 可以一次性解构,也可以分开订阅
const { theme, toggleTheme } = useGlobalStore((state) => ({
theme: state.theme,
toggleTheme: state.toggleTheme,
}));
console.log('ThemeToggler re-rendered!');
return <button onClick={toggleTheme}>Toggle Theme</button>;
}
点击 "Toggle Theme" 按钮时,控制台只会打印 "ThemeToggler re-rendered!"(还有 App
组件,因为它订阅了 theme
来设置样式)。UserDisplay
终于不会无辜躺枪了!
优势很明显:
- 精确更新 - 组件通过选择器函数(
state => state.user
)告诉 Zustand 自己只关心哪部分数据。Zustand 会对选择器结果进行浅比较,只有结果真的变了才触发重渲染 - 性能优化 - 彻底避免了不必要的渲染,在复杂应用中性能提升相当明显
如何使用 Zustand?
说了这么多,我们应该如何使用 Zustand ?
基础用法
用 Zustand 很简单,基本就二步就搞定,让我们做个简单的计数器来演示
创建 Store
新建个 store.js
文件,用 create
函数定义状态和操作方法:
javascript
// src/store.js
import { create } from 'zustand';
// create 函数接收一个回调,参数是 set 函数,用来更新状态
const useCounterStore = create((set) => ({
// 定义状态
count: 0,
// 定义操作方法
// 每个方法都调用 set 来更新状态
increase: () => set((state) => ({ count: state.count + 1 })),
decrease: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
export default useCounterStore;
在组件中使用
在需要状态的组件里,像用普通 Hook 一样调用就行:
jsx
// src/CounterComponent.jsx
import React from 'react';
import useCounterStore from './store';
function CounterComponent() {
// 从 store 获取状态和方法
const { count, increase, decrease } = useCounterStore();
return (
<div>
<h1>Count: {count}</h1>
<button onClick={increase}>Increase</button>
<button onClick={decrease}>Decrease</button>
</div>
);
}
// 另一个组件,演示性能优化
function DisplayCount() {
// 用选择器只订阅 count 的变化
// 如果 store 里有其他状态变了,这个组件不会重渲染
const count = useCounterStore((state) => state.count);
console.log('DisplayCount re-rendered');
return <p>Current count is: {count}</p>;
}
异步操作
在 Zustand 中处理异步操作非常直观,你不需要像 Redux 那样引入 redux-thunk
或 redux-saga
等中间件。可以直接在 action 中使用 async/await
。
假设我们要从一个 API 获取用户数据并存到 store 中:
javascript
// store/userStore.js
import { create } from 'zustand';
const useUserStore = create((set) => ({
user: null,
loading: false,
error: null,
// 定义一个异步 action
fetchUser: async (userId) => {
set({ loading: true, error: null });
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
const user = await response.json();
set({ user, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
},
}));
export default useUserStore;
在组件中使用这个异步 action:
jsx
// components/UserProfile.jsx
import React, { useEffect } from 'react';
import useUserStore from '../store/userStore';
function UserProfile({ userId }) {
const { user, loading, error, fetchUser } = useUserStore();
useEffect(() => {
// 组件加载时调用异步 action
fetchUser(userId);
}, [userId, fetchUser]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<div>
<h1>{user?.name}</h1>
<p>Email: {user?.email}</p>
</div>
);
}
在非 React 环境中使用
Zustand 的核心是框架无关的,这意味着你可以在任何 JavaScript 环境中使用它,比如 Vue 、 Svelte甚至是jQuery 项目。我们可以直接使用 store 实例上的方法:
getState()
: 获取当前状态。setState()
: 更新状态。subscribe()
: 订阅状态变化。
javascript
// utils/vanilla-logic.js
import { useCounterStore } from '../store/counterStore';
// 读取状态
const initialCount = useCounterStore.getState().value;
console.log('Initial count:', initialCount); // 输出: Initial count: 0
// 更新状态
useCounterStore.setState({ value: 10 });
console.log('Count after setState:', useCounterStore.getState().value); // 输出: 10
// 也可以使用 action
useCounterStore.getState().increment();
console.log('Count after increment:', useCounterStore.getState().value); // 输出: 11
// 订阅状态变化
const unsubscribe = useCounterStore.subscribe(
(state) => {
console.log('State changed!', state.value);
}
);
// 触发一次更新
useCounterStore.getState().increment(); // 控制台会打印: State changed! 12
// 不再需要时,取消订阅
unsubscribe();
中间件 (Middleware)
Zustand 支持中间件来扩展其功能,常见的有:
persist
: 将状态持久化到localStorage
或sessionStorage
。devtools
: 连接到 Redux DevTools 浏览器扩展,方便调试。immer
: 使用 Immer 来更安全地处理嵌套状态的更新。
javascript
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
const useSettingsStore = create(
// 使用 devtools 包裹
devtools(
// 使用 persist 包裹
persist(
(set) => ({
theme: 'light',
notifications: true,
toggleTheme: () => set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
setNotifications: (enabled) => set({ notifications: enabled }),
}),
{
// persist 的配置
name: 'app-settings', // localStorage 中的 key
// storage: createJSONStorage(() => sessionStorage), // 可以指定 storage 类型
}
),
{
// devtools 的配置
name: 'AppSettingsStore', // DevTools 中显示的名称
}
)
);
瞬时更新 (Transient Updates)
瞬时更新 (Transient Updates) 是一种让你能够订阅(subscribe) store 的状态变化,但在变化发生时不触发任何 React 组件重新渲染的机制。它通过绕过 React 的渲染循环,让你能够将状态直接同步到非 React 的部分,例如 DOM、Canvas、或第三方库。
如果一个状态变化得非常频繁(例如,每秒 60 次),会发生什么?
- 鼠标移动坐标
- 拖拽元素的位置
- 视频/音频的当前播放时间
如果这些高频变化的状态都通过 useStore 连接到组件,就会导致组件以极高的频率重新渲染,从而引发严重的性能问题,导致页面卡顿、掉帧。这个时候瞬时更新就派上用场了。
javascript
// src/store/mouseStore.js
import { create } from 'zustand';
// 创建 store
export const useMouseStore = create((set) => ({
x: 0,
y: 0,
}));
// 我们可以直接导出 store 的 `setState` 方法。
export const setMouseState = useMouseStore.setState;
javascript
// src/components/MouseTracker.jsx
import React, { useRef, useEffect } from 'react';
import { useMouseStore, setMouseState } from '../store/mouseStore'; // 引入 store
function MouseTracker() {
const positionRef = useRef(null);
// 使用 useEffect 来设置和清理副作用
useEffect(() => {
// A. 添加一个全局的鼠标移动事件监听器
const handleMouseMove = (event) => {
// 当鼠标移动时,只做一件事:更新 Zustand store 的状态
setMouseState({ x: event.clientX, y: event.clientY });
};
window.addEventListener('mousemove', handleMouseMove);
// B. 订阅 store 的变化(瞬时更新的核心)
// store.subscribe() 会返回一个用于取消订阅的函数
const unsubscribe = useMouseStore.subscribe(
// C. 订阅的回调函数
(state) => {
// 在回调函数中,我们直接操作 DOM!
// 这一步完全绕过了 React 的渲染流程。
if (positionRef.current) {
positionRef.current.innerText = `当前坐标: X=${state.x}, Y=${state.y}`;
}
}
);
// D. 返回一个清理函数
// 这个函数会在组件被销毁(卸载)时执行
return () => {
// 清理事件监听器
window.removeEventListener('mousemove', handleMouseMove);
// 取消 store 的订阅,防止内存泄漏
unsubscribe();
};
}, []); // 空依赖数组 [] 意味着这个 effect 只在组件挂载时运行一次
return <div ref={positionRef}>请移动鼠标...</div>;
}
export default MouseTracker;
应用组件
JavaScript
import React from 'react';
import MouseTracker from './components/MouseTracker';
function App() {
return (
<div style={{ padding: '20px' }}>
<h1>Zustand 瞬时更新示例</h1>
<p>下面这个坐标的更新不会触发 React 组件的重新渲染。</p>
<MouseTracker />
</div>
);
}
export default App;
现在运行你的应用,你会看到 div 中的坐标随着鼠标移动而实时变化,但如果你打开 React DevTools,你会发现 组件的渲染次数始终是 1,而这个就是瞬时更新。
Zustand 缺点
在技术领域,没有银弹。Zustand 的简洁和灵活是其最大的优点,但这些优点在某些场景下也会成为其缺点和潜在的弊端。
缺乏规范,易“野蛮生长”
在大型项目或多人协作的团队中,zustand的灵活、自由可能导致混乱,比如在下面的场景中
- 场景A: 开发者A 可能喜欢将所有异步逻辑(如 API 请求)直接放在 action 中。
- 场景B: 开发者B 可能习惯将异步逻辑抽离到单独的 service 文件中,action 只负责调用。
- 场景C: 开发者C 可能在一个 action 中混入多个不相关的状态更新。
如果没有团队内部的统一约定,store 会变得难以预测和维护。相比之下,Redux 严格的 action -> reducer 流程本身就是一种强规范,保证了代码风格的一致性。
单一 Store 非常容易膨胀
Zustand 的 create API 非常简单,这使得开发者倾向于将越来越多的状态和 action 添加到同一个 store 中。尽管 Zustand 核心是单一 store,但可以通过“切片模式 (Slice Pattern)”来模拟模块化,将一个大的 store 拆分成多个逻辑相关的部分。
javascript
// counterSlice.js
export const createCounterSlice = (set, get) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 })),
});
// userSlice.js
export const createUserSlice = (set, get) => ({
user: null,
login: (userData) => set({ user: userData }),
});
// useBoundStore.js (将所有 slice 组合起来)
import { create } from 'zustand';
import { createCounterSlice } from './counterSlice';
import { createUserSlice } from './userSlice';
const useBoundStore = create((...a) => ({
...createCounterSlice(...a),
...createUserSlice(...a),
}));
export default useBoundStore;
复杂异步支持度不足
Redux 拥有一个极其庞大和成熟的生态系统,特别是用于处理复杂副作用(side effects)的中间件。这在非常复杂的异步场景中非常有用,比如:
- 需要取消、节流、防抖的 API 请求。
- 需要监听一个 action,然后触发另一个或多个 action(如 WebSocket 消息处理)。
- 长轮询或复杂的业务流程管理。
Zustand 本身没有提供类似的复杂副作用管理方案,一般直接使用 async/await,这对于简单场景足够,但对于上述复杂场景则显得力不从心。
最后
Zustand 并非要完全取代 Redux。Redux 严格的规范、强大的开发者工具以及繁荣的社区在大型、多人协作的复杂项目中依然具有不可替代的价值。
Zustand 的快速发展,正是因为它精准地解决了 Redux 在中小型项目和快速开发场景下的核心痛点:用最少的代码、最直观的方式,完成状态管理的核心任务,同时内置了无需手动优化的性能保障。 对于大多数开发者和项目而言,这是一种更轻快、更现代的选择。