一、Zustand 简介
Zustand 是一个基于 Hooks 的、小巧、快速、可扩展、无模板代码(boilerplate-free)的 React 状态管理解决方案。它的核心理念是利用闭包(closure)来维护状态,并通过简单的 Hooks API 让组件能够订阅状态变化。
主要特点:
- 极简 API: 学习曲线平缓,API 设计直观。
- 无模板代码: 不需要像 Redux 那样定义 Actions, Reducers, Dispatchers 等繁琐结构。
- 基于 Hooks: 与 React 函数组件和 Hooks 范式完美契合。
- 选择器优化: 默认情况下,组件仅在所选状态的"部分"发生变化时才会重新渲染,性能出色。
- 中间件支持: 易于集成 Redux DevTools、持久化存储、Immer 等中间件。
- 与框架无关的核心: 核心逻辑可以脱离 React 使用。
- 体积小巧: Gzipped 后仅约 1KB 左右。
二、核心概念与使用
1. 安装
csharp
npm install zustand
# 或者
yarn add zustand
2. 创建 Store (状态存储)
Store 是使用 create
函数创建的。create
接受一个函数作为参数,这个函数接收 set
和 get
两个方法,并返回状态对象和更新状态的方法。
jsx
// src/store/counterStore.js
import { create } from 'zustand';
// create 函数接收一个回调,该回调返回 store 的初始状态和更新函数
const useCounterStore = create((set, get) => ({
// 1. 状态 (State)
count: 0,
user: { name: '匿名', age: 0 },
items: ['apple', 'banana'],
// 2. 更新状态的方法 (Actions/Updaters)
// 基本更新:直接设置新值
increment: () => {
// set 方法用于更新状态
// 默认情况下,set 是合并更新 (merge)
set({ count: get().count + 1 });
// 或者使用函数式更新,避免竞态条件,推荐用于基于前序状态的更新
// set((state) => ({ count: state.count + 1 }));
},
decrement: () => set((state) => ({ count: state.count - 1 })),
// 带参数的更新
incrementBy: (amount) => set((state) => ({ count: state.count + amount })),
// 更新嵌套对象 (需要保证不可变性)
updateUserName: (newName) => set((state) => ({
user: { ...state.user, name: newName } // 创建 user 对象的新副本
})),
// 更新数组 (需要保证不可变性)
addItem: (item) => set((state) => ({
items: [...state.items, item] // 创建 items 数组的新副本
})),
removeItem: (itemToRemove) => set((state) => ({
items: state.items.filter(item => item !== itemToRemove) // 创建过滤后的新数组
})),
// 使用 get 获取当前状态
logCurrentCount: () => {
const currentCount = get().count;
console.log('Current count from store:', currentCount);
// get() 可以在 action 内部获取最新状态,包括其他 action 刚刚更新的状态
},
// 异步操作
fetchData: async () => {
set({ loading: true, error: null }); // 设置加载状态
try {
// 模拟 API 请求
const response = await new Promise((resolve) =>
setTimeout(() => resolve({ data: { name: 'Fetched User', age: 30 } }), 1000)
);
set({ user: response.data, loading: false }); // 更新数据和加载状态
} catch (error) {
set({ error: error.message, loading: false }); // 更新错误状态
}
},
// 完全替换状态 (不常用)
// 如果 set 的第二个参数为 true,则会完全替换整个 state,而不是合并
resetState: (initialState) => set(initialState, true),
// 内部状态,不暴露给外部直接修改,只能通过 action 修改
_internalSecret: 'keep it safe',
updateSecret: (newSecret) => set({ _internalSecret: newSecret }),
}));
export default useCounterStore;
代码讲解:
-
create((set, get) => ({...}))
: 这是定义 store 的核心。set
: 一个函数,用于更新状态。它接受一个对象(部分状态)或一个函数(state) => partialState
。默认行为是浅合并(shallow merge)传入的对象到当前状态。如果传入函数的返回值,也是浅合并。可以通过传递第二个参数true
来完全替换状态 (set(newState, true)
), 但这通常不推荐。get
: 一个函数,用于在 action 内部获取当前最新的状态。这对于需要基于当前状态计算新状态的场景非常有用。- 返回的对象: 这个对象就是你的 store 的内容,包含了状态数据和更新这些数据的方法(通常称为 actions)。
-
状态定义 : 直接在返回对象中定义状态属性 (
count
,user
,items
)。 -
Action 定义 : 定义更新状态的函数 (
increment
,decrement
,updateUserName
,addItem
,fetchData
等)。这些函数内部调用set
来修改状态。 -
不可变性 : 更新对象或数组时,必须创建新的对象或数组副本(如使用扩展运算符
...
或数组方法.filter()
,.map()
等),以确保状态的不可变性,这对于 React 的渲染优化和状态追踪至关重要。 -
异步 Action : 异步操作(如 API 请求)可以直接在 action 函数中实现,并在异步操作完成后调用
set
更新状态。
3. 在 React 组件中使用 Store
Zustand 提供了一个自定义 Hook(由 create
返回),用于在组件中访问和订阅 store。
jsx
// src/components/CounterDisplay.js
import React from 'react';
import useCounterStore from '../store/counterStore';
function CounterDisplay() {
// --- 选择状态 ---
// 1. 订阅整个 store (不推荐,除非你需要 store 的大部分内容)
// 每次 store 更新(即使是你不需要的部分更新了),组件都会重新渲染
// const store = useCounterStore();
// const count = store.count;
// 2. 使用选择器 (Selector) 订阅特定状态 (推荐)
// 只有当 `state.count` 的值发生变化时,组件才会重新渲染
const count = useCounterStore((state) => state.count);
const userName = useCounterStore((state) => state.user.name);
// 3. 使用浅比较 (Shallow Compare) 订阅多个状态
// 只有当 selectedState 对象浅比较不相等时,组件才重新渲染
// 适用于选择多个原始类型值或顶层对象/数组引用
const { items, loading } = useCounterStore(
(state) => ({ items: state.items, loading: state.loading }),
shallow // 使用 zustand/shallow 进行浅比较
);
// 注意:需要 import { shallow } from 'zustand/shallow';
// 如果不使用 shallow,每次渲染都会创建一个新的 { items, loading } 对象,导致不必要的重渲染
// 4. 获取 action (action 函数引用是稳定的,不会引起不必要的重渲染)
const increment = useCounterStore((state) => state.increment);
const decrement = useCounterStore((state) => state.decrement);
const incrementBy = useCounterStore((state) => state.incrementBy);
const updateUserName = useCounterStore((state) => state.updateUserName);
const addItem = useCounterStore((state) => state.addItem);
const fetchData = useCounterStore((state) => state.fetchData);
const logCurrentCount = useCounterStore((state) => state.logCurrentCount);
console.log('CounterDisplay rendered'); // 观察渲染次数
return (
<div style={{ border: '1px solid blue', padding: '10px', margin: '10px' }}>
<h3>Counter Display</h3>
<p>Count: {count}</p>
<p>User Name: {userName}</p>
<div>
Items:
{loading ? (
<span>Loading items...</span>
) : (
<ul>
{items.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
)}
</div>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
<button onClick={() => incrementBy(5)}>Increment by 5</button>
<button onClick={() => updateUserName(`User_${Math.random().toString(36).substring(7)}`)}>
Update User Name
</button>
<button onClick={() => addItem(`item_${Date.now()}`)}>Add Item</button>
<button onClick={fetchData} disabled={loading}>
{loading ? 'Fetching...' : 'Fetch User Data'}
</button>
<button onClick={logCurrentCount}>Log Count in Store</button>
</div>
);
}
// --- 导入 shallow ---
// 在文件顶部添加:
import { shallow } from 'zustand/shallow';
export default CounterDisplay;
代码讲解:
useCounterStore()
: 这是create
返回的 Hook。- 选择器 (Selector) :
useCounterStore(state => state.someValue)
是最核心的用法。它接收一个函数,该函数接收整个 state 对象并返回你感兴趣的部分。 - 渲染优化 : Zustand 内部会对选择器的返回值进行比较(默认使用
Object.is
)。只有当返回值发生变化时,调用该 Hook 的组件才会重新渲染。这是 Zustand 高性能的关键。 - 浅比较 (
shallow
) : 当你需要从 store 中选择多个值并希望仅在这些值中的任何一个发生更改时才重新渲染组件时,可以使用shallow
进行优化。它对选择器返回的对象进行浅层比较。如果不使用shallow
,每次渲染(state) => ({ items: state.items, loading: state.loading })
都会返回一个新的对象引用,即使items
和loading
本身没有改变,也会导致组件重新渲染。 - 获取 Actions : 选择 action 函数(如
state => state.increment
)通常不会引起问题,因为这些函数在 store 初始化时创建,其引用是稳定的。
三、Zustand 原理深入(模拟源码层面解释)
Zustand 的核心非常简洁,我们可以模拟一下它的基本机制:
jsx
// 这是一个极简化的 Zustand 核心原理模拟,并非实际源码
// 实际源码更复杂,包含优化、中间件、React 18 支持等
// 全局存储所有 store 的状态和监听器
const globalStoreRegistry = new Map();
// 模拟 React 的 useState 和 useEffect
const React = {
useState: (initialValue) => {
// 简化模拟,实际 React 实现复杂得多
let value = initialValue;
const setValue = (newValue) => {
value = newValue;
// 触发组件重新渲染 (这里无法真正模拟)
console.log('React: State changed, triggering re-render (simulation)');
};
return [value, setValue];
},
useEffect: (effect, deps) => {
// 简化模拟,只在首次调用
console.log('React: useEffect called (simulation)');
const cleanup = effect();
// 实际 React 会处理依赖变化和卸载时的 cleanup
if (typeof cleanup === 'function') {
// cleanup(); // 模拟组件卸载时调用
}
},
useRef: (initialValue) => ({ current: initialValue }),
// 模拟 React 18 的 useSyncExternalStore (Zustand 内部会用类似的机制)
useSyncExternalStore: (subscribe, getSnapshot, getServerSnapshot) => {
const [state, setState] = React.useState(() => getSnapshot());
React.useEffect(() => {
const unsubscribe = subscribe(() => {
const newState = getSnapshot();
// 只有当快照变化时才更新组件状态
if (!Object.is(state, newState)) {
console.log('useSyncExternalStore: Snapshot changed, updating component state');
setState(newState);
} else {
console.log('useSyncExternalStore: Snapshot unchanged, skipping update');
}
});
// 组件卸载时取消订阅
return () => {
console.log('useSyncExternalStore: Unsubscribing');
unsubscribe();
};
}, [subscribe, getSnapshot]); // 依赖订阅和获取快照的函数
return state;
}
};
// Zustand 的 create 函数模拟
function create(createState) {
// 1. 存储当前 store 的状态
let state;
// 2. 存储订阅了该 store 的监听器 (组件更新函数)
const listeners = new Set();
// 3. 实现 set 函数
const set = (partial, replace = false) => {
console.log('Zustand Core: set called with', partial);
// partial 可以是对象,也可以是函数 (state) => partialState
const nextStateFragment = typeof partial === 'function' ? partial(state) : partial;
// 更新状态
const previousState = state; // 保存旧状态用于比较
state = replace ? nextStateFragment : { ...state, ...nextStateFragment };
console.log('Zustand Core: State updated to', state);
// 4. 通知所有监听器状态已改变
// 使用 setTimeout 批量处理更新,模拟 React 的批处理
// 实际 Zustand 可能有更复杂的批处理或直接依赖 React 的调度
Promise.resolve().then(() => {
listeners.forEach((listener) => {
// 监听器自己负责比较前后状态决定是否更新
listener(state, previousState);
});
console.log(`Zustand Core: Notified ${listeners.size} listeners`);
});
};
// 5. 实现 get 函数
const get = () => state;
// 6. 调用用户传入的函数,初始化状态和 actions
state = createState(set, get);
console.log('Zustand Core: Store initialized with state', state);
// 7. 实现 subscribe 函数 (供 useStore 内部使用)
const subscribe = (listener) => {
listeners.add(listener);
console.log('Zustand Core: Listener added, total:', listeners.size);
// 返回取消订阅函数
return () => {
listeners.delete(listener);
console.log('Zustand Core: Listener removed, total:', listeners.size);
};
};
// 8. 实现 useStore Hook (核心部分)
const useStore = (selector = (s) => s, equalityFn = Object.is) => {
// selector: 用户提供的选择函数,用于从 state 中提取所需部分
// equalityFn: 比较函数,用于判断选择的部分是否真的改变了
// 模拟 useSyncExternalStore 的用法
const selectedState = React.useSyncExternalStore(
(onStoreChange) => {
// 这个函数会被 useSyncExternalStore 调用,用于订阅 store 的变化
console.log(`useStore Hook (${selector.toString()}): Subscribing to store changes.`);
const unsubscribe = subscribe((newState, previousState) => {
// 当 store 变化时,重新运行 selector 获取新的选中状态
const newSelectedState = selector(newState);
const oldSelectedState = selector(previousState); // 需要旧状态来做比较
// 使用 equalityFn 比较新旧选中状态
if (!equalityFn(newSelectedState, oldSelectedState)) {
console.log(`useStore Hook (${selector.toString()}): Selected state changed, notifying React.`);
onStoreChange(); // 通知 useSyncExternalStore 快照已改变
} else {
console.log(`useStore Hook (${selector.toString()}): Selected state unchanged, skipping notification.`);
}
});
return unsubscribe; // 返回取消订阅函数
},
() => selector(state), // 获取当前选中状态的快照
() => selector(state) // 服务端渲染快照 (简化)
);
// ---- 以下是旧的基于 useState/useEffect 的模拟,理解原理有帮助 ----
// // 使用 useState 存储组件关心的那部分状态
// const [slice, setSlice] = React.useState(() => selector(state));
// const selectorRef = React.useRef(selector);
// const equalityFnRef = React.useRef(equalityFn);
// const stateRef = React.useRef(state); // 保存当前 state 引用
// const sliceRef = React.useRef(slice); // 保存当前 slice 引用
// // 更新 Refs,确保 useEffect 中能拿到最新的 selector 和 equalityFn
// selectorRef.current = selector;
// equalityFnRef.current = equalityFn;
// stateRef.current = state;
// sliceRef.current = slice;
// React.useEffect(() => {
// console.log(`useStore Hook (${selectorRef.current.toString()}): useEffect setup`);
// // 组件挂载或 selector/equalityFn 变化时,订阅 store
// const unsubscribe = subscribe((currentState, previousState) => {
// // store 状态变化时的回调
// const currentSelector = selectorRef.current;
// const currentEqualityFn = equalityFnRef.current;
// const previousSlice = sliceRef.current; // 获取上一次渲染时的 slice
// const nextSlice = currentSelector(currentState); // 计算新的 slice
// // 使用比较函数判断 slice 是否真的改变了
// if (!currentEqualityFn(previousSlice, nextSlice)) {
// console.log(`useStore Hook (${currentSelector.toString()}): Slice changed, updating component state`);
// sliceRef.current = nextSlice; // 更新 slice 引用
// setSlice(nextSlice); // 触发组件重新渲染
// } else {
// console.log(`useStore Hook (${currentSelector.toString()}): Slice unchanged, skipping update`);
// }
// });
// // 组件卸载时取消订阅
// return () => {
// console.log(`useStore Hook (${selectorRef.current.toString()}): useEffect cleanup - unsubscribing`);
// unsubscribe();
// };
// }, []); // 依赖项为空数组,仅在挂载和卸载时运行订阅/取消订阅逻辑
// // 注意:实际 Zustand 的实现可能更复杂,会处理 selector 或 equalityFn 变化的情况
return selectedState; // 返回选中的状态部分
};
// 9. 暴露 API (useStore Hook, set, get, subscribe 等)
const api = { destroy: () => listeners.clear(), setState: set, getState: get, subscribe };
Object.assign(useStore, api); // 将 API 附加到 Hook 上,方便外部访问
// 将 store 实例存入注册表(可选,用于某些高级场景或调试)
// globalStoreRegistry.set(createState, { state, listeners, api });
return useStore; // 返回自定义 Hook
}
// --- 模拟使用 ---
console.log("--- Simulating Store Creation ---");
const useMySimulatedStore = create((set, get) => ({
value: 0,
name: 'Test',
increment: () => set((state) => ({ value: state.value + 1 })),
setName: (name) => set({ name }),
}));
console.log("\n--- Simulating Component 1 (Selects value) ---");
function Component1() {
console.log("Component1 rendering...");
const value = useMySimulatedStore(state => state.value);
console.log("Component1 selected value:", value);
// ... render logic using value ...
return `Component1 Value: ${value}`;
}
Component1(); // 初始渲染
console.log("\n--- Simulating Component 2 (Selects name) ---");
function Component2() {
console.log("Component2 rendering...");
const name = useMySimulatedStore(state => state.name);
console.log("Component2 selected name:", name);
// ... render logic using name ...
return `Component2 Name: ${name}`;
}
Component2(); // 初始渲染
console.log("\n--- Simulating Action: increment ---");
const incrementAction = useMySimulatedStore.getState().increment; // 获取 action
incrementAction(); // 调用 action 更新状态
// 状态更新后,理论上 React 会重新调度渲染
console.log("\n--- Simulating Re-render after increment ---");
Component1(); // Component1 应该重新渲染,因为 value 变了
Component2(); // Component2 不应该重新渲染,因为 name 没变 (基于 selector 优化)
console.log("\n--- Simulating Action: setName ---");
const setNameAction = useMySimulatedStore.getState().setName;
setNameAction('New Name');
console.log("\n--- Simulating Re-render after setName ---");
Component1(); // Component1 不应该重新渲染
Component2(); // Component2 应该重新渲染
原理总结:
-
闭包状态 :
create
函数创建一个闭包,state
变量和listeners
集合被保存在这个闭包中,不会被外部直接访问。 -
set
函数 : 这是唯一修改闭包中state
的方式。它接收部分状态或更新函数,计算出新状态,然后替换或合并旧状态。 -
get
函数: 提供在 actions 内部同步访问最新状态的能力。 -
订阅/发布模式:
subscribe
函数允许外部(主要是useStore
Hook)注册一个监听器(回调函数)。- 当
set
函数更新状态后,它会遍历listeners
集合,并调用每一个监听器。
-
useStore
Hook:-
这是连接 React 组件和 Zustand store 的桥梁。
-
内部使用 React 的
useSyncExternalStore
(或类似机制,如useState
+useEffect
的组合) 来实现订阅。 -
核心优化 : 组件通过
selector
函数告诉useStore
它关心状态的哪一部分。 -
当 store 变化时,
useStore
内部的订阅回调会执行:- 用
selector
计算出新的选中状态 (nextSlice
)。 - 用
equalityFn
(默认为Object.is
,或用户指定的shallow
等) 比较nextSlice
和上一次的选中状态 (previousSlice
)。 - 只有当比较结果表明状态确实改变了 ,
useStore
才会通知 React 更新组件状态,从而触发组件的重新渲染。
- 用
-
-
proxy-compare
优化 (未在模拟中展示) : 对于更复杂的选择器(例如返回对象),Zustand 可以使用proxy-compare
库。它通过 Proxy 跟踪选择器函数在执行期间实际访问了 state 的哪些属性。然后,只有当这些被访问过的属性发生变化时,equalityFn
才会认为 slice 改变了,这比简单的浅比较更精确,能避免更多不必要的重渲染。
四、中间件 (Middleware)
Zustand 支持中间件来增强 store 的功能,用法类似函数组合。
1. Redux DevTools
在开发过程中连接 Redux DevTools 浏览器扩展,方便调试状态变化。
bash
# DevTools 通常作为开发依赖安装
npm install --save-dev @redux-devtools/extension
# 或者
yarn add --dev @redux-devtools/extension
jsx
// src/store/counterStoreWithDevtools.js
import { create } from 'zustand';
import { devtools } from 'zustand/middleware'; // 导入 devtools 中间件
// 将原始的 store 创建函数包裹在 devtools 中间件里
const useCounterStore = create(
devtools(
(set, get) => ({
// ... (和之前的 counterStore.js 内容一样)
count: 0,
user: { name: '匿名', age: 0 },
items: ['apple', 'banana'],
increment: () => set((state) => ({ count: state.count + 1 }), false, 'counter/increment'), // 第三个参数为 action 类型
decrement: () => set((state) => ({ count: state.count - 1 }), false, 'counter/decrement'),
incrementBy: (amount) => set((state) => ({ count: state.count + amount }), false, { type: 'counter/incrementBy', payload: amount }), // 可以是对象
updateUserName: (newName) => set((state) => ({ user: { ...state.user, name: newName } }), false, 'user/updateName'),
addItem: (item) => set((state) => ({ items: [...state.items, item] }), false, 'items/add'),
removeItem: (itemToRemove) => set((state) => ({ items: state.items.filter(item => item !== itemToRemove) }), false, 'items/remove'),
fetchData: async () => {
set({ loading: true, error: null }, false, 'user/fetch/pending');
try {
const response = await new Promise((resolve) =>
setTimeout(() => resolve({ data: { name: 'Fetched User', age: 30 } }), 1000)
);
set({ user: response.data, loading: false }, false, 'user/fetch/fulfilled');
} catch (error) {
set({ error: error.message, loading: false }, false, { type: 'user/fetch/rejected', error: error.message });
}
},
// ... 其他 actions
}),
{
name: 'CounterStore', // DevTools 中显示的 store 名称
// 其他 DevTools 配置选项
// enabled: process.env.NODE_ENV === 'development', // 只在开发环境启用
}
)
);
export default useCounterStore;
讲解:
import { devtools } from 'zustand/middleware';
- 将
create
的回调函数用devtools()
包裹起来。 devtools
的第二个参数是可选的配置对象,可以设置 store 名称等。- 为了在 DevTools 中更好地追踪,建议在
set
的第三个参数中提供 action 的名称或类型。这会显示在 DevTools 的 action 列表中。
2. 持久化 (Persist)
将 store 的状态持久化到 localStorage
, sessionStorage
或其他存储中。
bash
# 持久化中间件内置在 zustand 中
jsx
// src/store/persistentCounterStore.js
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
const usePersistentCounterStore = create(
// 可以组合多个中间件
devtools( // 先包裹 devtools
persist( // 再包裹 persist
(set, get) => ({
// ... 状态和 actions ...
count: 0,
lastUpdated: null,
// ... 其他 actions ...
increment: () => set((state) => ({ count: state.count + 1, lastUpdated: new Date().toISOString() }), false, 'counter/increment'),
}),
{
name: 'counter-storage', // localStorage 中的 key 名称
// storage: createJSONStorage(() => sessionStorage), // 可以选择 sessionStorage
// partialize: (state) => ({ count: state.count }), // 只持久化部分 state
// onRehydrateStorage: (state) => { // Hydration 完成后的回调
// console.log('Hydration finished');
// return (state, error) => {
// if (error) {
// console.log('An error happened during hydration', error);
// } else {
// console.log('Successfully rehydrated state:', state);
// // 可以在这里做一些初始化操作,比如 state.lastUpdated = null;
// }
// };
// },
// version: 1, // 版本号,用于迁移
// migrate: (persistedState, version) => { // 迁移函数
// if (version === 0) {
// // 从旧版本迁移数据
// // persistedState.newField = defaultValue;
// }
// return persistedState;
// },
}
),
{ name: 'PersistentCounterStore' } // DevTools 名称
)
);
export default usePersistentCounterStore;
讲解:
-
import { persist } from 'zustand/middleware';
-
将 store 创建函数包裹在
persist()
中。 -
persist
的第二个参数是配置对象:name
: 存储的 key。storage
: 指定存储引擎,默认为localStorage
。可以使用createJSONStorage(() => sessionStorage)
或自定义存储。partialize
: 函数,返回需要持久化的状态部分。onRehydrateStorage
: 从存储恢复状态(hydration)时的回调。version
和migrate
: 用于状态版本管理和迁移。
3. Immer 集成
使用 Immer 可以更方便地处理不可变状态更新,尤其是对于深层嵌套的对象和数组。
csharp
npm install immer
# 或者
yarn add immer
jsx
// src/store/immerCounterStore.js
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer'; // 导入 immer 中间件
const useImmerCounterStore = create(
devtools(
immer( // 使用 immer 包裹
(set, get) => ({
count: 0,
user: {
name: '匿名',
details: {
address: { street: '123 Main St', city: 'Anytown' },
hobbies: ['reading', 'coding']
}
},
items: [{ id: 1, text: 'Buy milk' }, { id: 2, text: 'Write code' }],
// 使用 Immer 后,可以直接修改 state "草稿" (draft)
increment: () => set((state) => { state.count += 1; }), // 直接修改
updateStreet: (newStreet) => set((state) => {
state.user.details.address.street = newStreet; // 直接修改嵌套属性
}),
addHobby: (hobby) => set((state) => {
state.user.details.hobbies.push(hobby); // 直接 push
}),
toggleItem: (id) => set((state) => {
const item = state.items.find(item => item.id === id);
if (item) {
item.completed = !item.completed; // 直接修改数组中的对象
}
}),
// 异步操作依然可以在外部处理,或者在 immer 内部处理(但不推荐在 immer recipe 中执行副作用)
fetchDataAndUpdate: async () => {
// 异步操作本身在 immer 外
const data = await Promise.resolve({ name: 'Fetched Immer User' });
// 获取数据后,在 set 中使用 immer 更新
set(state => {
state.user.name = data.name;
});
}
})
),
{ name: 'ImmerCounterStore' }
)
);
export default useImmerCounterStore;
讲解:
import { immer } from 'zustand/middleware/immer';
- 将 store 创建函数用
immer()
包裹。 - 在
set
的回调函数中,接收到的state
参数现在是一个 Immer 的draft
对象。你可以像修改普通 JavaScript 对象或数组一样直接修改它(例如state.count += 1
,state.user.details.hobbies.push(...)
)。 - Immer 会在后台处理这些修改,并生成一个符合不可变性要求的新状态。
五、Zustand vs. Redux
特性 | Zustand | Redux (通常与 React-Redux, Redux Toolkit 结合) |
---|---|---|
核心理念 | 极简、无模板代码、基于 Hooks、利用闭包和订阅模式管理状态 | 单一状态树、纯函数 Reducers、显式 Actions、严格的单向数据流 |
模板代码量 | 非常少,只需 create |
较多 (Actions, Action Creators, Reducers, Store 配置, Selectors) - RTK 大幅减少 |
API 复杂度 | 非常简单 (create , useStore , set , get ) |
相对复杂 (configureStore, createSlice, createAsyncThunk, useSelector, useDispatch) |
学习曲线 | 平缓 | 较陡峭 (需要理解 Flux/Redux 架构) |
不可变性 | 需要手动保证(或使用 Immer 中间件) | Reducers 必须是纯函数,强制不可变性(RTK 内部使用 Immer) |
异步操作 | 直接在 store actions 中处理 async/await |
通常使用中间件 (Thunk, Saga),RTK 提供 createAsyncThunk |
中间件 | 支持,生态相对较小但够用 (devtools, persist, immer 等) | 非常成熟和庞大的中间件生态 (logger, saga, thunk, router 等) |
DevTools 集成 | 通过 devtools 中间件良好支持 |
原生支持,生态核心部分 |
选择器优化 | 内建优化(Object.is , shallow , proxy-compare ),默认开启 |
需要 useSelector ,依赖比较函数(默认引用比较),需自行优化 (reselect) |
与 React 耦合 | 核心库可独立使用,useStore Hook 与 React 强相关 |
核心库独立,react-redux 提供绑定 |
Bundle 大小 | 非常小 (~1KB gzipped) | 核心库小,但加上 react-redux 和 redux-toolkit 会稍大 |
社区与生态 | 快速增长,活跃 | 非常庞大,成熟,资源丰富 |
适用场景 | 中小型项目、需要快速开发、偏爱 Hooks、不喜欢模板代码的项目 | 大型复杂应用、需要严格数据流管理、团队成员熟悉 Redux 的项目 |
总结对比:
- Zustand 更简单、更快速上手: 对于不喜欢 Redux 繁琐配置和概念的开发者,Zustand 是一个极具吸引力的选择。它大大减少了样板代码,使得状态管理逻辑更集中。
- Redux 更规范、生态更成熟: 对于需要严格遵循单向数据流、拥有复杂状态交互、或者团队已经深度使用 Redux 生态(如 Saga)的大型项目,Redux(尤其是 Redux Toolkit)仍然是一个非常可靠和强大的选择。它的规范性有助于大型团队协作和长期维护。
- 性能: 两者都可以通过选择器优化实现高性能。Zustand 的默认优化可能更"开箱即用"一些。
- 灵活性: Zustand 更加灵活,不强制特定的代码组织方式。Redux 则更倾向于结构化的方法。
六、更详细的代码示例:Todo List 应用
这个例子将结合多种 Zustand 特性,包括状态、actions、异步操作、中间件(devtools 和 persist)以及选择器。
jsx
// src/store/todoStore.js
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer'; // 使用 Immer 简化更新
// 模拟 API
const fakeApi = {
fetchTodos: async () => {
console.log('API: Fetching todos...');
await new Promise(resolve => setTimeout(resolve, 800));
// 模拟可能存在的已存储 todos
const storedTodos = localStorage.getItem('todos-storage'); // 假设 persist key 是 'todos-storage'
if (storedTodos && JSON.parse(storedTodos)?.state?.todos?.length > 0) {
console.log('API: Found stored todos, skipping fetch simulation.');
// 如果持久化存储中有数据,可能就不需要从"API"加载初始数据了
// 或者这里可以模拟合并服务器数据和本地数据
return JSON.parse(storedTodos).state.todos; // 返回持久化的数据作为模拟 API 结果
}
console.log('API: No stored todos found, returning default set.');
return [
{ id: Date.now() + 1, text: 'Learn Zustand', completed: true },
{ id: Date.now() + 2, text: 'Build a Todo App', completed: false },
];
},
saveTodo: async (todo) => {
console.log('API: Saving todo...', todo);
await new Promise(resolve => setTimeout(resolve, 300));
return { ...todo, saved: true }; // 模拟保存成功
}
};
// 定义 Todo 类型 (TypeScript 风格,JS 中可省略)
// interface Todo {
// id: number;
// text: string;
// completed: boolean;
// saving?: boolean; // 标记是否正在保存
// }
// 定义 Store State 类型 (TypeScript 风格)
// interface TodoState {
// todos: Todo[];
// filter: 'all' | 'active' | 'completed';
// loading: boolean;
// error: string | null;
// addTodo: (text: string) => Promise<void>; // 异步 action
// toggleTodo: (id: number) => void;
// removeTodo: (id: number) => void;
// setFilter: (filter: 'all' | 'active' | 'completed') => void;
// fetchTodos: () => Promise<void>; // 异步 action
// getFilteredTodos: () => Todo[]; // 派生状态的选择器逻辑可以在组件中实现,或放在 store 中(但不推荐)
// }
const useTodoStore = create(
devtools(
persist(
immer( // 最内层使用 immer 处理状态更新
(set, get) => ({
// --- State ---
todos: [],
filter: 'all', // 'all', 'active', 'completed'
loading: false,
error: null,
// --- Actions ---
fetchTodos: async () => {
if (get().todos.length > 0) {
console.log("Store: Todos already loaded (possibly from persistence), skipping fetch.");
return; // 如果已有数据(可能来自持久化),则不重新获取
}
set((state) => { state.loading = true; state.error = null; });
try {
const fetchedTodos = await fakeApi.fetchTodos();
set((state) => {
state.todos = fetchedTodos;
state.loading = false;
});
} catch (err) {
set((state) => {
state.error = err.message || 'Failed to fetch todos';
state.loading = false;
});
}
},
addTodo: async (text) => {
if (!text.trim()) return; // 不添加空内容
const newTodo = {
id: Date.now(),
text: text.trim(),
completed: false,
saving: true, // 标记开始保存
};
// 先快速更新 UI
set((state) => {
state.todos.push(newTodo);
});
try {
// 调用 API 保存
await fakeApi.saveTodo(newTodo);
// API 保存成功后更新状态 (移除 saving 标记)
set((state) => {
const todoIndex = state.todos.findIndex(t => t.id === newTodo.id);
if (todoIndex !== -1) {
state.todos[todoIndex].saving = false;
// 可以添加 saved: true 标记,如果需要的话
// state.todos[todoIndex].saved = true;
}
});
} catch (err) {
console.error("Failed to save todo:", err);
// 可以添加错误处理逻辑,比如标记 todo 保存失败
set((state) => {
const todoIndex = state.todos.findIndex(t => t.id === newTodo.id);
if (todoIndex !== -1) {
state.todos[todoIndex].saving = false; // 移除 saving 标记
state.todos[todoIndex].error = 'Save failed'; // 添加错误标记
}
});
}
},
toggleTodo: (id) => {
set((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) {
todo.completed = !todo.completed;
}
});
// 可以在这里触发异步保存状态的 API 调用
// fakeApi.updateTodoCompletion(id, get().todos.find(t => t.id === id)?.completed);
},
removeTodo: (id) => {
set((state) => {
state.todos = state.todos.filter((t) => t.id !== id);
});
// 可以在这里触发异步删除的 API 调用
// fakeApi.deleteTodo(id);
},
setFilter: (filter) => {
set({ filter }); // 使用对象形式 set,因为 filter 是顶层状态
// 或者 set(state => { state.filter = filter });
},
clearCompleted: () => {
set(state => {
state.todos = state.todos.filter(todo => !todo.completed);
})
}
// --- Getters (通常在组件中使用选择器实现) ---
// 不推荐在 store 内部定义复杂的 getter 函数,因为它们不会被 Zustand 自动优化
// 应该在组件中使用 useStore(selector) 来计算派生状态
// getFilteredTodos: () => {
// const { todos, filter } = get();
// switch (filter) {
// case 'active':
// return todos.filter((todo) => !todo.completed);
// case 'completed':
// return todos.filter((todo) => todo.completed);
// default: // 'all'
// return todos;
// }
// }
})
),
// Persist 配置
{
name: 'todos-storage', // localStorage key
storage: createJSONStorage(() => localStorage), // 显式指定 localStorage
partialize: (state) => ({ todos: state.todos, filter: state.filter }), // 只持久化 todos 和 filter
onRehydrateStorage: (state) => {
console.log("TodoStore: Hydration started");
return (hydratedState, error) => {
if (error) {
console.error("TodoStore: Hydration failed!", error);
} else {
console.log("TodoStore: Hydration successful!", hydratedState);
// 可以在这里进行 hydration 后的清理或设置
// 例如,移除所有 todo 的 saving 状态
if (hydratedState?.todos) {
set(state => {
state.todos.forEach(t => {
if (t.saving) t.saving = false;
if (t.error) delete t.error;
});
});
}
}
};
},
}
),
// DevTools 配置
{
name: 'TodoAppStore',
// 可以为 action 添加更具体的类型,方便调试
actionCreators: {
addTodo: (text) => ({ type: 'todos/add', payload: text }),
toggleTodo: (id) => ({ type: 'todos/toggle', payload: id }),
removeTodo: (id) => ({ type: 'todos/remove', payload: id }),
setFilter: (filter) => ({ type: 'filter/set', payload: filter }),
fetchTodos: () => ({ type: 'todos/fetch' }),
clearCompleted: () => ({ type: 'todos/clearCompleted'})
}
}
)
);
// --- 在组件中使用 ---
// src/components/TodoList.js
import React, { useEffect, useMemo } from 'react';
// import useTodoStore from '../store/todoStore'; // 导入 store Hook
import { shallow } from 'zustand/shallow'; // 导入 shallow
// 假设 useTodoStore 已经从 '../store/todoStore' 导入
function TodoList() {
// --- 选择状态和 Actions ---
const { todos, filter, loading, error, toggleTodo, removeTodo, fetchTodos } = useTodoStore(
(state) => ({
todos: state.todos,
filter: state.filter,
loading: state.loading,
error: state.error,
toggleTodo: state.toggleTodo,
removeTodo: state.removeTodo,
fetchTodos: state.fetchTodos,
}),
shallow // 使用 shallow 比较,因为我们选择了多个状态/函数
);
// --- 计算派生状态 (Filtered Todos) ---
// 使用 useMemo 缓存计算结果,只有当 todos 或 filter 变化时才重新计算
const filteredTodos = useMemo(() => {
console.log("Calculating filtered todos...");
switch (filter) {
case 'active':
return todos.filter((todo) => !todo.completed);
case 'completed':
return todos.filter((todo) => todo.completed);
default: // 'all'
return todos;
}
}, [todos, filter]); // 依赖项是 todos 和 filter
// --- 处理副作用 (如初始加载) ---
useEffect(() => {
console.log("TodoList useEffect: Checking if fetch is needed.");
// 组件挂载时尝试获取 todos (store 内部会判断是否真的需要 fetch)
fetchTodos();
}, [fetchTodos]); // fetchTodos 的引用是稳定的
console.log('TodoList rendered');
// --- 渲染逻辑 ---
if (loading && todos.length === 0) { // 只有在初始加载时显示 Loading
return <div>Loading todos...</div>;
}
if (error) {
return <div style={{ color: 'red' }}>Error: {error}</div>;
}
return (
<div>
<h4>Todo List (Filter: {filter})</h4>
{filteredTodos.length === 0 && !loading && <p>No todos found.</p>}
<ul style={{ listStyle: 'none', padding: 0 }}>
{filteredTodos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onToggle={toggleTodo} onRemove={removeTodo} />
))}
</ul>
</div>
);
}
// src/components/TodoItem.js
// (Props: todo, onToggle, onRemove) - 保持为展示组件,通过 props 接收数据和回调
function TodoItem({ todo, onToggle, onRemove }) {
console.log(`TodoItem ${todo.id} rendered`);
return (
<li style={{
display: 'flex',
alignItems: 'center',
padding: '8px 0',
borderBottom: '1px solid #eee',
opacity: todo.completed ? 0.6 : 1,
textDecoration: todo.completed ? 'line-through' : 'none',
backgroundColor: todo.saving ? '#eee' : (todo.error ? '#fdd' : 'transparent')
}}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
style={{ marginRight: '10px' }}
/>
<span style={{ flexGrow: 1 }}>{todo.text} {todo.saving && '(Saving...)'} {todo.error && `(${todo.error})`}</span>
<button onClick={() => onRemove(todo.id)} style={{ marginLeft: '10px', color: 'red', border: 'none', background: 'none', cursor: 'pointer' }}>
X
</button>
</li>
);
}
// src/components/AddTodo.js
import React, { useState } from 'react';
// import useTodoStore from '../store/todoStore';
function AddTodo() {
const [text, setText] = useState('');
// 只选择需要的 action
const addTodo = useTodoStore((state) => state.addTodo);
// 获取 loading 状态,防止重复提交或在加载时提交
const loading = useTodoStore((state) => state.loading);
console.log('AddTodo rendered');
const handleSubmit = async (e) => {
e.preventDefault();
if (!text.trim() || loading) return;
try {
await addTodo(text); // 调用异步 action
setText(''); // 成功后清空输入框
} catch (error) {
console.error("Failed to add todo from component:", error);
// 可以在这里显示错误提示给用户
}
};
return (
<form onSubmit={handleSubmit} style={{ marginTop: '15px' }}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="What needs to be done?"
disabled={loading}
style={{ marginRight: '10px', padding: '5px' }}
/>
<button type="submit" disabled={loading || !text.trim()}>
{loading ? 'Adding...' : 'Add Todo'}
</button>
</form>
);
}
// src/components/TodoFilter.js
import React from 'react';
// import useTodoStore from '../store/todoStore';
// import { shallow } from 'zustand/shallow';
function TodoFilter() {
const { filter, setFilter, clearCompleted, todos } = useTodoStore(state => ({
filter: state.filter,
setFilter: state.setFilter,
clearCompleted: state.clearCompleted,
todos: state.todos // 需要 todos 来计算 active count
}), shallow);
const activeCount = useMemo(() => todos.filter(t => !t.completed).length, [todos]);
const hasCompleted = useMemo(() => todos.some(t => t.completed), [todos]);
console.log('TodoFilter rendered');
return (
<div style={{ marginTop: '15px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{activeCount} item{activeCount !== 1 ? 's' : ''} left</span>
<div>
<button onClick={() => setFilter('all')} disabled={filter === 'all'} style={getButtonStyle(filter === 'all')}>All</button>
<button onClick={() => setFilter('active')} disabled={filter === 'active'} style={getButtonStyle(filter === 'active')}>Active</button>
<button onClick={() => setFilter('completed')} disabled={filter === 'completed'} style={getButtonStyle(filter === 'completed')}>Completed</button>
</div>
<button onClick={clearCompleted} disabled={!hasCompleted}>
Clear Completed
</button>
</div>
);
}
function getButtonStyle(isActive) {
return {
margin: '0 5px',
padding: '3px 7px',
border: isActive ? '1px solid #aaa' : '1px solid transparent',
cursor: 'pointer',
background: isActive ? '#f0f0f0' : 'none'
};
}
// src/App.js (组装 Todo 应用)
import React from 'react';
// import TodoList from './components/TodoList';
// import AddTodo from './components/AddTodo';
// import TodoFilter from './components/TodoFilter';
function App() {
return (
<div style={{ maxWidth: '500px', margin: '20px auto', padding: '20px', border: '1px solid #ccc', borderRadius: '5px' }}>
<h2>Zustand Todo App</h2>
<AddTodo />
<TodoList />
<TodoFilter />
</div>
);
}
// export default App; // 导出 App 组件
// --- 确保在应用入口或相关组件中导入并使用这些组件 ---
// 例如,在你的主应用文件中:
// import ReactDOM from 'react-dom/client';
// import App from './App'; // 导入上面的 App 组件
// const root = ReactDOM.createRoot(document.getElementById('root'));
// root.render(<React.StrictMode><App /></React.StrictMode>);
这个 Todo 应用示例展示了:
- 状态定义 (
todos
,filter
,loading
,error
) - 同步 Actions (
toggleTodo
,removeTodo
,setFilter
,clearCompleted
) - 异步 Actions (
fetchTodos
,addTodo
) 并处理加载和错误状态 - 使用 Immer 中间件简化嵌套状态和数组的更新
- 使用 Persist 中间件将
todos
和filter
持久化到 localStorage - 使用 DevTools 中间件进行调试
- 在组件中使用
useStore
Hook 选择所需的状态和 actions - 使用
shallow
比较优化选择多个状态的组件 - 使用
useMemo
在组件中计算派生状态 (filteredTodos
,activeCount
) - 使用
useEffect
处理副作用(初始数据加载) - 组件结构划分 (
TodoList
,TodoItem
,AddTodo
,TodoFilter
)
七、总结
Zustand 提供了一种现代、简洁且高效的方式来管理 React 应用的状态。它通过巧妙利用 Hooks 和闭包,避免了 Redux 的大部分模板代码,同时通过精细的选择器优化保证了良好的性能。对于许多项目,尤其是那些觉得 Redux 过重的项目,Zustand 是一个非常值得考虑的优秀替代方案。它的中间件系统也提供了足够的扩展性来满足常见的开发需求,如调试和持久化。