- Published on
别把服务端状态塞进客户端 store:前端状态分层的第一性原理
- Authors

- Name
- Jack Qin
前端状态管理的混乱,几乎总是从同一个动作开始:把一份从接口拿来的数据,"顺手"存进一个 Context 或 Redux store。这个动作看起来无害——数据已经在手里了,存下来下次直接用,省一次请求。但它埋下的是前端最经典的一类 bug:同一份数据现在有了两个副本,一个在服务端、一个在客户端 store,而它们的更新节奏不同步,迟早分叉。
要从根上避免这类 bug,得先回答一个更基本的问题:状态该用什么工具管,不取决于它"是什么数据",而取决于它的"真相源(source of truth)在哪"。 真相在服务端的数据(读数、报表、用户列表),它的权威副本永远在服务端,前端持有的只是一份缓存——缓存就该用缓存工具(TanStack Query)管,而不是当成自己的状态存进 store。真相在客户端的数据(当前选了哪个站点、侧边栏开没开),才是前端真正"拥有"的状态。
本文以某环境监测平台的前端为样本(React 19 + Vite + TanStack Query 单页应用),但重点是拆解这套结构背后的判断:状态为什么按"真相源"分三层、为什么用原生 fetch 而不是 axios、错误和类型为什么要做成契约式、以及一个时序系统里时区为什么必须收敛到唯一入口。
这个前端在解什么问题
它是一个数据密集的仪表盘 SPA:粉尘读数、水流量、储罐水位、热力图、周报,大量图表和地图。对应的后端是一套模块化 .NET API。约束有三:
- 后端用 HttpOnly Cookie 鉴权,前端不持有 token——这直接决定了 HTTP 客户端的选型;
- 所有数据存 UTC,展示用站点本地时区(典型 AWST,UTC+8 无夏令时)——时序数据的时区处理是头等大事;
- 类型安全要跨前后端:DTO 不能在前后端各写一份然后慢慢漂移。
整体是"特性化目录 + 分层状态 + 集中 API 客户端"。Provider 栈的嵌套顺序:
flowchart TD
PW[ProvidersWrapper]
PW --> QC[QueryClientProvider - TanStack Query]
QC --> Auth[AuthProvider - 鉴权上下文]
Auth --> GF[GlobalFiltersProvider - 站点 + 日期范围 + 时区]
GF --> Layout[LayoutProvider - 主题/布局]
Layout --> Toast[Toaster - toast 通知]
Toast --> Preline[Preline 自动初始化]
状态按"真相源"分三层
这是整个前端最重要的架构决策。三种状态用三种工具,分层依据正是上面那个问题——真相在哪:
| 关注点 | 工具 | 真相源 |
|---|---|---|
| 服务端状态 | TanStack React Query | 服务端(前端只是缓存) |
| 客户端状态 | React Context(鉴权、筛选、布局) | 客户端自己 |
| URL 状态 | localStorage 持久化 + 路由 | URL / 持久层 |
核心理念:不要把服务端状态复制进客户端 store。 服务端数据全部走 React Query,享受它的缓存、重取、失效机制;客户端状态(当前选了哪个站点、侧边栏开没开)才用 Context。
为什么这条边界一旦模糊就出 bug,值得讲透:React Query 的全部价值在于它知道自己持有的是缓存,所以它提供 staleTime、失效、后台重取这一整套"让缓存和真相源重新对齐"的机制。你一旦把服务端数据拷进 Context,就等于声明"这份数据归我了"——但你并没有那套对齐机制,于是服务端更新了、Context 里的拷贝不会动,两边静默分叉。问题不在于"存了一份拷贝",而在于拷进 Context 的同时丢掉了让拷贝失效的能力。 客户端状态不需要这套机制(因为它就是真相,没有要对齐的上游),所以用简单的 Context 足够,不需要 Redux/Zustand。
为什么不上 Redux/Zustand:因为客户端状态在这个应用里足够简单(站点选择、布局开关),Context 绰绰有余。引入重型 store 是在为不存在的复杂度付税。
原生 fetch 而不是 axios:选型由鉴权方式决定
| 决策 | 理由 |
|---|---|
原生 fetch + 轻量封装(credentials: 'include') | 零依赖;Cookie 自动带上 |
这个选型不是"喜欢原生",而是被鉴权方式决定的。axios 的核心卖点之一是拦截器——常用来在每个请求里手动塞 Authorization token。但这套系统的鉴权是 Cookie 驱动的,前端不持有 token,根本没有 token 要塞。只要每个请求带 credentials: "include",浏览器就自动把会话 Cookie 发出去。axios 最大的那个用途在这里直接消失了,剩下的横切逻辑用一个集中的轻封装就能覆盖,何必引一个依赖。
一个集中的 apiClient 封装了所有横切逻辑:
| 能力 | 细节 |
|---|---|
| 凭据 | 每个请求 credentials: "include" |
| CSRF | 从 XSRF-TOKEN Cookie 自动提取,变更请求时塞进 X-XSRF-TOKEN 头 |
| Content-Type | 普通对象自动 application/json,FormData/Blob 透传 |
| 参数 | query 参数自动过滤 null、字符串化 |
| 401 处理 | 全局 onUnauthorized 回调:清空 Query 缓存 + 跳登录 |
这是个很好的反例教学:技术选型常常不是比较两个库的功能列表,而是看你的约束让其中哪些功能变得不必要。 Cookie 鉴权让"自动塞 token"这个功能归零,axios 的性价比就随之坍塌。
契约式错误:前端不解析后端的自由文本
后端返回 ProblemDetails(RFC 9457),带 errorCode + traceId + fieldErrors。前端转成 typed ApiError,按 code 分级处理:
ApiError.code | 默认处理 |
|---|---|
validation + fieldErrors | 不弹 toast,表单在字段旁渲染错误 |
unauthorized | 清缓存 + 跳登录 |
forbidden | 跳无权限页或 toast |
not_found | 页内空状态 |
conflict | toast(表单可内联显示) |
server / network | toast |
| 静默重取失败 | 不弹 toast,仅 console.warn |
关键在于前端按结构化的 code 决策,而不是去解析后端的文本。为什么这条重要:如果前端靠 if (message.includes("not found")) 这种字符串匹配来分支,那后端文案一改、或者加个 i18n,前端逻辑就静默失效——前端和后端被一根**最脆弱的耦合(人类可读文本)**绑在了一起。改成按 code 分支,后端文案怎么变前端都不受影响。i18n 也是把后端的 errorCode(如 invalid_credentials、account_locked)映射到翻译 key,而不是直接显示后端字符串。
这是一个可迁移原则:两个系统之间的契约,要建立在稳定的结构化字段上,而不是建立在为人类显示而存在的、随时会变的文本上。 文本是给人看的,会随产品、语言、文案润色而变;结构化 code 是给机器看的,应当稳定。
OpenAPI 生成类型:从机制上防漂移
前后端类型安全靠 OpenAPI schema 生成:
{
"api:fetch-schema": "curl -o .../openapi-schema.json http://localhost:5000/openapi/v1.json",
"api:generate": "openapi-typescript .../openapi-schema.json -o src/lib/api/generated.ts",
}
生成的 generated.ts 只有类型、没有运行时代码,特性模块的 api/ 目录直接 import 这些 DTO 类型。CI 里后端把 schema 导出成 artifact,前端 CI 检查新鲜度——schema 一漂移就报警。
为什么这比"前后端各维护一份 interface"可靠得多:手写两份 interface,本质是让两个真相源描述同一个 DTO,它们靠人类纪律保持同步,而人类纪律会腐烂——后端加了个字段忘了通知前端,前端的 interface 就悄悄过时,直到运行时炸出来。OpenAPI 生成把后端 schema 设成唯一真相源,前端类型是它的派生物,漂移在 CI 阶段就变红。这和鉴权那篇里"权限只有一个权威来源"是同一条原则的不同应用:任何需要在两处保持一致的东西,正确的解法不是更努力地手动同步,而是让一处派生自另一处。
TanStack Query 的重试要看 retryable 标志
queries: {
staleTime: 2 * 60 * 1000, // 默认 2 分钟
retry: (failureCount, error) => {
if (isApiError(error) && !error.retryable) return false; // 不可重试的直接放弃
return failureCount < 2;
},
refetchOnWindowFocus: false,
},
mutations: { retry: false }, // mutation 永不自动重试
重试不是无脑 retry——4xx 这类"重试也没用"的错误标记为不可重试,直接放弃;只有可重试的(网络抖动、5xx)才重试最多 2 次。mutation 一律不自动重试,理由是副作用:一个失败的"创建"请求自动重试,可能真的创建了两条——retry: false 是在防"重复副作用",不是在省请求。这是一个常被忽略的不对称:读是幂等的,重试安全;写有副作用,重试危险。 全局把 query 和 mutation 的重试策略分开设,正是对这个不对称的回应。
实现要点
特性模块的标准范式
20+ 个特性模块都遵循同一个自包含结构,新人照着抄就行:
src/features/[feature-name]/
├── api/ # API fetch 函数 + Query keys
├── hooks/ # 包装 TanStack Query 的自定义 hook (useXQuery / useXMutation)
├── components/ # 特性专属组件
├── types/ # TypeScript 接口
├── services/ # 业务逻辑/转换(可选)
└── index.ts # 公开 barrel 导出(可选)
三段式范式——API 层(薄封装,只发请求):
export const siteComparisonApi = {
getComparison: (siteIds, from, to, options?) =>
apiClient.get<DustLevelComparisonDto[]>('/api/v1/dust-levels/comparison', {
params: { siteIds: siteIds.join(','), from, to },
signal: options?.signal,
}),
}
Query key(层级化、类型安全、稳定):
const siteComparisonKeys = {
all: ['dust-levels', 'comparison'] as const,
byRange: (siteIds, from, to) => [...siteComparisonKeys.all, ...siteIds, from, to] as const,
}
Query hook(组件唯一的数据入口):
export function useSiteComparisonQuery(siteIds, from, to) {
return useQuery({
queryKey: siteComparisonKeys.byRange(siteIds, from, to),
queryFn: ({ signal }) => siteComparisonApi.getComparison(siteIds, from, to, { signal }),
enabled: siteIds.length > 0,
staleTime: 5 * 60 * 1000,
})
}
铁律:组件永远不直接调 apiClient,只用 hook。 这条铁律的意义不是风格统一,而是封装失效逻辑的位置:query key、失效、enabled 条件全被关在 hook 里,组件只关心"数据、loading、error"三件事。一旦某个组件绕过 hook 直接调 apiClient,它就绕过了这套 key 和失效逻辑——它拿到的数据不进缓存、也不会被失效,缓存的一致性就有了一个洞。
为了让 query key 在筛选对象顺序变化时仍然稳定,有个 normalizeFilters 工具:丢掉 undefined/null、Date 转 ISO 字符串、数组排序。否则 {a,b} 和 {b,a} 会被当成两个不同的 key,缓存白白失效——query key 是缓存身份的指纹,指纹不稳定,缓存就形同虚设。
全局筛选上下文
站点选择、日期范围、时区集中在一个 GlobalFiltersContext:
interface GlobalFiltersState {
selectedSiteId: string | null
filteredSites: MineSite[] // 按特性开关过滤(如 dust_level_enabled)
siteTimezone: string // 来自 selectedSite.timeZone 或默认时区
dateRange: DateRange
dateRangePreset: DateRangePreset // today / last_7_days / custom ...
}
行为:从 API 加载用户有权限的站点,按特性开关过滤;持久化选中站点和日期范围到 localStorage;没选站点时自动选第一个;站点时区变化时重算日期范围(非自定义预设)。
时区:唯一入口,因为它是时序系统最脆的一环
时序数据系统里时区错一点点,整条曲线就偏了——而且它不报错,只是把数据画到错误的时间点上,极难发现。正因为它脆且静默,规则必须非常硬:
| 环节 | 规则 |
|---|---|
| 数据库存储 | 永远 UTC |
| API 响应 | UTC 时间戳(ISO 8601),绝不返回预格式化的本地字符串 |
| 展示 | 前端把 UTC 转站点时区 |
| 用户输入 | 站点时区转 UTC 再发 API |
| 图表轴 / 日期选择器 | 站点本地时间 |
所有时区逻辑集中在 lib/timezone.ts,DEFAULT_TIMEZONE = "Australia/Perth"。格式化一律用 date-fns-tz 的 formatInTimeZone(date, tz, format),绝不用 toLocaleString()——后者会跟着浏览器时区跑,在跨时区场景下静默出错。
核心理念:API 永远给 UTC,前端永远显式指定时区转换。 为什么要收敛到唯一入口:时区错误的危险不在于"难写对",而在于"散落"——只要有一处用了 toLocaleString() 或硬编码了时区字符串,整个系统的时间一致性就开了一道缝,而这道缝因为不报错,可能上线很久才被某个跨时区用户发现。把所有时区逻辑收进一个文件,等于把"可能出错的地方"从"散布全代码库"收缩成"一个可审计的点"。这和上面所有"唯一真相源"的论证是同一招:容易出错且后果严重的逻辑,要么消除,要么收敛到一个地方集中守住。
路由元数据驱动布局,导出全在客户端
路由表里每条路由声明元数据(requiredModule、showDatePicker、showSiteSelector 等),布局据此动态渲染头部控件,页面通过 HeaderActionsContext 往头部注入按钮——页面声明意图,布局据此渲染,二者解耦,页面不直接操作头部 DOM。
文档导出(DOCX/PDF/CSV/ZIP)完全在前端做——后端只通过 API 提供数据,前端渲染文档。图表抓取有三种策略:DOM 转 JPEG、Canvas 提取(ECharts 的 getDataURL())、SVG 序列化。这是个明确的取舍:客户端导出架构更简单(不需要服务端渲染基础设施),代价是受浏览器内存/性能限制。对这个项目的报表规模划算——注意这是一笔用"简单"换"规模上限"的交易,规模一旦超过浏览器内存就得重谈。
适用边界:这套架构的甜区与失效点
换来的东西很清楚:状态来源清晰(杜绝"数据存两份不同步")、20+ 特性模块同一范式(新人易上手)、类型和错误都是契约式的(防漂移)、时区有唯一入口(最易错处被收敛)。但每条收益都对应一个失效场景:
- 客户端状态变得很复杂(大量跨组件共享的派生状态、复杂撤销/重做):Context 会捉襟见肘,该上 Zustand/Jotai——"Context 足够"的前提是客户端状态简单;
- 需要 SSR/SEO:这是个纯 SPA(静态托管),要服务端渲染或 SEO 得换 Next.js 这类框架;
- 导出报表规模极大:纯客户端导出受浏览器内存限制,超大报表得把渲染挪回服务端——前面那笔"简单换规模"的交易到期了。
这套架构的甜区是:鉴权同根域 Cookie、数据密集但客户端状态不复杂、纯 SPA。 监测仪表盘正好落在这个甜区。
可迁移的那一层,其实贯穿全文一条线:前端架构里大部分"该用什么工具""该写在哪"的判断,最终都回到"这份东西的真相源在哪、谁是它的权威副本"。 服务端状态的真相在后端,所以用缓存工具而非 store;DTO 类型的真相在后端 schema,所以生成而非手写;时区转换的真相是 UTC + 站点时区,所以收敛到一个入口。想清楚真相源,分层和工具选择大多会自己浮现。