Published on

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

Authors
  • avatar
    Name
    Jack Qin
    Twitter

前端状态管理的混乱,几乎总是从同一个动作开始:把一份从接口拿来的数据,"顺手"存进一个 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"
CSRFXSRF-TOKEN Cookie 自动提取,变更请求时塞进 X-XSRF-TOKEN
Content-Type普通对象自动 application/jsonFormData/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页内空状态
conflicttoast(表单可内联显示)
server / networktoast
静默重取失败不弹 toast,仅 console.warn

关键在于前端按结构化的 code 决策,而不是去解析后端的文本。为什么这条重要:如果前端靠 if (message.includes("not found")) 这种字符串匹配来分支,那后端文案一改、或者加个 i18n,前端逻辑就静默失效——前端和后端被一根**最脆弱的耦合(人类可读文本)**绑在了一起。改成按 code 分支,后端文案怎么变前端都不受影响。i18n 也是把后端的 errorCode(如 invalid_credentialsaccount_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.tsDEFAULT_TIMEZONE = "Australia/Perth"。格式化一律用 date-fns-tzformatInTimeZone(date, tz, format),绝不用 toLocaleString()——后者会跟着浏览器时区跑,在跨时区场景下静默出错。

核心理念:API 永远给 UTC,前端永远显式指定时区转换。 为什么要收敛到唯一入口:时区错误的危险不在于"难写对",而在于"散落"——只要有一处用了 toLocaleString() 或硬编码了时区字符串,整个系统的时间一致性就开了一道缝,而这道缝因为不报错,可能上线很久才被某个跨时区用户发现。把所有时区逻辑收进一个文件,等于把"可能出错的地方"从"散布全代码库"收缩成"一个可审计的点"。这和上面所有"唯一真相源"的论证是同一招:容易出错且后果严重的逻辑,要么消除,要么收敛到一个地方集中守住。

路由元数据驱动布局,导出全在客户端

路由表里每条路由声明元数据(requiredModuleshowDatePickershowSiteSelector 等),布局据此动态渲染头部控件,页面通过 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 + 站点时区,所以收敛到一个入口。想清楚真相源,分层和工具选择大多会自己浮现。