第 12 章:React 前端的数据架构
前端同时消费应用配置、核心快照、系统状态和实时流。项目没有选择一个万能状态库,而是按更新频率分配给预加载缓存、SWR、Context、模块级 store 和组件状态。本章从 bootstrap 到页面刷新解释这套组合。
一、React mount 之前先解决语言和主题
main.tsx 不立即 createRoot(),而是先:
preloadAppData()请求 Verge 配置;- 从缓存、配置或浏览器语言解析初始语言;
- 初始化 i18next;
- 根据
theme_mode和系统偏好计算 light/dark; - 同时预热首页卡片数据;
- 再 mount React。
这样可以减少两类闪烁:
- 页面先用默认语言渲染,随后整页文字跳变;
- 先显示浅色,再读取配置切到深色。
若预加载失败,bootstrap 会初始化 fallback language,并从 window 注入或系统偏好计算主题,应用仍能渲染。
二、Provider 只放真正的应用级状态
根组件组合:
ThemeModeProvider
LoadingCacheProvider
UpdateStateProvider
BaseErrorBoundary
SWRConfig
WindowProvider
AppDataProvider
RouterProvider前三个是轻量客户端状态;WindowProvider 处理窗口装饰;AppDataProvider 聚合低频核心快照。高频流量和连接没有放在根 Context,避免每条消息让整棵组件树重新计算。
三、Query 层以 SWR 为执行器,再加显式 cache mirror
services/query-client.ts 提供类似 query API:
useQuerygetCacheData/setCacheDatarevalidateQuery/QueriesfetchCacheDataremoveCacheData
底层使用 SWR,但另外维护 Map<string, unknown> mirror。原因是 WebSocket handler 和非 React 代码需要同步读取当前值,才能安全执行函数式 updater:
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 时按运行可信度解析:
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:
- ref 始终指向最新函数;
- 返回一次创建的 callback;
- effect 可以稳定订阅;
- 调用时仍使用最新实现。
这是一种常见 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 mirrorsrc/providers/app-data-provider.tsx:核心快照聚合与窄 Contextsrc/pages/_layout/hooks/use-layout-events.ts:event → cache/revalidatesrc/hooks/use-clash.ts、use-verge.ts、use-profiles.ts:mutation 策略src/pages/_navigation.tsx、_routers.tsx:菜单/路由单一元数据src/services/states.ts:轻量 Context state