首页  |  学习总览  |  ← 上一章:框架核心 阶段六 · 第 6 章,共 10 章
阶段六 · 预计 60 小时

状态管理与数据流

状态管理是前端架构里最容易失控的地方。本章教你一套方法论:先分类 → 再定位 → 后选型,并用 Zustand、Redux Toolkit、服务端状态库讲透「到底怎么落地」。学完本章,任何状态你都能说出它该放哪、为什么。

1状态管理方法论(先学思路再学工具)

90% 的状态管理混乱,都是因为「没有分类直接开写」。

1.1 状态的五种类型

类型例子归宿
服务端状态列表数据、详情、用户信息请求缓存库(数据请求库)
客户端全局状态主题、语言、登录态、权限轻量全局库或 Context
路由状态URL 参数、查询条件、tab路由库(尽量放 URL)
表单状态输入值、校验、提交状态表单库或组件内部
局部 UI 状态弹窗开关、折叠、hover组件内部 state
💡 最常犯的错 把服务端数据塞进全局 store(Redux/Zustand)是教科书级反模式。服务端数据有自己的生命周期(加载、缓存、失效、重试、并发去重),这些能力请求缓存库内置,手写既重复又易错。

1.2 状态定位决策树(背下来)

状态属于谁?
→
只有一个组件用?
→ 组件内部 state
→
兄弟组件共享?
→ 提升到共同父级
→
深层跨级?
→ Context
→
无关模块共享?
→ 全局状态库

补充判断:刷新要保留 → URL 或持久化;数据来自服务端 → 请求缓存库,不进全局 store;只有组件自己用 → 永远别全局化。

2Zustand:轻量全局状态库

当前中小项目的主流选择,样板代码最少。

2.1 基本用法

// 创建 store(一个函数返回状态与动作)
import { create } from "zustand";

const useCartStore = create((set) => ({
  items: [],                                  // 状态
  addItem: (item) =>                          // 动作:必须用 set 更新
    set((state) => ({ items: [...state.items, item] })),
  clear: () => set({ items: [] }),
}));

// 组件里使用:选择器订阅,只取自己需要的
function CartCount() {
  const count = useCartStore((s) => s.items.length);  // 只有 count 变了才重渲染
  return <span>共 {count} 件</span>;
}
💡 Zustand 为什么性能好 核心是「选择器 + 浅比较」:组件订阅的是 s => s.items.length 这个计算结果,store 变化时用浅比较判断——没变就不通知。比 Context 的「一改全通知」精准得多。

2.2 选择器陷阱与持久化

// ⚠️ 陷阱:选择器返回新对象会无限重渲染!
// ❌ 每次调用都返回新对象,浅比较永远不相等 → 死循环
const data = useCartStore((s) => ({ total: s.items.length, list: s.items }));

// ✅ 用 useShallow 做浅比较
import { useShallow } from "zustand/react/shallow";
const data = useCartStore(useShallow((s) => ({ total: s.items.length, list: s.items })));

// 持久化:一行中间件搞定
import { persist } from "zustand/middleware";
const useStore = create(persist(
  (set) => ({ theme: "light", toggle: () => set(s => ({ theme: s.theme === "light" ? "dark" : "light" })) }),
  { name: "theme-storage" }   // localStorage 的 key
));
3Redux Toolkit:大团队的严谨之选

Redux 的定位是「严格约束 + 可预测」,适合复杂全局状态与大团队。

3.1 三大原则与核心概念

  • 单一数据源:整个应用只有一棵状态树,存在一个 store 里。
  • 状态只读:不能直接改状态,只能派发 action(描述「发生了什么」)。
  • 纯函数修改:reducer 接收旧状态与 action,返回新状态(必须是纯函数)。
组件
dispatch(action)
→
Reducer
纯函数计算新状态
→
Store
保存新状态
→
组件
订阅并更新

单向数据流:action → reducer → store → UI。数据流向清晰,配合 DevTools 可以时间旅行调试。

3.2 createSlice:现代写法(不用手写 action 类型)

// slice = 一组状态 + 动作 + reducer 的合集
import { createSlice, configureStore } from "@reduxjs/toolkit";

const cartSlice = createSlice({
  name: "cart",
  initialState: { items: [] },
  reducers: {
    addItem(state, action) {           // 内部用 immer,可以「看似直接改」
      state.items.push(action.payload);
    },
    removeItem(state, action) {
      state.items = state.items.filter(i => i.id !== action.payload);
    },
  },
});

// 自动生成 actions:cartSlice.actions.addItem
// 自动生成 reducer:cartSlice.reducer
const store = configureStore({ reducer: { cart: cartSlice.reducer } });

// 组件里使用
const items = useSelector(s => s.cart.items);   // 订阅
const dispatch = useDispatch();
dispatch(addItem({ id: 1, name: "苹果" }));      // 派发
💡 RTK Query:官方服务端状态方案 Redux Toolkit 内置的 RTK Query 提供了缓存、失效、轮询、乐观更新等完整能力——如果你团队已用 Redux,服务端状态用它而不要手写 thunk。
4服务端状态:请求缓存库

现代前端最推荐的「数据层」方案,把请求当状态管理。

4.1 核心心智:缓存优先

请求缓存库(数据请求库 / 请求缓存库,如数据请求库 Query、SWR)的核心思想:把「获取数据」当成「读缓存」——数据来了进缓存,多个组件共享同一份缓存,不会重复请求;数据失效了自动重新请求。

// 数据请求库 Query 基本用法
import { useQuery } from "@tanstack/react-query";

function UserList() {
  const { data, isLoading, error, refetch } = useQuery({
    queryKey: ["users"],          // 缓存键:同键共享
    queryFn: fetchUsers,          // 请求函数
    staleTime: 60_000,            // 60 秒内数据「新鲜」,不重新请求
  });
  if (isLoading) return <Loading />;
  if (error) return <ErrorView />;
  return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

// 修改后使缓存失效,自动重新拉取(关键能力)
const queryClient = useQueryClient();
await saveUser(user);
queryClient.invalidateQueries({ queryKey: ["users"] });

4.2 进阶能力(面试加分)

  • 乐观更新:先更新 UI,失败再回滚——体验最好。
  • 请求取消:组件卸载或重复请求时取消旧请求,避免竞态。
  • 重试与退避:网络错误自动重试,指数退避。
  • 窗口聚焦重新请求:回到页面自动刷新数据。
  • 无限滚动:加载更多自动拼接。
⚠️ 使用要点 ① queryKey 是缓存的核心,设计好键的层级(["users", userId]);② 不要把「需要持久化到 URL 的筛选条件」塞进 queryKey(放 URL,路由状态);③ 关注 staleTime 与 gcTime 的区别——前者是「多久算旧」,后者是「缓存多久后被回收」。
5表单状态与路由状态

两类容易被忽视的状态,管好它们体验立涨。

5.1 表单状态:受控表单库

// React Hook Form:性能好(非受控 + ref)、样板少
import { useForm } from "react-hook-form";

function RegisterForm() {
  const { register, handleSubmit, formState: { errors } } = useForm();
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("username", { required: "必填", minLength: { value: 3, message: "至少 3 个字符" } })} />
      {errors.username && <p>{errors.username.message}</p>}
      <button type="submit">注册</button>
    </form>
  );
}

5.2 路由状态:能放 URL 就别放内存

  • 筛选条件、分页、tab、搜索词 → 放 URL 查询参数(可分享、可刷新、可回退)。
  • React Router 的 useSearchParams 读写 URL 参数。
  • 好处:刷新不丢、链接可分享、浏览器前进后退可用。
// 用 URL 管理筛选状态
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get("page") || "1");
const keyword = searchParams.get("q") || "";
setSearchParams({ page: "2", q: "react" });   // 更新 URL,组件自动重渲染
6实战项目(通关必做)

本章项目验证「方法论」是否真的用起来了。

项目一:购物车应用(状态分层实战)

要求:① 商品列表来自服务端状态(数据请求库,含加载/错误/重试);② 购物车是全局状态(Zustand);③ 结算表单用表单库;④ 商品分类筛选放 URL;⑤ 登录态放全局状态 + 持久化。做完后,画一张「每个状态放哪、为什么」的表——这才是本章真正的交付物。

项目二:给现有项目做状态重构

找出自己项目里「乱放」的状态(比如组件内管服务端数据、useState 传了十几层),按方法论重构。记录重构前后的对比:代码量、重渲染次数、出 bug 的概率。

7通关自检题

状态管理考的是「判断力」,每题都说出你的判断依据。

1. 状态的五种类型是什么?各自归宿在哪?

答案:

  1. 服务端状态(列表数据、详情、用户信息)→ 请求缓存库(数据请求库 / SWR)
  2. 客户端全局状态(主题、语言、登录态、权限)→ 轻量全局库(Zustand)或 Context
  3. 路由状态(URL 参数、查询条件、当前 tab)→ 路由库,尽量放 URL
  4. 表单状态(输入值、校验、提交状态)→ 表单库(React Hook Form)或组件内部
  5. 局部 UI 状态(弹窗开关、折叠、hover)→ 组件内部 state(useState)

2. 为什么服务端数据不该放进全局 store?

答案:服务端数据有自己完整的生命周期(加载、缓存、失效、重试、并发去重、请求取消),这些能力请求缓存库内置提供。塞进全局 store 意味着你要手写所有上述逻辑——既重复造轮子又容易出错。全局 store 的正确定位是管「客户端的、自己产生的」状态(主题、登录态等),不是服务端的「远程数据的本地镜像」。

💡 深度分析 判断标准很简单:刷新页面后这个数据还有效吗? 服务端数据会被全局刷新、被多人修改;客户端自己产生的状态才是「唯一真相」。请求缓存库把「异步数据」当作「有版本的缓存」管理,store 把「客户端状态」当作「源头」管理,二者职责边界清晰。

3. 状态定位决策树的完整流程?

答案:按以下顺序逐层判断:

  1. 状态属于谁?只有一个组件用 → 组件内部 state(useState)
  2. 兄弟组件间共享 → 状态提升到共同父级(prop drilling 在 3 层以内可接受)
  3. 深层跨级传递 → Context(跨 3 层以上且不需要频繁更新的场景)
  4. 无关模块间共享 → 全局状态库(Zustand / Redux)
💡 补充判断口诀 ① 刷新要保留 → URL 或 持久化(persist 中间件);② 数据来自服务端 → 请求缓存库,不进全局 store;③ 只有组件自己用 → 永远别全局化。决策树的精髓是「优先最小作用域」,能放组件内就别提升,能提升就别上全局。

4. Zustand 的选择器为什么能提升性能?返回新对象的陷阱是什么?

答案:选择器通过「精准订阅 + 浅比较」提升性能:组件只订阅自己需要的那个值(如 state => state.items.length),store 更新时对选择器返回值做浅比较——没变就不触发重渲染。

陷阱:选择器如果每次都返回 新引用(如 { total, list }),浅比较永远不相等 → 导致组件无限重渲染(死循环)。

💡 解决方案 返回多个字段时,使用 useShallow 做浅比较替代默认的 === 比较。useShallow((s) => ({ total: s.items.length, list: s.items })) 会逐字段比较,只有字段值变化才触发重渲染。

5. Redux 的三大原则?单向数据流的五个环节?

答案:

三大原则:

  1. 单一数据源:整个应用一棵状态树,存在唯一 store。
  2. 状态只读:不能直接修改状态,只能 dispatch action 来描述「发生了什么」。
  3. 纯函数修改:reducer 是纯函数,接收 (state, action) 返回新状态,不修改原状态。

单向数据流环节:

UI 触发
dispatch(action)
→
Middleware
可选:日志/异步
→
Reducer
纯函数算新状态
→
Store
保存新状态
→
UI 订阅更新
useSelector
💡 为什么单向数据流重要 数据流向只有「UI → action → reducer → store → UI」这一条路径,任何状态变更都可追溯。配合 Redux DevTools,你可以「时间旅行」——回放到任意历史 action 的时刻,方便调试。

6. Redux Toolkit 的 createSlice 解决了旧版 Redux 的什么痛点?

答案:解决了旧版 Redux 的四大痛点:

  1. 大量样板代码(boilerplate):旧版要手写 action type 常量、action creator、reducer switch-case。createSlice 根据 reducers 对象自动生成 action type 和 action creator,代码量减半。
  2. 不可变更新痛苦:旧版要写嵌套的展开运算符 {...state, nested: {...state.deep}}。内部集成 Immimer,允许「看似直接修改」的写法,RTK 自动转为不可变更新。
  3. store 配置繁琐:旧版要手动组合 reducer、applyMiddleware。configureStore 自动配置 DevTools、常用 middleware。
  4. TypeScript 类型推导弱:RTK 用 TS 重写,action payload 类型自动推导,无需手动写 enum/guard。
💡 一句话总结 RTK 让 Redux 从「配置地狱 + 样板工厂」变成「声明式 + 开箱即用」。如果团队已用 Redux,必须升级到 RTK;如果新项目,先评估是否真的需要 Redux 的严格约束。

7. 请求缓存库的 queryKey 是干什么的?staleTime 和 gcTime 的区别?

答案:

queryKey 的作用:作为缓存的唯一标识。相同 queryKey 的请求共享缓存(组件复用同一数据、避免重复请求);不同 queryKey 视为不同缓存(如 ["users", 1] 和 ["users", 2] 是两条记录)。同时用于 invalidateQueries 精准失效缓存。

staleTime vs gcTime:

配置含义默认值
staleTime数据「新鲜」的时间窗口。窗口内再次挂载不重新请求,超过后才后台重新拉取0(每次 mount 都重新请求)
gcTime缓存在内存中保留的时间。超过且无订阅者时,缓存被垃圾回收5 分钟
💡 记忆技巧 staleTime 控制「多久重新获取」,gcTime 控制「多久彻底删除」。典型配置:高频数据(如用户列表)staleTime 设 60s,低频数据(如设置项)设 5 分钟。gcTime 应该 >= staleTime,给「重新请求」留窗口。

8. 乐观更新是什么?什么时候该用它?

答案:乐观更新(Optimistic Update)是一种交互策略:在请求发出去之前,先把 UI 更新为「假设成功」的形态;请求成功则保持结果,失败则回滚到更新前的状态并提示用户。

适用场景:高延迟操作(网络慢)、操作成功率高的场景(点赞、标记已读、加入购物车)。用户感知「零延迟」,体验远超等接口返回才更新。

⚠️ 什么时候不该用乐观更新 操作失败率高(如支付提交)、状态一致性要求严格(如库存扣减)、回滚成本高的场景——这类场景适合悲观更新(等响应成功才更新 UI)。
💡 数据请求库 Query 的实现方式 使用 onMutate 中取消当前 queries、写入乐观数据;onError 中用 context 回滚;onSettled 中 invalidateQueries 重新拉取确保最终一致。

9. 为什么筛选条件要放 URL 而不是内存?

答案:三个核心价值:

  1. 可分享:带筛选参数的 URL 复制给同事,打开后看到完全一致的视图(包括当前筛选、分页)。
  2. 刷新不丢:放内存的状态刷新页面归零,URL 参数天然持久化。
  3. 浏览器导航集成:前进/后退按钮自然切换筛选状态,用户认知负担为零。
💡 什么不该放 URL 临时 UI 状态(弹窗开关、hover)放组件内部即可;敏感数据(token)不能放 URL(会出现在 history 和服务器日志中);超大 JSON 数据放 sessionStorage 或 store。URL 参数宜简短、宜人类可读。

10. 给一个场景:购物车(跨页面)+ 商品列表(服务端)+ 结算表单,三个状态分别怎么管?

答案:

状态类型归宿理由
购物车(跨页面)客户端全局状态Zustand + persist 持久化跨页面共享、客户端自产自销、刷新要保留
商品列表服务端状态请求缓存库(useQuery)来自服务端、需要缓存/失效/重试/并发去重
结算表单表单状态React Hook Form(组件内)只当前表单用、提交后即销毁、需要校验和受控管理
💡 综合场景分析思路 每回答一个状态的归宿,都要说出三要素:① 谁消费(一个组件 / 兄弟组件 / 跨页面);② 数据从哪来(服务端 / 客户端自产自销);③ 生命周期(提交即走 / 刷新保留 / 跨页共享)。这就是「方法论」在实战中的输出格式。
✅ 全部通关? 状态架构已心中有数。下一阶段《性能优化实战》,把前面学的渲染、网络、工程化知识全部转化为「可量化的性能提升」。