Electric 写路径模式三共享持久化乐观状态Shared Persistent Optimistic State实战指南【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric这篇指南围绕 electric 仓库中 write-patterns 示例 的第三个写路径模式展开在保留 Electric 负责读路径同步、写仍走 API 架构的前提下把乐观状态从组件作用域提升为全局共享、可持久化的响应式存储从而获得离线写入韧性、跨组件一致性以及更精细的回滚控制。读完本文你将掌握基于 valtio localStorage 落地共享持久化乐观状态的完整代码路径理解以write_id而非id匹配同步流以实现并发 rebase 的关键机制并能够独立运行本示例验证其行为。模式在写路径设计空间中的定位write-patterns 示例把写路径的四种方案放在同一个 React 页面中并排运行方便在真实网络条件下对比模式写路径策略乐观状态范围持久化1-online-writes直接请求 API失败重试无无2-optimistic-state请求 API 组件级乐观状态仅发起写入的组件无刷新即失3-shared-persistent本文请求 API 共享乐观状态整个应用localStorage4-through-the-db本地嵌入式数据库直写本地 shadow 表嵌入式数据库本模式是 2-optimistic-state 的直接演进二者都通过 API 落库也都把 本地写入立即生效、等服务端回环后再收敛 作为核心体验区别只在于乐观状态的存放位置与生命周期。在3-shared-persistent中React 的useOptimistichook 被替换为 valtio 的useSnapshot加一个自定义的 reduce 函数二者承担几乎相同的职责index.tsx 源码。核心实现共享、持久化、响应式的乐观状态存储存储定义proxyMap localStorage 双向绑定示例在模块顶层而非组件内定义乐观状态这决定了它天然对全应用可见// examples/write-patterns/patterns/3-shared-persistent/index.tsx const KEY electric-sql/examples/write-patterns/shared-persistent type LocalWrite { id: string operation: Operation value: PartialTodo } const optimisticState proxyMapstring, LocalWrite( JSON.parse(localStorage.getItem(KEY) || []) ) subscribe(optimisticState, () { localStorage.setItem(KEY, JSON.stringify([...optimisticState])) })三个关键点共享shared状态是模块级单例proxyMap任何组件都能通过useSnapshot读取、通过optimisticState.set/delete修改持久化persistent初始化时从 localStorage 恢复任何变更通过 valtio 的subscribe立即写回 localStorage页面刷新、组件卸载都不会丢失未同步完成的写入响应式reactivevaltio 的 proxy 语义让组件在useSnapshot下自动重渲染。addLocalWrite为每次本地写入生成uuid并登记到共享存储返回的LocalWrite同时承载操作类型与写入数据function addLocalWrite(operation: Operation, value: PartialTodo): LocalWrite { const id uuidv4() const write: LocalWrite { id, operation, value } optimisticState.set(id, write) return write }与模式二对比从 useOptimistic 到共享 reduce模式二使用 React 内置useOptimistic其归并逻辑内嵌在组件内、状态随组件卸载而消失2-optimistic-state/index.tsx。本模式把同一套归并逻辑抽成纯函数computeOptimisticState输入是 Electric 同步下来的不可变数据synced与本地写入列表writesconst computeOptimisticState ( synced: Todo[], writes: LocalWrite[] ): Todo[] { return writes.reduce( (synced: Todo[], { operation, value }: LocalWrite): Todo[] { switch (operation) { case insert: return [...synced, value as Todo] case update: return synced.map((todo) todo.id value.id ? { ...todo, ...value } : todo ) case delete: return synced.filter((todo) todo.id ! value.id) default: return synced } }, synced ) } const todos computeOptimisticState(sorted, [...localWrites.values()])这种 不可变同步状态 可变本地状态 的分离是本文档强调的设计要点同步数据只由 Electric 的 shape 流驱动更新本地写入只活在optimisticState中两者在读时合并combine on-read。由此回滚策略变得易于推理——撤销一个未同步的写入只需从optimisticState删除对应条目完全不影响已同步数据且回滚的入口同时拥有本地写入上下文LocalWrite与共享存储句柄可以让回滚非常外科手术式地精准。合并逻辑以 write_id 匹配实现并发 rebase为什么 insert/update 匹配 write_id而 delete 匹配 id这是本模式相对模式二最实质的差异。在matchWrite中匹配键按操作类型分流const matchFn operation delete ? matchBy(id, value.id) : matchBy(write_id, write.id) await matchStream(stream, [operation], matchFn)insert / update 匹配write_id每次本地写入都带有唯一的write.iduuid并通过 API 一并写入数据库的write_id列。当自己的写入通过 Electric 复制流回环时message.value.write_id write.id精确命中此时才清除该乐观状态。其他人对同一行的并发修改不会携带你的write_id因此不会误清你的乐观状态——你的本地状态可以安全地 rebase 在别人并发修改之上直到你自己的写入真正落库。delete 匹配idDELETE 语句无法更新write_id列列在行删除时随之消失只能退而匹配主键id。文档同时指出如果想支持可撤销的并发删除应当改用软删除——软删除本质上是 UPDATE仍然可以携带write_id从而继续享受 rebase 语义。数据库侧通过 02-add-write-id.sql 增加可空列ALTER TABLE todos ADD COLUMN write_id UUID;迁移注释明确说明了设计动机按每次操作的更新键而非行id匹配允许把本地乐观状态 rebase 到其他用户对同一行的并发修改之上——因为只有你自己的写入同步回来时才清除本地状态别人的写入不会清除它。matchStream / matchBy 的底层实现matchWrite依赖electric-sql/experimental包其实现位于 packages/experimental/src/match.tsexport function matchStreamT extends Rowunknown( stream: ShapeStreamInterfaceT, operations: ArrayOperation, matchFn: (message: ChangeMessageT) boolean, timeout 60000 // ms ): PromiseChangeMessageT { // 订阅流 - 过滤 change message - 命中即退订并 resolve } export function matchByT extends Rowunknown( column: string, value: ValueGetExtensionsT ): (message: ChangeMessageT) boolean { return (message: ChangeMessageT) message.value[column] value }matchStream在 shape 流上持续监听直到出现操作类型在给定列表内且matchFn返回 true的消息后自动退订并 resolve默认 60 秒超时。matchBy(column, value)则生成一个比较message.value[column]的谓词——matchBy(write_id, write.id)正是利用该机制在复制流里识别自己的写入。写路径数据流请求与同步回环并行等待每个事件处理器都遵循相同节奏先登记乐观状态再并行等待两个 promise——API 请求返回与复制流回环startTransition(async () { const write addLocalWrite(insert, data) const fetchPromise sendRequest(path, POST, write) const syncPromise matchWrite(stream, write) await Promise.all([fetchPromise, syncPromise]) })sendRequest会把write_id附加到请求体后再发给 APIasync function sendRequest(path, method, { id, value }: LocalWrite): Promisevoid { const data { ...value, write_id: id } let response: Response | undefined try { response await api.request(path, method, data) } catch (_err) { /* ignore */ } if (response undefined || !response.ok) { optimisticState.delete(id) // 请求失败 - 回滚本地乐观状态 } }值得注意这里的失败处理是直接删除本地写入放弃乐观状态这是最简洁的回滚文档强调的要点是——因为回滚入口同时掌握LocalWrite上下文与共享存储开发者完全可以在失败分支里实现更精细的恢复逻辑。请求成功路径不删除状态交由matchWrite在同步回环到达时清理因此乐观状态的生命周期 API 请求期间 服务端落库后复制回本地期间这正是与普通乐观 UI只等 fetch的区别。useShape负责读路径把 Postgres 中的todos表经 Electric 同步为响应式数据并顺带取出底层stream供匹配逻辑复用const { isLoading, data, stream } useShapeTodo({ url: TODOS_URL, parser: { timestamptz: (value: string) new Date(value) }, })数据读取经 shared/app/config.ts 中的TODOS_URLVITE_SERVER_URL或http://localhost:3001完成。后端与数据库API 复用 write_id 落库本模式的写路径完全复用既有 REST API这也是其务实的核心无需引入嵌入式数据库即可获得大部分本地优先体验。表结构见 01-create-todos.sqlid为主键的 UUIDwrite_id由第二个迁移追加。shared/backend/api.js 中的三个写端点都接受可选的write_id并透传到 SQLPOST /todosINSERT INTO todos (id, title, completed, created_at, write_id) VALUES ($1, $2, false, $3, $4)PUT /todos/:idUPDATE todos SET completed $1, write_id $2 WHERE id $3DELETE /todos/:idDELETE from todos where id $1无法携带 write_id与匹配逻辑呼应。输入校验使用 zodcreateSchema与updateSchema中write_id均为可选字段保证其他不传该字段的模式如在线写、组件级乐观也能复用同一 API。GET /todos则把 Electric 协议参数透传给ELECTRIC_URL默认http://localhost:3000的/v1/shape端点仅转发ELECTRIC_PROTOCOL_QUERY_PARAMS白名单内的查询参数并在服务端固定tabletodos作为 shape 流的代理入口。网络容错内置指数退避重试的客户端即使乐观状态已持久化底层网络请求依然依赖 shared/app/client.ts 的容错客户端resilientFetch捕获网络异常后进入retryFetch按retryCount * 1.1 * 1000ms的延迟递增重试最多 32 次约 3 分钟窗口。注释特别说明若想对 4xx/5xx 也做弹性可在返回前检查状态码决定是否重试。需要区分的是——重试机制保证请求最终送达而乐观状态的存在保证了重试期间 UI 不阻塞两者共同构成本地优先的体验底座。优势、代价与适用场景优势Benefits实现相对简单却能占据设计中很有吸引力的位置在复杂度与能力之间取得平衡乐观状态持久化使本地写入更具韧性——刷新页面、临时断网都不会丢失用户的未同步操作共享存储让所有组件都能看到并响应本地写入规避了组件级乐观状态模式二只有发起组件可见的弱点更适合真实世界的复杂应用不可变同步状态与可变本地状态的分离让回滚策略易于推理与实现且回滚入口具备本地写入上下文与共享存储可做到相对精准的外科式回滚。代价Drawbacks读时合并on-read combine使本地读取略慢写入仍经由 API若希望彻底去 API 化、走纯本地优先路线可升级到数据库直写模式 4-through-the-db本地嵌入式数据库 shadow 表 视图合并但那是用更高复杂度嵌入式数据库、复杂本地 schema、回滚上下文缺失换来的能力。典型适用场景构建本地优先local-first软件、交互式 SaaS 应用、协作与创作类软件。如何运行本示例前置确保仓库根目录已安装依赖并构建工作区包在 monorepo 根目录执行pnpm install pnpm run -r build在 write-patterns 示例目录 内依次执行# 启动 Postgres / Electric 后端容器并执行 shared/migrations 下的迁移 pnpm backend:up # 同时启动 Vite 前端与 Express API端口 3001 pnpm dev完成后关闭后端容器pnpm backend:down示例默认把所有模式渲染在同一个页面中可打开浏览器并借助开发者工具的离线模式直观对比本模式在断网刷新、并发编辑场景下与其余三个模式的行为差异。运行细节与部署配置可参考示例 README 的 How to run 一节依赖清单valtio、electric-sql/react、electric-sql/experimental、uuid 等见 write-patterns/package.json。【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考