Store 与同步:本地优先不是离线口号
一、Store 的真实职责
packages/internal/store 不是一个单一的全局对象,而是一组领域模块:
store/
├── modules/feed
├── modules/subscription
├── modules/list
├── modules/inbox
├── modules/entry
├── modules/unread
├── modules/collection
├── modules/summary
├── modules/translation
├── modules/image
└── morph / lib / hydrate / context
每个模块通常都有 store、getters、hooks、selectors、types 或 utils。这样的拆法把“怎么写入”“怎么读取”“React 如何订阅”分开,避免组件直接操作 Zustand 原始 state。
二、一次数据同步的四层
这四层解决不同问题:
- Morph 解决协议差异;
- Action 解决状态转移;
- Store 解决内存中的即时读取;
- Database 解决重启后恢复与离线读取。
如果一个 mutation 只改 Zustand、不写数据库,刷新或重启就会丢;如果只写数据库、不 patch Store,当前页面就没有即时反馈。
三、Hydrate 是恢复本地投影的总入口
hydrate.ts 收集了 feed、subscription、inbox、list、unread、user、entry、collection、summary、translation、image 等 hydrate action。需要迁移时,流程是:
await initializeDB()
await migrateDB()
await Promise.all(hydrates.map((item) => item.hydrate()))
这里的 Promise.all 暗示这些模块的恢复没有强制先后依赖;真正的关系通过 ID 和 selector 在读取时解析。它避免了串行读取几十张表的启动瓶颈,也让新增模块只需加入 hydration 列表。
四、mutation 的正确顺序
以“标记已读”为例,理想路径是:
- 组件/Hook 计算目标 Entry IDs;
- 本地 Store 立即把
read设为true,未读数同步减少; - 将请求加入批处理队列,减少快速滚动产生的网络请求;
- API 成功后确认远端状态;
- API 失败时根据错误策略回滚、重试或保留本地标记;
- 数据库持久化,保证下一次 hydrate 仍能看到状态。
unread/store.test.ts 覆盖了批量标记、并发/陈旧返回和快速操作合并等场景。这些测试非常重要,因为未读不是单个布尔值:它是 Entry 级别的事实和 Feed/List 级别的汇总之间的推导。
五、为什么未读数单独成模块
未读 Store 使用 feedId -> count 的投影,同时通过 Entry Store 计算实际未读。这个设计允许列表页快速展示 badge,不必每次把所有 Entry 重新遍历一遍;当陈旧的 Entry 请求返回时,测试也会保护本地刚刚做出的“已读”意图。
一个值得带走的思想是:
汇总值可以是缓存,但不能成为唯一事实。事实在 Entry,汇总在 Unread;两者通过 action 和测试保持一致。
六、API Context 与可测试性
Store 内的 action 不应直接 import 单例网络客户端,否则测试很难替换 API。store/src/context.ts 提供 apiContext 一类的注入边界,测试可以提供 mock API,验证“本地 patch + 一次请求 + 最终状态”的完整行为。
这比只测纯 selector 更有价值,因为真正的 bug 往往发生在异步竞态:
- 用户先标记已读;
- 旧查询稍后返回未读;
- 如果没有版本意识或本地 patch 保护,UI 会闪回未读。
七、同步的三个时间维度
启动同步
本地 DB → Store hydrate。目标是快速恢复用户界面。
交互同步
用户动作 → 本地乐观更新 → API mutation。目标是低延迟和可感知反馈。
拉取同步
API 增量/详情 → Morph → Store + DB。目标是最终与远端事实一致。
这三类同步不能混成一个 fetchEverything(),因为它们的错误、延迟和用户可见性不同。
八、这种架构的代价
代价是每个领域动作都必须回答“内存、磁盘、远端谁先谁后”。Store 模块数量也会增长,跨模块 selector 需要约束依赖方向。
收益是 UI 组件可以更简单:它们订阅模型和派生选择器,不需要知道 SQLite、重试、API DTO 或平台存储。对于桌面和移动端来说,这种复杂度集中在共享层,往往比在每个平台各自实现一遍更便宜。