13 分钟
全栈开发

状态管理实战:从 useState 到 Zustand

理解 props drilling 痛点,用 Zustand 建全局 store 与 selector 订阅

  • 理解 props drilling 是什么、为什么讨厌
  • 知道 Context 的局限
  • 会用 Zustand 建全局 store(进度/主题/登录态)
  • 理解 selector 订阅为什么能避免无关重渲染
💡高中搭桥

可以把全局 store 想象成一个大家都能读写的共享字典,再配一个「通知员」:字典里某一格变了,只通知订阅了那一格的组件。这和 Python 里一个全局状态对象 + 一堆监听回调是一回事,谁关心哪块数据,谁就登记一下。

props drilling 的痛

React 默认数据靠 props 一层一层往下传。如果一个深层孙子组件要用登录态,就得从顶层组件一路把 login 状态透传三四层——中间那些组件根本用不到,却要帮忙转发。这就叫 props drilling(属性逐层钻取)。

示例
// 痛点示意:login 状态要穿过根本不需要它的 B、C 组件
<App login={user}>        // 顶层有登录态
  <Nav login={user}>      // 只是帮忙转发
    <Avatar login={user}> // 真正用到的是它
  </Nav>
</App>

Context 的局限与 Zustand 的出现

Context 能解决 drilling,但它有个毛病:一旦 Context 值变了,所有用了它的组件都会重渲染,哪怕你只需要其中一个字段。Zustand 是一个极简的全局状态库,核心思路是:建一个 store,组件用 selector 只订阅自己需要的那一小片数据,那片没变就不重渲染。

JavaScript
// Zustand 写法(概念示意,真实项目 import { create } from "zustand")
import { create } from "zustand";

const useStore = create((set) => ({
  progress: 0,
  theme: "light",
  setProgress: (n) => set({ progress: n }),
  toggleTheme: () => set((s) => ({ theme: s.theme === "light" ? "dark" : "light" })),
}));

// 组件里:只订阅 progress,theme 变了不会让它重渲染
function ProgressBar() {
  const progress = useStore((s) => s.progress);
  return <div>{progress}%</div>;
}

这里最关键的细节是 selector 返回的那一小片值:Zustand 默认用 Object.is 比较「这次 selector 的结果」和「上次 selector 的结果」,只有这一小片真的变了才触发重渲染。如果你图省事写 useStore((s) => s)——把整个 state 都订阅回来,那无论哪个字段变,这个组件都会重渲染,selector 就白写了。记住一个原则:selector 返回的越精确,多余渲染越少。

什么不该放进全局 store

全局状态不是垃圾桶。一旦什么都往里塞,store 会变成一个谁都能改、改了到处影响的黑盒,调试时根本说不清是谁改的、界面为什么变。判断标准很简单:这个状态是不是「多个互不嵌套的组件、跨好几层都要读写」。登录态、主题色、全局学习进度符合;而一个弹窗的开关、一个输入框的内容、鼠标 hover 在哪个卡片上,只在局部组件间流转,放全局就是过度设计。

⚠️全局状态滥用反模式

常见翻车:把表单字段全塞进全局 store,结果改一个字整个页面所有订阅组件都重渲染;或者把服务端缓存(接口返回的列表)也塞进全局 store,结果和 react-query 这类缓存两套真相打架。经验法则:能用 useState 解决的局部状态就别上全局;服务端数据交给 react-query/swr,全局 store 只管「真正的前端共享状态」。

Context / Redux / Zustand 怎么选

三者都是解决共享状态,差别在复杂度和精确订阅能力。Context 适合「低频变化」的值(主题、登录用户),因为它一变所有消费者都重渲染,不适合高频更新。Redux 功能最全、生态成熟、有 DevTools 时间旅行,但样板代码多、概念重,小项目用着累。Zustand 极简、API 就一个 create、内置 selector 精确订阅,是现在中小型 React 项目的甜点选择。

💡选型一句话

低频全局值(主题/用户)用 Context 就够;高频、跨层、需要精细控制重渲染的用 Zustand;大型团队、需要严格单向数据流和完整工具链的才上 Redux。本项目选了 Zustand,正是看中它「够用且不啰嗦」。

Zustand 里发请求也很自然:store 的 action 里直接 await 接口,拿到数据后 set 进 state 就行,不需要 Redux 那种 action/reducer/thunk 的层层包装。想让状态刷新页面后还在,加一个持久化中间件把指定字段写进 localStorage 即可。这些是进阶用法,现在知道 store 既能存值、也能存改值的方法、还能承载异步流程就够了。

动手:用 Python 模拟 store + 订阅者

下面模拟「store + selector + 只在订阅片变化时通知」。注意:改 progress 时只有进度条收到通知,顶栏不动;改 theme 时才轮到顶栏。

示例代码(可运行)
🐍本项目就用 Zustand

这个真实项目(朋友好学)的全局进度、主题、登录态就是用 Zustand 管的。s8-m4-l3 会带你逐行解析项目里的 store 写法;s13-m2-l3 再讲 Zustand 和 Redux、Context 到底怎么选型。现在先记住:共享、跨层、频繁用的状态才进全局 store。

选择题

什么时候才该用全局状态库(如 Zustand)?

填空题填写空白处的代码
Zustand 中用 订阅 store 里你需要的那一小片数据,避免无关重渲染。

资深工程师加餐

底层原理 · 大厂视角 · 工程经验,点卡片展开

穿透是反复查「根本不存在」的数据,用布隆过滤器或缓存空值挡;击穿是某个热点 key 过期瞬间大量请求压向数据库,用互斥重建或逻辑过期;雪崩是大批 key 同时过期,给 TTL 加随机抖动。缓存与库的一致性常用「先更新数据库、再删除缓存」,并靠重试/订阅补偿。

挑战任务

实现一个最简订阅 store

简单+50 XP

全局 store 的核心是「set 时通知所有订阅者」。实现 make_store(),返回 state、subscribe(fn)、set_n(v) 三个部分:set_n 改值后依次调用所有登记的 fn。

实现一个最简订阅 store
1 个测试用例

课后作业

给状态分类:进全局还是留局部

中等+20 XP

全局 store 只放跨多层共享的状态,别什么都塞。实现 is_global(state):state 在 GLOBAL 清单(auth_user/theme/global_progress)里返回 True,在 LOCAL 清单(modal_open/input_draft/hover_card)里返回 False。逐个打印状态与判定。

给状态分类:进全局还是留局部
1 个测试用例