
Electric 写模式实战本地优先应用中四种 Write Pattern 的渐进式选型与源码解析【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric本文以 Electric 官方仓库中的 write-patterns 演示页与配套示例为核心系统讲解在 Electric 应用中处理本地写入的四种渐进式模式在线写入、乐观状态、共享持久化乐观状态、以及穿透数据库同步through-the-database sync。四种模式均以 Electric 作为读路径read-path同步引擎写路径write-path策略逐层增强文章结合 write-patterns 示例源码、Writes 指南 与共享后端实现完整覆盖每种模式的实现要点、优缺点、适用场景与本地回滚策略帮助你在真实项目中做出可落地的选型决策。Electric 与写路径先读同步再选写入策略Electric 的定位是读路径同步read-path sync它把数据从 Postgres 同步到本地应用与服务但不提供也不规定写路径同步方案——即它不内置把数据从本地写回 Postgres的解决方案。这一设计哲学来自 Electric 的可组合性composable理念正如你可以把同步接入任意客户端写操作也可以按任意方式实现配合你已有的 API 与后端栈。write-patterns 演示页 对应的示例正是这一理念的具象化它实现了 Writes 指南 中描述的四种写模式并且把四种模式作为同一个 React 应用的四个组件同时运行在同一页面上。这样设计的目的是让你能够并排对比side-by-side评估各模式的行为包括在不同网络连通性条件下的表现——例如断开网络时哪些模式可以离线写入、哪些只能等待恢复。四种模式按复杂度递进排列在线写入Online writes最简单写请求直接发 API离线不可写乐观状态Optimistic state引入 React 内置的useOptimistic支持离线写入但状态仅限组件作用域且不持久共享持久化乐观状态Shared persistent optimistic state把乐观状态放入共享的、持久化的本地存储跨组件可见、可回滚穿透数据库同步Through-the-database sync把共享持久化乐观状态推进到本地嵌入式数据库应用代码只与一张本地表交互后台自动同步。示例仓库结构与运行方式示例位于 examples/write-patterns整体结构如下examples/write-patterns/ ├── patterns/ # 四种模式的核心代码每种一个子目录 │ ├── 1-online-writes/index.tsx │ ├── 2-optimistic-state/index.tsx │ ├── 3-shared-persistent/index.tsx │ ├── 4-through-the-db/ │ │ ├── index.tsx # 应用组件 │ │ ├── db.ts # PGlite 初始化与 Electric 读同步 │ │ ├── local-schema.sql # 本地库 schema影子表 视图 触发器 │ │ └── sync.ts # ChangeLogSynchronizer 写路径同步器 │ └── index.ts # 统一导出四个模式组件 ├── shared/ # 共享代码 │ ├── app/ # React 应用外壳client.ts、config.ts 等 │ ├── backend/api.js # Express API 服务含 Electric 代理 │ └── migrations/ # 服务端 Postgres 迁移 ├── package.json # 依赖与脚本 ├── sst.config.ts # SST 部署配置 └── Dockerfile从 package.json 的依赖可以看出各模式用到的关键技术栈electric-sql/client、electric-sql/reactworkspace 内部包读路径同步与useShapeHookelectric-sql/experimentalmatchStream/matchBy流匹配工具用于监听本地写回同步时刻electric-sql/pglite与electric-sql/pglite-sync模式四的本地嵌入式数据库与 Electric-PGlite 桥接valtio模式三的共享响应式存储zodAPI 服务端的输入校验。运行步骤从 示例 README 继承完整操作步骤。先在 monorepo 根目录安装依赖并构建工作区包pnpm install pnpm run -r build然后在examples/write-patterns目录下启动 Docker 后端容器该命令同时会执行数据库迁移见 package.json 中backend:up脚本PROJECT_NAMEwrite-patterns pnpm -C ../../ run example-backend:up pnpm db:migrate其中迁移使用pg-migrations apply --directory ./shared/migrationspnpm backend:up启动开发服务器dev脚本用concurrently同时跑 Vite 前端与node shared/backend/api.js后端pnpm dev结束后拆除后端容器pnpm backend:down模式一在线写入Online writes模式一实现 的核心思路是用 Electric 做读同步用 HTTP API 做写。读路径上组件使用 Electric React 包的useShapeHook把 Postgres 中的todos表同步进 React 状态const { isLoading, data } useShapeTodo({ url: TODOS_URL, parser: { timestamptz: (value: string) new Date(value), }, })其中TODOS_URL定义在 shared/app/config.ts取值为VITE_SERVER_URL环境变量或默认的http://localhost:3001加上/todos路径。写路径上三个事件处理器分别对共享 API 客户端发起POST /todos、PUT /todos/:id、DELETE /todos/:idasync function createTodo(event: React.FormEvent) { event.preventDefault() const form event.target as HTMLFormElement const formData new FormData(form) const title formData.get(todo) as string const path /todos const data { id: uuidv4(), title: title, created_at: new Date(), } await api.request(path, POST, data) form.reset() }注意这里写入是先 await 再更新界面UI 不会在写成功前变化界面更新完全依赖 Electric 把新行同步回来。客户端请求封装在 shared/app/client.ts 中内置了指数退避重试详见下文共享基础设施一节所以 API 短暂不可用时请求会自动重试但应用界面上看不到任何中间状态。优点与适用场景实现非常简单可以直接复用你已有的 API应用对读操作而言离线可用Electric 持续把服务端数据同步到本地速度快。适合的场景实时仪表盘、数据分析与可视化、在云端生成 embedding 的 AI 应用以及写入本身就需要在线集成的系统例如支付。缺点写路径上挂着网络慢、有延迟、要转圈等待交互类应用无法离线写入。要支持离线写入就需要引入下面的乐观状态模式。顺带一提phoenix-liveview 示例 也实现了这一模式——用 Electric 向 LiveView 客户端流式推送数据用常规 Phoenix API 处理写入说明该模式不限于 React 生态。模式二乐观状态Optimistic state模式二实现 在模式一的基础上引入乐观状态写入时先乐观地把结果渲染出来同时异步地把数据发给服务器。它使用 React 内置的useOptimisticHook 与useTransition来管理乐观状态的生命周期type Write { operation: insert | update | delete value: PartialTodo } const [todos, addOptimisticState] useOptimistic( sorted, (synced: Todo[], { operation, value }: Write) { switch (operation) { case insert: return synced.some((todo) todo.id value.id) ? synced : [...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) } } )useOptimistic的第二个参数是一个 reducer给定当前已同步synced的状态和一个待处理的写操作返回渲染用的合并状态。写入处理器则被包进startTransition在发出 HTTP 请求的同时订阅 Electric 的形状流等待这条写入同步回来startTransition(async () { addOptimisticState({ operation: insert, value: data }) const fetchPromise api.request(path, POST, data) const syncPromise matchStream( stream, [insert], matchBy(id, data.id) ) await Promise.all([fetchPromise, syncPromise]) })这里有几个源码级的细节值得注意useShape除了返回data还解构出了stream形状流。代码同时等待两个 PromiseAPI 请求成功fetchPromise且这条写入经由 Electric 复制流同步回应用syncPromisematchStream/matchBy来自electric-sql/experimental包它们监听形状流中指定操作类型如insert且主键匹配的条目之所以要额外等待同步回传是因为写成功后乐观状态才应该被丢弃——注释中明确说明这与大多数乐观状态示例略有不同我们同时等待 API 请求和同步完成。一旦服务端的数据经 Electric 流回来useOptimistic的 pending 状态自然清空UI 无缝切换到真实同步数据。如果应用或 API 离线写请求按退避算法重试网络恢复后最终成功成功后写入经 Electric 自动同步回应用乐观状态被丢弃。优点与缺点优点是网络彻底离开了写路径写入立即显示、离线可写、无转圈等待实现依然简单且可复用现有 API。适合管理类应用与交互式仪表盘、希望快速无转圈的应用以及对网络波动敏感patchy connectivity的移动应用。缺点是这份乐观状态是组件作用域内的其他渲染同一份数据的组件看不到它可能显示陈旧数据而且状态不持久——组件卸载或页面刷新后即丢失。这两点由模式三解决。模式三共享持久化乐观状态Shared persistent optimistic state模式三实现 把乐观状态从组件内存挪进一个共享的、持久化的本地存储。该模式可以用多种客户端状态管理与存储机制实现示例选择 valtio 的proxyMap作为共享响应式存储并在任何变更时把整个 store 序列化写入localStorageconst KEY electric-sql/examples/write-patterns/shared-persistent // 共享、持久化、响应式的乐观状态存储 const optimisticState proxyMapstring, LocalWrite( JSON.parse(localStorage.getItem(KEY) || []) ) subscribe(optimisticState, () { localStorage.setItem(KEY, JSON.stringify([...optimisticState])) })组件内用 valtio 的useSnapshot读取 store再用一个与useOptimisticreducer 几乎同构的纯函数把已同步数据与本地写入合并const localWrites useSnapshotMapstring, LocalWrite(optimisticState) const 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()])写路径由两个模块函数配合addLocalWrite(operation, value)为每次本地写入生成一个uuidv4()作为写入记录 id存入共享 storematchWrite(stream, write)订阅形状流直到该写入同步回来随后从 store 中删除这条本地写入。关键的匹配策略体现在源码中const matchFn operation delete ? matchBy(id, value.id) : matchBy(write_id, write.id)sendRequest(path, method, write)把写入附带write_id发往 API若请求失败或非 2xx则从乐观状态中删除该条本地写入——这就是该模式下的回滚入口因为它同时握有本地写入上下文与共享 store。关键实现细节基于 write_id 的 rebase这是模式三相对模式二最重要的增强。合并逻辑在write_id而非行id上匹配 insert/update 操作其效果是其他用户并发改动同一行时不会清除你的乐观状态本地状态得以rebase在并发变更之上只有你自己的那条写入同步回来时才清除本地状态。删除操作仍按id匹配因为 delete 无法更新write_id列如需支持可恢复的并发删除可以改用软删除本质上是 update。服务端表结构为此做了配套迁移 02-add-write-id.sql 为todos表增加可选的write_id UUID列其注释解释了设计意图——按每次操作的更新键匹配、而非只按行id匹配就能把本地乐观状态 rebase 到他人对同一行的并发变更之上。优点与缺点该模式占据了设计空间中一个非常有吸引力的点实现相对简单、无重型依赖持久化的乐观状态让离线写入更健壮共享 store 让所有组件都能看见并响应乐观状态避免了模式二中组件间不一致的问题因此更适合更复杂的真实世界应用。同时不可变的服务端同步状态与可变的本地状态相互分离使回滚策略易于推理与实现——回滚入口同时持有本地写入上下文与共享 store可以做相对外科手术式的定向回滚甚至扩展到同时清除因果依赖于被拒写入的其他本地写入等更复杂策略。缺点读时合并数据使本地读取略慢写入仍经由 API 完成——复用现有 API 通常很务实但如果想彻底摆脱 API、获得更纯粹的 local-first 体验则可以升级到模式四。适合构建本地优先软件、交互式 SaaS、协作与创作类应用。模式四穿透数据库同步Through-the-database sync模式四 把共享持久化乐观状态的概念一路推进到本地嵌入式数据库用的是 PGlite浏览器中的 Postgres 兼容数据库。它具体做了四件事把 Electric 同步的数据写入一张不可变的todos_synced表把本地乐观状态持久化在影子表todos_local中用一个todos视图把两者在读取时合并为读写提供统一接口自动检测本地变更在后台把它们同步到服务端。PGlite 初始化读路径直入本地表db.ts 负责创建并配置 PGlite 实例注意idb://开头的DATA_DIR表示数据持久化在 IndexedDB 中即本地数据库本身跨页面刷新存活const pglite: PGliteWithLive await PGlite.create(DATA_DIR, { extensions: { electric: electricSync(), // electric-sql/pglite-sync live, // electric-sql/pglite/live }, }) await pglite.exec(localSchemaMigrations) // 执行 local-schema.sql await pglite.electric.syncShapeToTable({ shape: { url: TODOS_URL }, shapeKey: todos, table: todos_synced, primaryKey: [id], })syncShapeToTable把 Electric 形状直接写入todos_synced表而不是走 JS 内存。live扩展则让本地查询能响应表变化自动重跑useLiveQuery。本地 Schema影子表、视图与触发器机器local-schema.sql 是这个模式复杂度的核心值得逐一拆解todos_synced表不可变的服务端同步状态含记账列write_id UUIDtodos_local影子表本地乐观状态。除数据列外有三类记账列changed_columns TEXT[]记录哪些列被本地改过、is_deleted BOOLEAN软删除标记、write_id UUID NOT NULLtodos视图FULL OUTER JOIN两表按列做COALESCE/CASE合并——只有当changed_columns标记了该列时才取本地值否则取同步值WHERE local.id IS NULL OR local.is_deleted FALSE过滤软删除行changes变更日志表id BIGSERIAL、operation、value JSONB、write_id、transaction_id XID8——这是写路径同步的队列。触发器承担两类工作其一乐观状态生命周期管理。当 Electric 把数据写入todos_synced时delete_local_on_synced_insert_and_update_trigger按write_id匹配删除对应的本地乐观行支持并发 rebase与模式三同理delete_local_on_synced_delete_trigger因删除不可 rebase 而直接按id匹配。其二把对视图的写操作重定向。todos视图上挂了三组INSTEAD OF触发器todos_insert_trigger先校验 id 在两张表中都不存在然后向todos_local插入全列标记为 changed并向changes写入一条insert变更消息write_id为gen_random_uuid()transaction_id为pg_current_xact_id()todos_update_trigger逐列用IS DISTINCT FROM与同步值比较维护changed_columns只保留既标记为变更、值又真的变了的列向todos_local插入/更新并记录update变更todos_delete_trigger在todos_local中 upsert 一条is_deleted TRUE的软删除记录并记录delete变更。最后changes表上的changes_notify触发器在每次插入变更日志时执行NOTIFY changes——这是驱动后台同步器的信号源。变更同步器ChangeLogSynchronizersync.ts 中的ChangeLogSynchronizer类监听该信号并把变更批量发往服务端。从源码结构看其工作循环是start()通过this.#db.listen(changes, ...)注册 PGlite 的 listen 回调并立即跑一次process()handle()在通知到达时若正在处理则只置位#hasChangedWhileProcessing否则立即进入process()——这是一个简单的单飞single-flight调度器process()先从changes表查询id position的增量批次send()把批次按transaction_id分组排序后POST /changes根据响应分流acceptedHTTP 成功→proceed(position)删除已处理变更并前移水位retry网络异常或 5xx→ 保持原水位下轮重试rejected4xx→rollback()rollback()是刻意的极简策略在一个本地事务里DELETE FROM changes并DELETE FROM todos_local即任一写入被拒清空全部本地状态。README 明确建议生产环境实现更精细的策略例如只清除因果依赖于被拒写入的状态、向用户提示发生了什么或考虑使用现成框架stop()通过AbortController与unsubscribe干净地停掉同步。应用代码因此保持极简index.tsx 中的应用组件没有任何网络代码Wrapper在挂载时初始化 PGlite 并启动同步器ThroughTheDB组件用useLiveQuery读SELECT * FROM todos三个事件处理器只是对本地库执行db.sql模板的INSERT/UPDATE/DELETE。数据获取与发送被完全抽象在 Electric 读同步读与变更日志同步器写背后——组件只与一张表对话。优点与缺点优点完整离线支持、共享乐观状态、组件纯本地数据库交互、无需面向网络编码。适合本地优先软件、移动端与桌面端应用、协作创作类软件。缺点嵌入式数据库是相对沉重的依赖影子表与触发器机器使客户端 schema 定义复杂化后台同步使得回滚处理更困难——模式三中你能在仍在处理用户输入、上下文还在时检测到写入被拒而穿透数据库同步下这个上下文很难重建。共享基础设施弹性客户端、API 服务与数据库迁移四种模式共享一套后端与客户端基础设施理解它们有助于把握整个示例的行为边界。带退避重试的 HTTP 客户端shared/app/client.ts 是所有模式的 API 调用入口内置弹性重试// Keeps trying for 3 minutes, with the delay // increasing slowly from 1 to 20 seconds. const maxRetries 32 const backoffMultiplier 1.1 const initialDelayMs 1_000延迟公式为retryCount × 1.1 × 1000ms最多 32 次、约 3 分钟。resilientFetch只在网络错误fetch抛出异常时重试注释也提示可以按需在 4xx/5xx 时重试。这是模式一离线只读、写等待恢复行为与模式二离线写入最终送达的底层保证。Express API 服务REST 写入 Electric 形状代理shared/backend/api.js 用 Express 提供两类路由默认监听 3001 端口连接 Postgres 的DATABASE_URL默认为postgresql://postgres:passwordlocalhost:54321/electric写入路由POST /todoszod 校验id/title/created_at及可选write_id、PUT /todos/:id、DELETE /todos/:id分别执行对应的INSERT/UPDATE/DELETE。注意updateTodo会把write_id一并写回——这正是模式二/三按write_id匹配同步回传的数据基础形状代理路由GET /todos并不是普通 REST 查询而是把请求代理到 Electric 的/v1/shape端点ELECTRIC_URL默认http://localhost:3000。它只透传 Electric 协议所知的查询参数通过electric-sql/client导出的ELECTRIC_PROTOCOL_QUERY_PARAMS白名单过滤在服务端强制tabletodos并按需附加source_id/secret凭据。这个代理设计让客户端形状 URL 指向 API 服务而不是直连 Electric从而可以在服务端注入认证变更批量路由POST /changes专供模式四接收按transaction_id分组的变更数组在单个数据库事务BEGIN…COMMIT失败ROLLBACK中按operation分发到 insert/update/delete 逻辑。服务端迁移01-create-todos.sql 创建基础表并插入一条种子数据CREATE TABLE IF NOT EXISTS todos ( id UUID PRIMARY KEY, title TEXT NOT NULL, completed BOOLEAN NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL );02-add-write-id.sql 再补充write_id列。迁移注释点明了它对简单模式并非必需但为更高级模式提供了监控复制流时的匹配选项。进阶议题合并逻辑与回滚从 Writes 指南 的进阶章节可以提炼出处理离线/乐观写入的两个核心复杂度也是选型时必须权衡的点1. 合并逻辑merge logic。当复制流带回一条变更时应用必须决定如何处置与之重叠的乐观状态并发其他用户、设备甚至标签页的变更会让问题更复杂。模式三、四都演示了rebase 本地状态到同步状态之上而非天真地清空以保留本地变更。指南还指出仓库内的 linearlite 示例 是另一个合并逻辑更复杂的穿透数据库同步实现可作对照参考。2. 回滚rollbacks。离线写入被服务端拒绝时应用需要某种方式回退本地状态并通知用户。基线策略是任一写入被拒即清空全部本地状态模式四的rollback()即此策略更宽容的策略包括把被拒写入标记出来供人工解决冲突或只清除因果依赖于被拒操作的写入集合。一个关键考量是间接性直接向 API 发写时回滚可以在写上下文仍在时处理而穿透数据库同步时原始写入上下文已很难重建。3. YAGNI 提醒。指南引用了 local-first 论文作者之一 Adam Wiggins 在 Muse 项目上的经验现实中冲突极其罕见presence 等策略就能很好地缓解。因此除非你确实在构建高并发协作体验否则模式一~三的直白策略往往更容易实现与推理对多数应用已经足够好用。模式选型速查表维度1. 在线写入2. 乐观状态3. 共享持久化4. 穿透数据库离线读支持Electric支持支持支持离线写不支持支持组件作用域、不持久支持共享、持久于 localStorage支持持久于本地 PGlite写路径HTTP APIHTTP APIHTTP APIHTTP API后台变更日志批量发送组件间一致性一致等真实数据可能不一致一致一致回滚上下文有请求现场有有含共享 store弱后台同步上下文难重建复杂度/依赖最低低React Hook中valtio localStorage高PGlite 复杂 schema 触发器典型场景仪表盘、云端 AI、支付类管理台、快速交互应用本地优先 SaaS、协作创作本地优先、移动端/桌面端选型建议遵循 YAGNI 原则从最简单的满足需求的模式起步只有在确实需要离线写、组件一致性或纯本地数据库体验时才逐级升级模式四的额外复杂度嵌入式库、影子表、触发器、naive 回滚只有在收益明确时才值得支付。小结write-patterns 示例把 Electric 生态中读同步交给 Electric、写路径自选策略的架构理念落地为四个可直接运行、可并排对比的 React 组件从最简单的在线写入到useOptimistic驱动的乐观状态再到 valtio localStorage 的共享持久化乐观状态最后到 PGlite 嵌入式数据库 INSTEAD OF触发器 NOTIFY变更日志的完整 local-first 架构。四种模式共享同一套带退避重试的客户端、Express API含 Electric 形状代理与POST /changes批量写入端点与write_id迁移使得模式间的差异被清晰地隔离在各自的patterns/*/index.tsx与少量辅助文件中非常适合作为本地优先应用写路径选型的参考实现与教学材料。【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考