常见陷阱与最佳实践
本章汇总 Zustand 开发中最常见的陷阱和最佳实践,每个陷阱附有错误示例和正确做法。
陷阱速查表
| 陷阱 | 严重度 | 章节 |
|---|---|---|
| 选择器返回新对象/数组导致无限重渲染 | 🔴 高 | §1 |
set 中直接修改旧引用 |
🔴 高 | §2 |
| 解构整个 Store(无选择器) | 🟡 中 | §3 |
| 闭包中读取过时的 state | 🟡 中 | §4 |
| persist hydration 异步导致 UI 闪烁 | 🟡 中 | §5 |
| 忘记创建新 Map/Set 实例 | 🟡 中 | §6 |
| 巨型单一 Store 膨胀 | 🟢 低 | §7 |
| 滥用 subscribe(组件中) | 🟢 低 | §8 |
create<T>()(...) 遗漏双重括号 |
🟢 低 | §9 |
| v4 → v5 导入路径变化 | 🟢 低 | §10 |
§1 选择器返回新对象/数组导致无限重渲染
// ❌ 每次选择器执行都创建新对象 → Object.is 永远 false → 无限重渲染
const { bears, fishes } = useStore((s) => ({
bears: s.bears,
fishes: s.fishes,
}));
// ❌ 每次返回新数组
const [bears, fishes] = useStore((s) => [s.bears, s.fishes]);
// ✅ 方式 1:useShallow
import { useShallow } from 'zustand/react/shallow';
const { bears, fishes } = useStore(
useShallow((s) => ({ bears: s.bears, fishes: s.fishes }))
);
// ✅ 方式 2:分别订阅
const bears = useStore((s) => s.bears);
const fishes = useStore((s) => s.fishes);
// ✅ 方式 3:选择器返回原始值
const total = useStore((s) => s.bears + s.fishes); // number 是原始值
💡 最佳实践:默认让选择器返回原始值(number、string、boolean)。需要同时取多个字段时,用
useShallow包裹。
§2 set 中直接修改旧引用
// ❌ 直接修改数组 → 引用不变 → 不触发重渲染
set((state) => {
state.todos.push(newTodo);
return state; // 返回同一个引用!
});
// ❌ 直接修改对象属性
set((state) => {
state.user.name = 'NewName';
return { user: state.user }; // user 引用没变!
});
// ✅ 创建新引用
set((state) => ({
todos: [...state.todos, newTodo],
user: { ...state.user, name: 'NewName' },
}));
// ✅ 使用 Immer 中间件
import { immer } from 'zustand/middleware/immer';
create(
immer((set) => ({
todos: [],
addTodo: (text) =>
set((s) => {
s.todos.push({ text, done: false }); // Immer 下安全
}),
}))
);💡 最佳实践:有深层嵌套状态时用 Immer。状态结构扁平时手动展开即可。
§3 解构整个 Store(无选择器)
// ❌ 订阅整个 Store → 任何字段变化都重渲染
function Component() {
const { bears, fishes } = useStore();
// ...
}
// ✅ 用选择器精确订阅
function Component() {
const bears = useStore((s) => s.bears);
const fishes = useStore((s) => s.fishes);
// bears 变化时才重渲染,fishes 变化不会触发
}
// ✅ 如果需要多个字段,用 useShallow
function Component() {
const { bears, fishes } = useStore(
useShallow((s) => ({ bears: s.bears, fishes: s.fishes }))
);
}§4 闭包中读取过时的 state
// ❌ 在组件闭包中捕获 state → 得到的是渲染时刻的快照
function Component() {
const bears = useStore((s) => s.bears);
const increase = useStore((s) => s.increase);
const handleClick = () => {
// bears 是闭包中的旧值
if (bears < 10) increase();
};
}
// ✅ 方式 1:在 action 中用 get() 或 set 回调
const useStore = create((set, get) => ({
bears: 0,
increaseIfBelow10: () => {
if (get().bears < 10) { // get() 永远是最新的
set((s) => ({ bears: s.bears + 1 }));
}
},
}));
// ✅ 方式 2:在 action 中用 set 的函数式更新
const useStore = create((set) => ({
bears: 0,
increaseIfBelow10: () =>
set((s) => (s.bears < 10 ? { bears: s.bears + 1 } : {})),
}));💡 最佳实践:把依赖最新状态的逻辑放在Store 的 action 内部(用
get()或set()的回调参数),而不是在组件闭包中处理。
§5 persist hydration 异步导致 UI 闪烁
// ❌ 问题:首次渲染时 Store 还是初始值,hydration 完成后才更新
// 用户会看到从初始值到持久化值的闪烁
// ✅ 方案 1:skipHydration + 手动控制
const useStore = create(
persist(
(set) => ({ theme: 'light', setTheme: (t) => set({ theme: t }) }),
{ name: 'settings', skipHydration: true }
)
);
function App() {
const [ready, setReady] = useState(false);
useEffect(() => {
// 手动触发 hydration
useStore.persist.rehydrate();
// 等待完成
const unsub = useStore.persist.onFinishHydration(() => setReady(true));
return unsub;
}, []);
if (!ready) return <LoadingScreen />; // hydration 完成后再渲染
return <MainApp />;
}
// ✅ 方案 2:在 onRehydrateStorage 回调中设置 ready
function useHydrated() {
const [hydrated, setHydrated] = useState(useStore.persist.hasHydrated());
useEffect(() => {
const unsub = useStore.persist.onFinishHydration(() => setHydrated(true));
return unsub;
}, []);
return hydrated;
}§6 忘记创建新 Map/Set 实例
// ❌ 直接修改 Map → 引用不变 → 不触发更新
const useStore = create((set) => ({
users: new Map(),
addUser: (user) =>
set((s) => {
s.users.set(user.id, user); // 修改了同一个 Map!
return { users: s.users }; // 返回旧引用 → 不更新
}),
}));
// ✅ 创建新 Map
addUser: (user) =>
set((s) => {
const next = new Map(s.users);
next.set(user.id, user);
return { users: next };
}),
// ✅ 或用 Immer
import { immer } from 'zustand/middleware/immer';
create(
immer((set) => ({
users: new Map(),
addUser: (user) => set((s) => { s.users.set(user.id, user); }),
}))
);§7 巨型单一 Store 膨胀
// ❌ 所有状态挤在一个 Store 里
const useAppStore = create((set) => ({
// 用户相关
user: null, login: () => {}, logout: () => {},
// 购物车相关
cart: [], addToCart: () => {}, removeFromCart: () => {},
// UI 相关
theme: 'light', sidebarOpen: false, toggleSidebar: () => {},
// 20+ 个字段和 action...
}));
// ✅ 按功能域拆分多个 Store
// stores/useUserStore.ts
export const useUserStore = create((set) => ({
user: null, login: () => {}, logout: () => {},
}));
// stores/useCartStore.ts
export const useCartStore = create((set, get) => ({
items: [], add: () => {}, total: () => {},
}));
// stores/useUIStore.ts
export const useUIStore = create((set) => ({
theme: 'light', sidebarOpen: false, toggleSidebar: () => {},
}));💡 最佳实践:Zustand 推崇多 Store 按功能域拆分。一个 Store 不应超过 10 个字段(含 action)。超大 Store 也会让选择器粒度变粗,失去性能优势。
§8 在组件中用 subscribe 替代选择器
// ❌ 在组件中用 subscribe 同步状态 → 手动管理订阅,容易遗漏清理
function Component() {
const [bears, setBears] = useState(0);
useEffect(() => {
const unsub = useStore.subscribe((s) => setBears(s.bears));
return unsub;
}, []);
}
// ✅ 直接用选择器 → Zustand 自动管理订阅和清理
function Component() {
const bears = useStore((s) => s.bears);
}💡 最佳实践:
subscribe用于组件外部(日志、副作用、非 React 环境等)。组件内永远使用选择器 hook。
§9 create<T>()(...) 遗漏双重括号
// ❌ 缺少第一对括号(泛型实例化)
const useStore = create<BearState>(
persist((set) => ({ ... })) // TypeScript 报错
);
// ❌ 或不写类型
const useStore = create(
persist((set) => ({ ... })) // state 类型为 unknown
);
// ✅ 正确的双重括号写法
const useStore = create<BearState>()(
persist((set) => ({ ... }))
);🚨 陷阱:
create<T>()(...)是 TypeScript 的显式泛型实例化语法。第一层()实例化泛型,第二层(...)传入 creator 函数。不写双重括号会导致类型推断失败。
§10 v4 → v5 导入路径变化
// ❌ v4 默认导入(v5 中不存在)
import create from 'zustand';
// ✅ v5 命名导入
import { create } from 'zustand';
// ❌ v4 从主入口导入 shallow
import { shallow } from 'zustand';
// ✅ v5 从子路径导入
import { shallow } from 'zustand/shallow';
// ❌ v4 从主入口导入 persist/devtools
import { persist, devtools } from 'zustand';
// ✅ v5 从 middleware 路径导入
import { persist, devtools } from 'zustand/middleware';最佳实践清单
Store 设计
- 按功能域拆分多个独立 Store(每个 Store ≤ 10 个字段)
- Store 文件名以
use开头(useXxxStore) - TypeScript 类型使用
create<T>()(...)双重括号语法 - 提供一个
reset()action 用于测试和状态重置
选择器
- 选择器默认返回原始值(number、string、boolean)
- 多字段组合使用
useShallow包裹 - 不直接解构整个 Store:
const { a, b } = useStore() - 提取可复用的选择器为独立函数(原子选择器)
Action
- 依赖前值的更新使用
set((s) => ({ ... }))或get() - 异步操作正确处理 loading / error 状态
- 异步操作处理竞态条件(AbortController 或 requestId)
- 乐观更新必须有回滚逻辑
不可变更新
- 深层嵌套状态使用 Immer 中间件
- Map/Set 操作创建新实例(或使用 Immer)
-
set中不要直接修改旧 state 引用
中间件
- 开发环境启用
devtools中间件 - 需要持久化的用户偏好使用
persist中间件 -
persist注意异步 hydration 导致的 UI 闪烁 - Immer 放在中间件组合的最内层
性能
- 定期检查是否有组件订阅了整个 Store
- 使用 React DevTools Profiler 确认重渲染范围
测试
- 每个测试前用
setState(initialState, true)重置 Store - 测试异步 action 时 mock fetch
- 单元测试 Store 本身(不依赖 React 渲染)
生产检查清单
- 所有 Store 都有明确的 TypeScript 类型
- 选择器精确订阅最小状态片段
- 异步操作有完整的 loading/error 处理
- 异步操作有竞态保护
- persist 中间件的 hydration 状态已处理
-
reset()action 已实现(方便测试和登出场景) - 没有组件中使用
subscribe替代选择器 - devtools 在生产环境已禁用
- Store 已按功能域拆分,没有巨型 Store
- 确认运行的是 Zustand v5(或明确使用 v4)