用 Zustand 做 React 全局状态管理:告别 Context 重渲染,3 个实战模式与持久化

用 Zustand 做 React 全局状态管理:告别 Context 重渲染,3 个实战模式与持久化

组件层级一深,状态就开始满天飞。一个登录用户信息,从顶层 App 一路 props 传到七层之下的头像组件,中间六层压根用不上它,只是「过路」。你想到用 Context 收口,结果发现另一个坑:Context 里随便一个字段变了,所有订阅这个 Context 的组件------哪怕它只读了另一个不相干的字段------全部跟着重渲染。列表一长,输入框都开始卡。

Redux 能解决重渲染,但要写 action、reducer、dispatch、connect,一个简单的计数器都得铺三个文件。Zustand 是这两者之间的甜点:API 只有一个 create,没有 Provider 包裹,选择性订阅默认就带,重渲染问题天然解决。这篇讲清楚它到底怎么用、三个真实场景,以及一个新手必踩的选择器陷阱。

先看 Context 到底卡在哪

一个典型的全局 Context,存了用户信息和主题:

jsx 复制代码
const AppContext = createContext(null);

function AppProvider({ children }) {
  const [user, setUser] = useState({ name: "阿满", vip: false });
  const [theme, setTheme] = useState("light");
  // 每次 setTheme,value 都是新对象,所有 consumer 全部重渲染
  return (
    <AppContext.Provider value={{ user, setUser, theme, setTheme }}>
      {children}
    </AppContext.Provider>
  );
}

问题出在 value={``{...}}:每次 render 都生成一个新对象引用,React 判定 Context 变了,于是所有 useContext(AppContext) 的组件重渲染。切换主题时,连只读 user.name 的头像组件也白白重渲染一遍。想优化只能把 Context 拆成好几个,或者上 useMemo + 手动 memo 子组件,越写越绕。

Zustand 的基本用法:一个 store 搞定

安装后,一个 store 就是一个函数:

js 复制代码
// stores/useUserStore.js
import { create } from "zustand";

export const useUserStore = create((set) => ({
  user: { name: "阿满", vip: false },
  theme: "light",
  login: (name) => set({ user: { name, vip: true } }),
  toggleTheme: () =>
    // set 接收当前 state 返回要合并的部分,只改 theme
    set((state) => ({ theme: state.theme === "light" ? "dark" : "light" })),
}));

组件里用它,关键是传一个选择器,只取你要的字段:

jsx 复制代码
function Avatar() {
  // 只订阅 user.name,theme 变了这个组件纹丝不动
  const name = useUserStore((s) => s.user.name);
  return <span>{name}</span>;
}

function ThemeButton() {
  const theme = useUserStore((s) => s.theme);
  const toggleTheme = useUserStore((s) => s.toggleTheme);
  return <button onClick={toggleTheme}>当前:{theme}</button>;
}

没有 Provider,不用包裹根组件,AvatarThemeButton 直接 import 就能用。切换主题时,Avatar 的选择器 s.user.name 返回值没变,Zustand 用 Object.is 比较后判定无需更新,Avatar 不重渲染。这就是它比 Context 香的核心:订阅粒度到字段级

陷阱:选择器返回新对象,等于没优化

新手最容易踩的坑------一次性取多个字段时图省事返回一个对象:

jsx 复制代码
// ❌ 每次 render 选择器都返回新对象,Object.is 永远为 false,每次 state 变都重渲染
const { name, vip } = useUserStore((s) => ({ name: s.user.name, vip: s.user.vip }));

(s) => ({...}) 每次都 new 一个对象,默认的浅相等比较认为「变了」,选择性订阅直接失效。正确做法有两种。

其一,拆成多个选择器,各订阅各的(最简单,推荐):

jsx 复制代码
const name = useUserStore((s) => s.user.name);
const vip = useUserStore((s) => s.user.vip);

其二,如果确实要一次取多个,用 useShallow 做浅比较:

jsx 复制代码
import { useShallow } from "zustand/react/shallow";

// useShallow 逐字段浅比较,name 和 vip 都没变时不触发重渲染
const { name, vip } = useUserStore(
  useShallow((s) => ({ name: s.user.name, vip: s.user.vip }))
);

记住这条规则:选择器要么返回原始值,要么用 useShallow 包住返回的对象,否则订阅优化白做。

实战模式一:异步 action 直接写在 store 里

Zustand 的 action 就是普通函数,写异步逻辑天经地义,不用中间件:

js 复制代码
export const useCartStore = create((set, get) => ({
  items: [],
  loading: false,
  error: null,
  fetchCart: async (uid) => {
    set({ loading: true, error: null });
    try {
      const res = await fetch(`/api/cart/${uid}`);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      set({ items: await res.json(), loading: false });
    } catch (e) {
      set({ error: e.message, loading: false });
    }
  },
  // get() 读当前最新 state,适合基于旧值算新值的场景
  totalCount: () => get().items.reduce((sum, it) => sum + it.qty, 0),
}));

组件里调用 fetchCart 就行,loading / error 状态全局可见,任何组件都能读到同一份购物车,不用把请求逻辑重复散在各处。get() 让你在 action 内部拿到最新 state,做「基于当前值计算」很方便。

实战模式二:persist 中间件做本地持久化

主题、登录 token 这类希望刷新不丢的状态,套一个 persist 中间件即可,自动读写 localStorage:

js 复制代码
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";

export const useSettingStore = create(
  persist(
    (set) => ({
      theme: "light",
      lang: "zh",
      setTheme: (theme) => set({ theme }),
    }),
    {
      name: "app-setting", // localStorage 的 key
      storage: createJSONStorage(() => localStorage),
      // 只持久化 theme,lang 不落盘(比如每次跟随浏览器语言)
      partialize: (s) => ({ theme: s.theme }),
    }
  )
);

partialize 控制只把需要的字段写进存储,避免把一次性的 loading、临时数据也塞进 localStorage。

Next.js 里用 persist 要注意:store 首次渲染在服务端没有 localStorage,水合(hydration)后客户端才读到真实值,可能出现首屏闪一下默认值。解决办法是把依赖持久化状态的 UI 放到 useEffect 后再渲染,或用 store 的 onRehydrateStorage 标记水合完成,首屏先渲染占位,和 SSR 主题闪烁是同一类问题。

实战模式三:在组件外读写 store

Zustand 的 store 不只是 Hook,它本身挂着 getState / setState / subscribe,能在任何 React 之外的地方用------比如路由守卫、axios 拦截器、WebSocket 回调:

js 复制代码
// axios 拦截器里读 token,这里没有组件、用不了 Hook
import { useUserStore } from "@/stores/useUserStore";

axios.interceptors.request.use((config) => {
  const token = useUserStore.getState().token; // 直接拿最新值
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

// WebSocket 推送到达时,直接写 store,组件自动更新
socket.on("notice", (msg) => {
  useUserStore.setState((s) => ({ notices: [msg, ...s.notices] }));
});

这是 Context 做不到的:Context 的值只能在组件树内通过 Hook 拿。Zustand 把状态从组件树里解放出来,请求层、事件层都能直接操作同一份数据,少了一堆「把 setState 传下去」的胶水代码。

什么时候别用它

Zustand 适合中小型应用的全局状态,但不是万金油:

  • 纯服务端数据 (列表、详情这种从接口拉的)优先用 React Query / SWR,它们自带缓存、失效、重试、竞态处理,比手写 fetchXxx action 强得多。Zustand 存那些属于「客户端本地」的状态------UI 开关、选中项、购物车、登录态。
  • 单个组件内部状态 别往全局塞,useState 就够,全塞 store 反而难维护。
  • 需要严格的 action 审计、时间旅行调试、大团队强约定时,Redux Toolkit 的模板化反而是优点。

小结

  • Context 的痛点是任一字段变化触发所有 consumer 重渲染 ;Zustand 靠选择器 + Object.is 比较做到字段级订阅,天然规避。
  • 用法极简:create 定义 store,组件里 useStore((s) => s.某字段) 取值,无 Provider
  • 选择器返回对象必须配 useShallow,否则每次 new 对象让订阅优化失效------这是头号新手坑。
  • 三个实战招:异步 action 直接写在 store、persist 中间件持久化(注意 SSR 水合闪烁)、getState/setState 在组件外读写。
  • 记忆点:服务端数据交给 React Query,客户端状态交给 Zustand,组件私有状态留给 useState,各司其职才不乱。
相关推荐
IMPYLH1 小时前
HTML 的 <section> 元素
前端·html
X1A0RAN1 小时前
密匣 PsdKeep:一个属于你自己的 Chrome 账号记事本
前端·chrome
529宝宝起名网2 小时前
用 Python 开发历史名字查询与起名灵感工具:从古籍人物数据库到名字文化故事生成
开发语言·前端·python
linux_cfan2 小时前
videojs v10 源代码系列解读:10 · `DestroyMixin`:双重 rAF 延迟销毁
前端·javascript·音视频
计算机魔术师2 小时前
英伟达是人工智能领域的"中央银行"
前端
我的div丢了肿么办2 小时前
顶部区域固定,左侧区域滚动,右侧区域滚动,彼此独立
前端·css
雪芽蓝域zzs2 小时前
第四十六节:顶部【驾驶舱】独立大屏页面实现
前端·javascript·vue.js
IMPYLH2 小时前
HTML 的 <select> 元素
前端·html
爱丶不疚2 小时前
Electron:Module 与 Service 的职责边界与加载时序编排
前端·electron·nestjs