Skip to content

第 12 章:React 前端的数据架构

前端同时消费应用配置、核心快照、系统状态和实时流。项目没有选择一个万能状态库,而是按更新频率分配给预加载缓存、SWR、Context、模块级 store 和组件状态。本章从 bootstrap 到页面刷新解释这套组合。

一、React mount 之前先解决语言和主题

main.tsx 不立即 createRoot(),而是先:

  1. preloadAppData() 请求 Verge 配置;
  2. 从缓存、配置或浏览器语言解析初始语言;
  3. 初始化 i18next;
  4. 根据 theme_mode 和系统偏好计算 light/dark;
  5. 同时预热首页卡片数据;
  6. 再 mount React。

这样可以减少两类闪烁:

  • 页面先用默认语言渲染,随后整页文字跳变;
  • 先显示浅色,再读取配置切到深色。

若预加载失败,bootstrap 会初始化 fallback language,并从 window 注入或系统偏好计算主题,应用仍能渲染。

二、Provider 只放真正的应用级状态

根组件组合:

text
ThemeModeProvider
LoadingCacheProvider
UpdateStateProvider
BaseErrorBoundary
SWRConfig
WindowProvider
AppDataProvider
RouterProvider

前三个是轻量客户端状态;WindowProvider 处理窗口装饰;AppDataProvider 聚合低频核心快照。高频流量和连接没有放在根 Context,避免每条消息让整棵组件树重新计算。

三、Query 层以 SWR 为执行器,再加显式 cache mirror

services/query-client.ts 提供类似 query API:

  • useQuery
  • getCacheData / setCacheData
  • revalidateQuery/Queries
  • fetchCacheData
  • removeCacheData

底层使用 SWR,但另外维护 Map<string, unknown> mirror。原因是 WebSocket handler 和非 React 代码需要同步读取当前值,才能安全执行函数式 updater:

ts
setCacheData(key, current => append(current, incoming))

只调用异步 mutate 而拿不到当前 mirror,多个快速 append 更容易基于同一个旧值计算。

四、Query key 是跨模块契约

例如 RunState 统一使用 runStateQueryKey。AppDataProvider 和 useSystemState 若各自用不同 key,会出现两份缓存:事件只更新一份,另一份仍旧。

同理:

  • ['getVergeConfig']
  • ['getProfiles']
  • ['getProxyView']
  • ['getClashConfig']

既是 fetch identity,也是 event invalidation 的目标。字符串散落仍有维护风险,但集中 Hook 和 constants 已减少分裂。

五、AppDataProvider 聚合什么

它读取:

  • Verge config 和 Runtime config;
  • ClashInfo 与 Mihomo BaseConfig;
  • ProxyView;
  • Rules / RuleProviders;
  • System proxy;
  • RunState;
  • App uptime。

随后拆成多个窄 Context:Proxies、Rules、ClashConfig、System、Uptime、CoreDataStatus、Refreshers。

为什么不只提供一个巨型 AppDataContext?窄 Context 让只关心 uptime 的组件不因 rules 改变而重渲染;每个 value 都通过 useMemo 保持引用稳定。

六、同一个值可能有多层 fallback

首页展示 mixed port 时按运行可信度解析:

text
Mihomo live BaseConfig
  ↓ 不可用
Runtime config
  ↓ 不可用
Verge selected
  ↓ 不可用
Application Merge Config

这与后端 effective/desired 思路一致:界面优先展示核心实际值,但核心未启动时仍能展示用户期望值。

七、事件不是直接 setState,而是写缓存或失效 query

useLayoutEvents 的策略分两类:

7.1 payload 已是完整快照

verge://run-state-changed 直接 setCacheDataAsync(runStateQueryKey, payload),无需再 invoke。

7.2 event 只是“某类数据变了”

refresh-clash-config 触发多个 key revalidate:ProxyView、Version、BaseConfig、Runtime、Rules、Providers。

事件和缓存的职责因此清晰:event 不成为第二套状态存储,只负责更新权威 query cache。

八、为什么仍保留少量轮询

AppDataProvider 对 ProxyView 每 3 秒 refetch,对 uptime 每 3 秒读取。事件能在主动切节点/配置变化时立即刷新,但核心/provider 也可能自行更新,未必总有 Tauri event。低频轮询作为收敛兜底。

refetchIntervalInBackground=false 防止窗口隐藏时继续做无意义 UI 请求。

九、稳定 refresh 函数

SWR 返回的 refetch 引用可能随 render 变化。若把它直接放进订阅 effect 依赖,会重复退订/订阅。

useStableFn

  1. ref 始终指向最新函数;
  2. 返回一次创建的 callback;
  3. effect 可以稳定订阅;
  4. 调用时仍使用最新实现。

这是一种常见 useEvent 替代模式。

十、写操作后的三种缓存策略

10.1 强一致重读

patchVerge() command 成功后 refetch(),适合副作用多、后端可能规范化值的设置。

10.2 乐观局部合并

patchProfiles() 若后端返回 valid,直接把 patch 合入 getProfiles 缓存;非 busy 异常 outcome 则重读。减少简单设置保存后的视觉延迟。

10.3 先乐观,后台持久化

导航拖拽先 mutateVerge(prev => ({...prev, menu_order}), false),再调用 patchVerge。拖拽立即响应,失败可由后续事件/重读收敛。

选择哪种策略取决于后端是否会改变返回形态和操作失败概率,不能所有 mutation 都统一乐观。

十一、用锁避免 UI 重复提交

useClash 使用 useLockFn 包装 patch,防止用户快速点击导致同一个 hook 并发发出配置变更。后端仍有 transaction/Busy 保护,前端锁的目标是减少无意义请求和抖动,不是安全边界。

前后端双层保护各有作用:

  • 前端改善体验;
  • 后端保证无论调用者是谁都一致。

十二、路由与导航共享元数据

navItems 同时定义 path、label、icon、Component。_routers.tsx 从它生成子路由,Layout 从它生成菜单。

这保证添加页面时路由与菜单不易遗漏,但也要注意:导航排序使用 path 作为稳定 id,修改 path 会影响用户保存的 menu_order,需要兼容或清理旧值。

十三、错误边界分层

  • BaseErrorBoundary 防止整个应用白屏;
  • Layout 内 Outlet 外还有页面级 boundary;
  • traffic 等高频组件有专门 error boundary;
  • window 全局监听 error / unhandledrejection 记录诊断。

错误边界不会捕获 event handler 和 async callback 的所有错误,因此 subscription handler 内仍需显式 try/catch。

十四、前端状态分配原则

状态类型放置位置
启动前必须知道preload module cache
可请求、可失效的服务器快照SWR/query cache
跨树、低频应用状态Context
高频共享流模块级 external store / shared subscription
单组件交互local state/ref
CPU 密集时间序列Web Worker

这比“统一使用某个状态库”更贴合数据本身。

本章源码索引

  • src/main.tsx:bootstrap 与 Provider 组合
  • src/services/preload.ts:语言、主题和 Verge 预加载
  • src/services/query-client.ts:SWR adapter 与同步 cache mirror
  • src/providers/app-data-provider.tsx:核心快照聚合与窄 Context
  • src/pages/_layout/hooks/use-layout-events.ts:event → cache/revalidate
  • src/hooks/use-clash.tsuse-verge.tsuse-profiles.ts:mutation 策略
  • src/pages/_navigation.tsx_routers.tsx:菜单/路由单一元数据
  • src/services/states.ts:轻量 Context state

原创文档 CC BY-SA 4.0 · 站点代码 MIT