前端状态管理选型:本地、全局与服务器状态的边界
本文目录
"这个状态应该放哪"是前端日常里最高频的架构决策,而多数团队的回答是"全部塞进 Redux/Pinia"或者"全部 useState 加 Context"。两种答案都把不同性质的状态压进了同一个容器,代价在应用长大后才显现:服务端数据被复制成全局 state 手动同步、UI 开关触发全页重渲染、刷新逻辑散落在二十个组件里。这篇文章先把状态分类讲清楚,再逐层讨论 props 钻井、Context 的真实成本、三个主流全局库的思路差异、为什么服务器状态应该交给专门的方案,最后给一张可执行的决策表。
第一步:给状态分类
选型失败的第一原因是没有分类。按"权威来源"和"变化频率"两个维度,前端状态可以划成四类:
| 类别 | 权威来源 | 示例 | 典型错误 |
|---|---|---|---|
| 组件本地状态 | 组件自身 | 输入框草稿、展开/收起 | 提升到全局"以防万一" |
| 全局客户端状态 | 当前会话的前端 | 已选筛选条件、主题、多步骤向导进度 | 塞进服务端数据流,或反向把服务端数据塞进来 |
| URL 状态 | 地址栏 | 页码、搜索词、弹层开关 | 复制链接丢状态、刷新回第一页 |
| 服务器状态 | 后端数据库 | 商品列表、订单、用户资料 | 复制到本地 store 后与服务端漂移 |
一个直观的占比示意:多数中后台页面里真正"全局客户端状态"的比例远比想象中小。
四类状态的处理原则:服务器状态交给专门方案(后文),URL 状态交给路由参数,组件本地状态留在组件里,只有真正的全局客户端状态才进入全局 store。判断"全局"的标准不是"多个组件用",而是"需要在没有父子关系的组件之间共享,且跨页面存活"。
props 钻井:什么时候真的是问题
数据从顶层组件逐层传递到深层叶子,中途每一层都要声明和转发一遍 props——这就是 props 钻井(prop drilling)。
function App() {
const [theme, setTheme] = useState<"dark" | "light">("dark");
return (
<Layout theme={theme}>
{/* Layout 不用 theme,只负责转发 */}
<Sidebar theme={theme}>
<UserMenu theme={theme} /> {/* 只有这里真正要用 */}
</Sidebar>
</Layout>
);
}
先明确:两层以内的传递是正常的数据流,不是问题。props 钻井成为真实问题的标志是:
- 中间组件的 props 里出现自己完全不消费、只为转发存在的字段;
- 新增字段要连续改 4 个以上文件;
- 重构时没人敢删中间层转发代码,因为不确定有没有隐性消费者。
解决顺序有讲究:先试"状态下沉"——把状态下移到使用它的最小公共父组件,多数"钻井"只是状态放得太高了;状态确实被多个分散子树用到,才考虑 Context 或全局 store。
Context 的真实成本
Context 是 React 内置的依赖注入机制,但它被大量误用为全局状态方案。成本结构必须讲清楚:
Context 的 value 变化时,所有消费这个 Context 的组件无条件重新渲染——无论它们用的是 value 的哪个部分,也无论子组件有没有 memo(Context 变化绕过 memo 的 props 比较)。
const ThemeContext = createContext<{ theme: Theme; setTheme: (t: Theme) => void }>(
null as never
);
function App() {
const [theme, setTheme] = useState<Theme>("dark");
// 反模式:value 内联对象,每次渲染都是新引用,所有消费者全部重渲染
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Page />
</ThemeContext.Provider>
);
}
两条基本规则:
- value 用 useMemo 固定引用,把真正变化的部分和从不变化的部分拆成两个 Context(比如 theme 一个 Context、setTheme 一个 Context——setter 引用从不变化,消费它的组件就不需要跟着 theme 更新)。
- Context 适合低频、全局的数据:主题、登录身份、语言、feature flags。高频数据(每帧变化的光标位置、输入框内容)放进 Context 等于让全页跟着抖。
拆分加固定引用后的标准形态:
const ThemeContext = createContext<Theme>("dark");
const ThemeUpdateContext = createContext<(t: Theme) => void>(() => {});
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("dark");
return (
// 稳定的 setter 单独一个 Context,theme 变化不影响它的消费者
<ThemeUpdateContext.Provider value={setTheme}>
<ThemeContext.Provider value={theme}>
{children}
</ThemeContext.Provider>
</ThemeUpdateContext.Provider>
);
}
| 场景 | Context 是否合适 |
|---|---|
| 主题、语言、当前用户身份 | 合适:低频、读多写少 |
| 跨层静态注入(配置、服务实例) | 合适:这是它的本意 |
| 高频更新的编辑器状态 | 不合适:用局部状态或专用 store |
| 替代 props 传递服务端数据 | 不合适:交给请求层缓存(后文) |
Pinia 里 Context 问题不存在对应物——订阅是组件级精确的,useStore() 返回的 getter 只有真正用到的部分变化才触发更新。这也是"React 需要 Context 规则清单、Vue 需要的比较少"的机制根源。
三个全局库的思路对比
Zustand、Pinia、Redux Toolkit 的 API 长得越来越像,但模型起点不同:
| 维度 | Zustand | Pinia | Redux Toolkit (RTK) |
|---|---|---|---|
| 心智模型 | 单一 hook 外的轻量 store | 按领域拆分的 store 模块 | 单一 store + 分片 reducers |
| 定位生态 | React 为主,框架无关内核 | Vue 官方推荐,DevTools 一等公民 | React 为主,生态最成熟 |
| 样板代码 | 最少 | 少 | 中等(createSlice 已大幅简化) |
| 中间件/devtools | 可选装配 | 内置 | 内置且规范严格 |
| 适用团队 | 小团队、快速迭代 | Vue 项目事实默认 | 大团队、强约束偏好、已有 Redux 存量 |
三种味道的最小实现对比:
// Zustand:一个 hook 搞定,set 合并语义
import { create } from "zustand";
const useCart = create<{
items: string[];
add: (id: string) => void;
}>((set) => ({
items: [],
add: (id) => set((s) => ({ items: [...s.items, id] })),
}));
function CartBadge() {
const items = useCart((s) => s.items); // 选择器订阅,精确重渲染
return <span>{items.length}</span>;
}
// Pinia:defineStore 按领域划分,Vue 响应式原生
import { defineStore } from "pinia";
export const useCartStore = defineStore("cart", {
state: () => ({ items: [] as string[] }),
actions: {
add(id: string) {
this.items.push(id);
},
},
getters: {
count: (state) => state.items.length,
},
});
// Redux Toolkit:createSlice 收敛 action/type 样板
import { createSlice, configureStore } from "@reduxjs/toolkit";
const cartSlice = createSlice({
name: "cart",
initialState: { items: [] as string[] },
reducers: {
itemAdded(state, action: PayloadAction<string>) {
state.items.push(action.payload); // Immer 提供可写语法
},
},
});
export const store = configureStore({ reducer: { cart: cartSlice.reducer } });
选型建议压缩成一句:Vue 项目默认 Pinia;React 项目按团队规模——小团队 Zustand 上手最快,需要强约束或已有存量选 RTK。三个库都支持组件级精确订阅,性能差异在中后台规模下不构成选型依据,真正拉开差距的是规范成本。
服务器状态:为什么应该交给专门的方案
服务器状态和客户端状态性质完全不同:它是远程数据的缓存,需要过期、重新验证、去重、重试,并且随时可能被别人改掉。把服务器数据复制进全局 store 手动维护,意味着自己重新实现这一整套:
| 你要自己实现的问题 | 专门方案已内置 |
|---|---|
| 两个组件同时挂载,同一接口请求两次 | 请求去重(共享同一 promise) |
| 数据何时过期、何时重取 | staleTime / cacheTime 策略 |
| 切回页面要不要刷新 | 窗口聚焦重验证(可配置) |
| 失败重试、加载/错误分支 | useQuery 返回 status/isError 全套 |
| 修改后如何让缓存失效 | 查询失效与乐观更新 API |
TanStack Query(React Query)与 SWR 是这一思路的代表。典型形态:
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
function ProductList({ categoryId }: { categoryId: string }) {
const { data, status, error } = useQuery({
queryKey: ["products", categoryId], // key 决定缓存粒度与共享
queryFn: () => fetchProducts(categoryId),
staleTime: 60_000, // 1 分钟内直接用缓存
});
if (status === "pending") return <Skeleton />;
if (status === "error") return <ErrorView message={error.message} />;
return <List items={data} />;
}
Vue 侧对应的是 TanStack Query Vue 适配或社区方案,Pinia 团队也明确建议:服务端数据缓存不是 store 的职责,Pinia 放客户端状态。改造的判断标准很直接:全局 store 里凡是"字段名和接口返回一一对应、需要在挂载时 dispatch 请求、靠刷新按钮重新拉取"的部分,都是应该迁给查询层的部分。迁出后 store 只剩真正的客户端状态,代码量通常还会下降。
决策表:这个状态放哪里
按顺序问下面的问题,命中即停:
| 顺序 | 问题 | 是 → 放哪 |
|---|---|---|
| 1 | 刷新后需要保留吗(页码、搜索词)? | URL 查询参数 / 路由 state |
| 2 | 数据的权威来源是后端吗? | 请求层缓存(TanStack Query / SWR),不进全局 store |
| 3 | 只有这一个组件树用,且随组件销毁? | useState/ref 本地状态 |
| 4 | 只有一个父组件和它的直接/浅层子组件用? | 提升到最近的公共父组件(props 下传) |
| 5 | 是低频全局数据(身份、主题、语言)? | Context(React)/ provide(Vue) |
| 6 | 跨页面、无关系组件共享的客户端状态? | Zustand / Pinia / RTK |
| 7 | 需要跨会话持久化? | 上述方案 + 持久化中间件,显式版本化 schema |
补充两条工程规则:每个全局字段都要能回答"谁写、谁读、何时失效",答不上来的字段放回本地;store 按领域拆分而不是一个大对象,重渲染范围和心智负担都受益。
注意事项与边界
- Zustand/Pinia 不用 Provider,模块级单例在测试里需要重置(Zustand 提供
setState重置模式,Pinia 用createTestingPinia),否则用例之间互相污染。 - Context 的 value 必须处理"未挂 Provider"的默认值,运行时
undefined崩溃是高频线上问题。 - URL 状态放进 store 再同步到地址栏是最坏的组合:两份真相必然漂移,以地址栏为唯一来源。
- 持久化(localStorage)不要存服务器数据的副本,登录 token 过期后旧数据会造成"看起来有权限其实没有"的假象。
- SSR 环境下模块级 store 会在请求间共享,必须用每请求新建实例的模式(Next.js/Nuxt 的标准做法),否则用户 A 的数据漏给用户 B。
- 选型迁移的成本与组件耦合度成正比:先把服务器状态迁到请求层(往往能删除大量代码)再考虑换 store,顺序反了会白做一遍。
总结
状态选型的主线是按权威来源分类,每类用各自的原生工具:服务器状态给请求缓存层(TanStack Query/SWR),URL 状态给路由,本地状态留在组件,Context 只放低频全局数据,真正的全局客户端状态才进 Zustand/Pinia/RTK。Context 的重渲染范围和 props 钻井的"下沉优先"是两个最常见的实践盲区。按决策表逐项归位之后,全局 store 会小到不需要争论选型。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。