网络一断,前端就只能转圈?用 Eremite 给 REST 应用补上离线队列与可恢复同步
移动网络、地铁里的笔记本、偶发的公司 VPN 抖动,都会让“请求失败后提示重试”的前端暴露出一个问题:用户刚刚完成的操作,到底是没发生、只在本地发生,还是已经被服务端接收?如果界面为了等待 fetch() 而冻结,体验很差;如果先乐观更新又没有可恢复的投递记录,刷新页面后则可能把尚未提交的数据丢掉。
Eremite.js 是一个面向任意 REST 后端的本地优先数据层。它不要求后端改成特定同步协议:只要现有 API 能用 fetch 调用,就能把读取缓存、乐观更新、持久化 outbox(待发送队列)和断线后的顺序推送放到前端。项目以 TypeScript 编写,MIT 许可证,核心包没有运行时依赖;Vue 3 与 React 另有官方绑定。
这篇文章不把“离线”理解成“永远不访问服务器”。更准确的目标是:本地先给出一致的可见状态,网络恢复后以可追踪、可重试的方式把操作交给既有 REST API。
先把问题拆开:缓存、待提交操作和确认状态不是一回事
普通缓存只解决“下次读得更快”。而一条用户刚创建的待办事项还需要回答更多问题:它是否已经被服务端确认?请求在浏览器关闭时是否还留着?两个标签页会不会把同一项重复提交?服务端自己分配 ID 时,本地刚创建的临时对象如何与服务端实体对上?
Eremite 的设计把已确认的数据与 pending changes 分开保存。页面可以立即渲染新的本地对象,同时用 $pending 告诉 UI 它尚在同步;待投递操作进入持久化 outbox。README 说明它利用 IndexedDB、Web Locks、BroadcastChannel 和 Web Crypto 等浏览器能力,并通过标签页 leader 选举避免同一队列操作被重复提交。这里的“exactly once”是项目对客户端队列投递语义的设计目标,不应把它延伸成跨越任意后端故障的全局事务承诺。
最小接入:把已有 REST 调用放到 push,把服务端快照放到 pull
先安装核心包;如果页面用 Vue 或 React,再增加对应绑定:
npm install @eremitejs/core npm install @eremitejs/vue
下面的例子假定后端已经有 GET /api/todos、POST /api/todos 和 PATCH /api/todos/:id/toggle。关键不是替换后端,而是明确三类职责:collections 放本地数据,mutators 描述立即生效的本地变更,push 描述稍后发送到 API 的副作用,pulls 则把服务端读到的快照写回 store。
import { collection, createStore } from '@eremitejs/core'
interface Todo { id: string; title: string; done: boolean }
export const store = createStore({
name: 'todos-app',
collections: { todos: collection() },
mutators: {
addTodo(tx, input: Todo) {
tx.todos.set(input.id, input)
},
toggleTodo(tx, input: { id: string }) {
tx.todos.update(input.id, todo => { todo.done = !todo.done })
},
},
push: {
async addTodo({ input, idempotencyKey }) {
await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey },
body: JSON.stringify(input),
})
},
async toggleTodo({ input }) {
await fetch(`/api/todos/${input.id}/toggle`, { method: 'PATCH' })
},
},
pulls: {
todos: {
fetch: async () => await (await fetch('/api/todos')).json(),
write(tx, todos: Todo[]) {
for (const todo of todos) tx.todos.set(todo.id, todo)
},
},
},
})
这个结构有一个容易被忽略的工程要求:后端应正确处理 Idempotency-Key。网络在响应返回前中断时,客户端无法凭空判断服务器是否已经处理请求。把同一个幂等键传给后端,才让后端有机会把“重试”识别为同一次创建,而不是多创建一条记录。Eremite 能保存和调度队列,但不能替后端补上幂等性设计。
UI 层:立即响应,同时把同步状态变成可见信息
在 Vue 中,官方绑定提供 usePull、useQuery 与 useSyncStatus。页面加载时先拉取服务端快照;用户新增项目时用 store.id() 生成本地 ID,并调用 mutator。UI 既不必等待网络,也不必假装所有操作都已完成:
当前离线;还有 {{ pendingOps }} 项待同步
这里最重要的不是那句“离线可用”,而是状态表达:$pending 适合显示“正在保存”,online 与 pendingOps 适合让用户知道系统没有遗忘他的操作。若产品涉及付款、审批或不可逆提交,更应在 UI 中区分“已在本地登记”“已送达服务端”“已被业务流程确认”,不要把一次乐观更新包装成最终成功。
服务器分配 ID、冲突与多标签页:不要把边界藏起来
不少 REST API 由服务器生成 ID。Eremite 的文档将“占位 ID 与关系”“重试直到服务器给出真实 ID”列为独立主题,这意味着接入复杂关联数据前应先阅读对应文档并用项目自己的 API 做演练。不要仅把 Todo 示例复制到订单、评论引用或父子关系表上:这些场景要验证临时引用替换、失败回滚和刷新后的恢复。
同样,outbox 并不等于不需要冲突策略。两个设备同时修改一份资料时,后端仍应定义版本字段、条件更新、最后写入者策略,或让用户解决冲突。Eremite 把本地 confirmed state 与 pending changes 分离,目的是减少乐观更新把已确认数据污染掉的风险;最终的业务冲突规则仍属于你的 API 合约。
开发阶段可以直接运行仓库附带的可执行示例:
pnpm install pnpm --filter example-tasks-vue dev
官方示例中的 tasks 应用使用模拟的 flaky backend,演示服务端分配 ID、outbox、重试与冲突 UI。它比只在稳定 Wi-Fi 下点几次按钮更适合验证断网、刷新、重新联网和多标签页这些真实失败路径。
上线前把失败路径写成验收用例
很多离线功能在演示时看起来顺畅,是因为测试只覆盖了“断网时新增一条数据”。真正的风险发生在时序交叉处:请求已经抵达服务器但响应丢失、浏览器在 outbox 写入后被关闭、另一个标签页也在修改同一实体、网络恢复后服务端因权限或校验拒绝操作。建议在接入前为每一种 mutator 写下可观察的结果,而不是只验证页面没有报错。
| 场景 | 应观察什么 | API / UI 的责任 |
|---|---|---|
| 完全离线时新增 | 新项目立即可见,且标识为 pending;刷新后仍存在 | 客户端持久化本地状态与队列,UI 显示待同步数 |
| 请求超时后重试 | 服务端不会生成两份同样的记录 | POST 使用稳定的幂等键,服务端按键去重 |
| 服务端返回 4xx | 用户能看懂失败原因,也能编辑、撤销或重新提交 | 不要把失败静默留在队列中 |
| 两个标签页同时操作 | 同一待发操作不被两次提交,界面最终可收敛 | Eremite 的多标签协调配合后端版本规则 |
| 设备重新联网 | 队列按预期顺序恢复,关键失败不会阻塞而无人知晓 | 为重试、告警与人工处理设计清晰状态 |
尤其要区分网络失败与业务失败。前者可能只是暂时不可达,适合重试;后者如字段校验、权限过期、对象已删除,通常需要用户或业务逻辑介入。把所有异常都自动重放,容易制造循环请求;把所有异常都立即回滚,又会损失离线工作的价值。实践中可以在 UI 中保留一个“待同步 / 需要处理”的入口,让用户看到具体哪条操作卡住了。
如果项目已有服务端审计或事件日志,也建议把幂等键、客户端操作 ID 和服务端最终实体 ID 关联起来。这样遇到“用户说我点过保存,但网页又显示未保存”的问题时,团队能沿着一条操作链定位:本地是否写入、outbox 是否存在、请求是否发出、后端是否受理、拉取快照是否覆盖了界面,而不是只从浏览器控制台猜测。
什么时候值得用,什么时候先别用
Eremite 适合已有 REST API、又希望在浏览器端获得本地缓存与可恢复写入的业务应用:现场记录、任务管理、轻量 CRM、巡检表单等。它的优势是不用为了离线能力把后端迁到专用同步服务。
但它不是离线问题的万能答案。若所有写入都必须实时授权、服务端状态强一致且绝不能延迟,应该先评估是否允许本地排队;若数据涉及高度敏感内容,也要审查 IndexedDB 的本地存储、登出清理、设备加密与共享终端策略。接入前至少演练四件事:断网时创建、刷新后仍有待提交操作、网络恢复后的重试、服务端拒绝后的用户可见反馈。
相关链接