十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Electric 写入模式实战:从在线写入到通过数据库同步的四种本地优先方案(write-patterns 示例深度解析)

Electric 写入模式实战:从在线写入到通过数据库同步的四种本地优先方案(write-patterns 示例深度解析) Electric 写入模式实战从在线写入到通过数据库同步的四种本地优先方案write-patterns 示例深度解析【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric本指南以仓库中 examples/write-patterns 示例为核心系统讲解在基于 Electric 的本地优先local-first应用中处理「写入」的四种模式在线写入、组件级乐观更新、共享持久化乐观状态、以及通过嵌入式数据库的自动同步。四种模式共用一个 React 待办应用在同一页面并排运行读者读完可以掌握每种模式的实现代码、适用场景、权衡取舍以及如何在仓库中启动运行做行为对比。一、示例概览一个应用四条写入路径write-patterns是 Electric 官方仓库中的一个完整 React 示例应用。它的核心思路是所有模式共享同一条「读路径」read-path即通过 Electric 将 Postgres 数据同步进本地应用区别只在于「写路径」write-path即本地写入如何最终回到 Postgres。主文档 README 明确指出这四种模式对应 Electric 官方文档中的 Writes 指南且示例被设计为把四种模式作为同一 React 应用的四个组件同时渲染在页面上因此你可以并排观察它们的差异并在不同网络连通性如 DevTools 切换到 Offline下验证各自行为。从目录结构看示例的代码组织非常清晰每个模式一个子目录位于 patterns/ 下1-online-writes/在线写入2-optimistic-state/组件级乐观状态3-shared-persistent/共享持久化乐观状态4-through-the-db/通过数据库同步本地嵌入式 PGlite共享代码位于 shared/包括 Express API 服务端 api.js、前端 API 客户端 client.ts、应用配置 config.ts 以及两份数据库迁移脚本。顶层还有 package.json脚本与依赖、vite.config.ts、sst.config.ts 等工程配置。依赖方面示例使用electric-sql/client、electric-sql/react、electric-sql/experimental提供matchStream/matchBy等流匹配工具、electric-sql/pglite与electric-sql/pglite-react模式四状态管理选用valtio模式三服务端使用express、pg、zod前端框架为 React 19 RC。所有包版本可通过 package.json 查看。二、共享基础设施迁移、API 与弹性客户端在展开四种模式之前先理解示例的共享部分因为所有模式都构建在其上。2.1 数据库迁移todos表与write_id字段Postgres 侧的 schema 由两份迁移脚本定义shared/migrations/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 );并插入一条初始数据Get stuff done便于启动后立即看到效果。shared/migrations/02-add-write-id.sql 为表追加一个可选的write_id列ALTER TABLE todos ADD COLUMN write_id UUID;该迁移的注释解释了它的用途对较简单的模式并非必需但为高级模式提供了一个按操作匹配复制流、从而失效本地状态的键。关键洞察在于按每次操作的write_id而不是仅按行的id匹配可以在其他用户并发修改同一行时把本地乐观状态「rebase」到新的数据之上——因为只有当你自己的写入从复制流同步回来时才会清除本地状态而不是别人的写入。2.2 API 服务端REST 写路径与 shape 代理shared/backend/api.js 是一个 Express 服务默认端口3001连接字符串默认postgresql://postgres:passwordlocalhost:54321/electric均可用环境变量PORT、DATABASE_URL覆盖。它提供GET /todos不是直接查库而是把请求代理给 Electric 的 shape 端点。代码中通过ELECTRIC_PROTOCOL_QUERY_PARAMS来自electric-sql/client只透传 Electric 协议参数服务端固定设置tabletodos并在存在ELECTRIC_SOURCE_ID/ELECTRIC_SOURCE_SECRET时附加认证参数最后把 Electric 的 Web Stream 转为 Node.js 流透传给客户端。这意味着前端永远只跟自己的 API 打交道而 Electric 的同步细节被封装在后端——这正是「复用现有 API」这一写路径思路的体现。POST /todos、PUT /todos/:id、DELETE /todos/:id使用zod校验输入如idSchema z.string().uuid()再执行对应的 SQLcreateTodo/updateTodo/deleteTodo。注意createTodo与updateTodo都会把请求体里的write_id可选一并写入数据库供复制流匹配用。POST /changes专门为模式四设计接收按事务分组的变更列表transactionsSchema校验在单个 Postgres 事务内逐个应用 insert/update/delete成功COMMIT、失败ROLLBACK。2.3 弹性 API 客户端3 分钟退避重试shared/app/client.ts 封装了所有写路径共用的请求逻辑其「弹性」体现在resilientFetch/retryFetch遇到网络错误时最多重试maxRetries 32次退避延迟按retryCount * backoffMultiplier(1.1) * initialDelayMs(1000)增长即延迟从 1 秒缓慢增长到 20 秒总重试时长约 3 分钟。这为离线期间的写入提供了一个基本的“尽力而为”保障源码注释还提示若想对 4xx/5xx 也做重试可在返回前检查 status。配置方面shared/app/config.ts 定义API_URL默认http://localhost:3001可用VITE_SERVER_URL覆盖与TODOS_URL ${API_URL}/todos所有模式通过useShape({ url: TODOS_URL, ... })消费 shape 流。三、模式一在线写入Online writes代码位于 patterns/1-online-writes/index.tsx。这是最朴素也最简单的方案读路径用 Electric写路径直接走 HTTP API仅在线可用。3.1 实现要点组件用useShape订阅 Postgres 中的todos数据const { isLoading, data } useShapeTodo({ url: TODOS_URL, parser: { timestamptz: (value: string) new Date(value), }, }) const todos data ? data.sort((a, b) a.created_at - b.created_at) : []注意parser.timestamptz把 Electric 流中timestamptz类型的字符串解析成Date——四个模式的useShape配置完全一致。用户事件的处理函数直接await api.request(...)例如创建async 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 await api.request(/todos, POST, { id: uuidv4(), title: title, created_at: new Date(), }) form.reset() }更新与删除同样是对/todos/:id发起PUT/DELETE。在请求完成前UI 不会出现新数据客户端有 3 分钟退避重试但用户界面上仍然要等写入成功。3.2 收益与代价子文档 1-online-writes/README.md 明确给出了它的定位收益实现非常简单可以直接复用你已有的 API 体系阅读数据可以很快、且离线可读因为读路径由 Electric 同步缓存支撑。适用场景实时仪表盘、数据分析和可视化在云端生成 embedding 的 AI 应用以及写操作本质上就要求在线的系统比如支付。代价写路径上有网络往返——慢、有延迟、伴随 loading 转圈交互型应用若要求离线可用必须升级到带本地乐观状态的方案模式二。仓库中的 Phoenix LiveView 示例即 examples/phoenix-liveview/README.md也实现了同一种模式用 Electric 把数据流式送进 LiveView 客户端写入则走普通 Phoenix API——如果你主要使用 Elixir 生态可以对照参考。四、模式二乐观状态Optimistic state代码位于 patterns/2-optimistic-state/index.tsx。它在模式一的基础上用 React 19 内置的useOptimistic钩子把网络移出写路径写入立即显示等 API 成功、再等数据通过 Electric 同步回来之后才丢弃本地临时状态。4.1 实现要点组件额外从useShape返回值里取出streamShapeStream 实例用于监听自己的写入何时从服务器同步回来const { isLoading, data, stream } useShapeTodo({ ... })乐观状态通过useOptimistic定义——第一个参数是「真实」数据来自同步的sorted第二个参数是 reducer把一次本地写操作合并进列表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) } } )写入处理函数把api.request与「等待同步」的matchStream放进同一个startTransitionstartTransition(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]) })这里matchStream(stream, [operation], matchBy(id, id))来自electric-sql/experimental它的作用是把 Promise 挂到 shape 流上直到出现匹配该id的对应操作insert/update/delete才 resolve。注释特别强调本地乐观状态的生命周期覆盖两个阶段——(1) HTTP 请求进行中(2) 直到写入经由 Electric shape 流同步回来。这与大多数只等 API 返回的乐观更新示例不同是本地优先架构下需要额外注意的一点数据必须「走完一圈」本地 → API → Postgres → Electric → 本地才算最终一致。4.2 收益与代价子文档 2-optimistic-state/README.md 的结论收益实现简单可复用现有 API网络被移出写路径应用读写都能离线、响应快速、无 loading 转圈。适用场景管理类应用与交互式仪表盘追求「快」、想避免写入转圈的应用网络状况不稳定场景下的移动应用。代价两个关键局限乐观状态只存在于发起写入的那个组件内部其他渲染同一份数据的组件看不到它可能显示陈旧数据乐观状态不持久化组件卸载或页面刷新即丢失。这两点正是模式三要解决的问题。五、模式三共享持久化乐观状态Shared persistent optimistic state代码位于 patterns/3-shared-persistent/index.tsx。它用valtio 的可变响应式 store承载乐观状态并在任何变更时持久化到 localStorage从而让所有组件都能看到、并在页面刷新后仍保留本地写入。5.1 实现要点共享 store 与持久化模块顶层定义了共享的optimisticState一个proxyMap初始化时从 localStorage 恢复任何变更都写回const 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])) })addLocalWrite为每次写入生成uuidv4()作为LocalWrite.id把{ id, operation, value }存入 storeuseSnapshot让组件响应式读取本地写入集合再通过computeOptimisticStatereducer结构与模式二的useOptimisticreducer 几乎一致把同步数据与本地写入合并后渲染。5.2 实现要点以write_id匹配的 rebase 逻辑matchWrite是模式三最值得细读的函数子文档称其为merge logicasync function matchWrite(stream, write): Promisevoid { const { operation, value } write const matchFn operation delete ? matchBy(id, value.id) : matchBy(write_id, write.id) try { await matchStream(stream, [operation], matchFn) } catch (_err) { return } optimisticState.delete(write.id) }关键点3-shared-persistent/README.md 的 Implementation notesinsert 和 update 用write_id匹配而不是id。请求发送时sendRequest会把write_id: id放进请求体见下文服务端写入 Postgres 的write_id列当该行经 Electric 同步回来时matchStream用write_id精确匹配到属于自己的那次写入。这样其他用户对同一行的并发更新不会清除你的乐观状态本地状态可以被 rebase 到最新数据上。delete 仍然按id匹配因为删除操作无法更新write_id列。若想支持「可回退的并发删除」注释建议改用软删除——它本质上是 update。sendRequest把本地写入发往 API并在失败/非 2xx 时删除对应的乐观状态条目async function sendRequest(path, method, { id, value }) { 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) } }写入流程与模式二对称addLocalWrite→Promise.all([sendRequest(...), matchWrite(...)])两个 Promise 都完成后matchWrite内部已把该条目从共享 store 删除乐观状态自然消失。5.3 收益与代价子文档对模式三的评价是在设计空间中占据了一个很有说服力的位置——提供良好的 UX 和 DX却不引入太多复杂度或重型依赖。收益实现相对简单乐观状态持久化让离线写入更有韧性乐观状态放在共享 store 中所有组件都能看到并响应避免了模式二「组件各自为政、显示陈旧数据」的弱点更适合复杂真实应用把「不可变的同步状态」与「可变的本地状态」分离让回滚策略容易推理与实现——回滚入口同时拥有本地写入上下文和共享 store可以做比较精准的surgical回滚。适用场景构建本地优先软件交互式 SaaS 应用协作与创作类软件。代价在读取时合并数据on-read本地读取会略微变慢写入仍需经过 API。这通常是有益且务实的可复用现有 API但如果你希望完全不运行 API、追求更纯粹的本地优先则应考虑模式四。六、模式四通过数据库同步Through-the-database sync代码位于 patterns/4-through-the-db/包含 index.tsx、db.ts、local-schema.sql 与 sync.ts。它把「共享、持久的乐观状态」这一思路推进到底本地嵌入一个 PGlite 数据库应用代码直接读写一个统一视图后台自动检测变更并同步到服务器。子文档 4-through-the-db/README.md 总结的六步流程读路径read path把 Electric 同步的数据放进不可变表todos_synced把本地乐观状态持久化在影子表todos_local用一个视图todos把两者合并提供统一的读写接口。写路径write path 4.自动检测本地写入 5. 写入一张变更日志表changes 6. 把变更POST给 API 服务器。6.1 组件层直接对本地数据库执行 SQLindex.tsx 中Wrapper负责初始化 PGliteloadPGlite()、启动ChangeLogSynchronizerwritePathSync.start()并用PGliteProvider把数据库注入组件树卸载时调用stop()。业务组件ThroughTheDB用useLiveQuery查询视图写入则是纯 SQL例如创建await db.sql INSERT INTO todos ( id, title, completed, created_at ) VALUES ( ${uuidv4()}, ${title}, ${false}, ${new Date()} ) 更新、删除同样是对todos视图执行UPDATE/DELETE。可以看到应用代码里完全没有fetch、没有 API 调用、没有乐观合并逻辑——数据读取被useLiveQuery抽象数据发送被 ChangeLog 机制抽象。这是它最接近「纯本地优先开发体验」的地方。6.2 本地 schema视图 三类触发器local-schema.sql 完整定义了本地数据库结构值得逐个拆解两张表 一个视图-- 不可变的同步状态 CREATE TABLE IF NOT EXISTS todos_synced ( id UUID PRIMARY KEY, title TEXT NOT NULL, completed BOOLEAN NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL, write_id UUID -- 记账列 ); -- 本地乐观状态影子表 CREATE TABLE IF NOT EXISTS todos_local ( id UUID PRIMARY KEY, title TEXT, completed BOOLEAN, created_at TIMESTAMP WITH TIME ZONE, changed_columns TEXT[], -- 哪些列被本地改过 is_deleted BOOLEAN NOT NULL DEFAULT FALSE, write_id UUID NOT NULL ); -- 统一读写视图 CREATE OR REPLACE VIEW todos AS SELECT COALESCE(local.id, synced.id) AS id, CASE WHEN title ANY(local.changed_columns) THEN local.title ELSE synced.title END AS title, CASE WHEN completed ANY(local.changed_columns) THEN local.completed ELSE synced.completed END AS completed, CASE WHEN created_at ANY(local.changed_columns) THEN local.created_at ELSE synced.created_at END AS created_at FROM todos_synced AS synced FULL OUTER JOIN todos_local AS local ON synced.id local.id WHERE local.id IS NULL OR local.is_deleted FALSE;视图用FULL OUTER JOIN合并两表并通过changed_columns决定每列取本地值还是同步值is_deleted过滤软删除。这实现了「读取时合并」的全部逻辑且对上层应用透明。同步状态清理触发器把「共享乐观状态」模式中的matchWrite下沉到了数据库层面CREATE OR REPLACE FUNCTION delete_local_on_synced_insert_and_update_trigger() RETURNS TRIGGER AS $$ BEGIN DELETE FROM todos_local WHERE id NEW.id AND write_id IS NOT NULL AND write_id NEW.write_id; -- 按 write_id 匹配允许 rebase RETURN NEW; END; $$ LANGUAGE plpgsql;AFTER INSERT OR UPDATE ON todos_synced时触发delete_local_on_synced_delete_trigger则直接按id删除本地行删除不可并发回退匹配id是安全的若要可回退并发删除仍建议软删除。这组触发器与模式三的matchWrite逻辑一一对应当服务端写入同步进todos_synced时自动清除对应的本地乐观状态。写入捕获触发器INSTEAD OF让应用可以「直接写视图」todos_insert_trigger检查id是否已存在于同步表/本地表冲突则RAISE EXCEPTION否则写入todos_local并记录 insert 变更到changestodos_update_trigger对比NEW与todos_synced只把真正变化的列标记进changed_columns首次更新插入本地行后续更新则合并列集并记录 update 变更todos_delete_trigger对todos_local做软删除is_deleted TRUE并记录 delete 变更。每次写入变更都会插入changes表并携带transaction_idpg_current_xact_id()与本地生成的write_id。变更日志表定义为CREATE TABLE IF NOT EXISTS changes ( id BIGSERIAL PRIMARY KEY, operation TEXT NOT NULL, value JSONB NOT NULL, write_id UUID NOT NULL, transaction_id XID8 NOT NULL );最后changes_notify_trigger在每次AFTER INSERT ON changes时执行NOTIFY changes——这是本地写路径的“事件源”驱动后台同步。6.3 后台同步器ChangeLogSynchronizersync.ts 实现了一个最小但完整的同步器用于说明「监听changes并 POST 给 API」的模式start()通过db.listen(changes, handler)对应 PGlite 的listenAPI订阅通知并立即启动首轮process()handle()若正在处理则置位hasChangedWhileProcessing否则直接process()——避免通知丢失query()按id #position拉取尚未同步的变更send()把变更按transaction_id分组Object.groupBy并按事务 id 排序构造{ id: transaction_id, changes: [...] }数组 POST 到/changes返回三种结果之一accepted/rejected/retry网络错误或 5xx 为retry其他 4xx 为rejectedproceed()服务端接受后删除id position的已处理变更并推进游标rollback()子文档特别提醒这里的回滚策略非常朴素——只要有任何写入被服务端拒绝就清空全部changes与todos_local。更精细的做法应只清除与失败写入存在因果依赖的本地状态并向用户说明情况stop()置shouldContinue false、中止进行中的请求并取消订阅。组件中一次离线创建的完整数据流为INSERT INTO todos视图→INSTEAD OF触发器写入todos_local 插入changes→NOTIFY changes→ChangeLogSynchronizer拉取 → 分组 POST/changes→ 服务端事务写入 Postgres → Electric 复制回本地todos_synced→ 清理触发器按write_id删除本地乐观状态 → 视图回归纯同步数据。6.4 收益与代价收益子文档原文要点完整的离线支持、共享乐观状态组件只与本地数据库交互无需任何网络编码数据收发被完全抽象读由 Electric 同步、写由变更消息日志处理。适用场景构建本地优先软件移动与桌面应用协作与创作类软件。代价本地嵌入式数据库是一个相对重的依赖影子表与触发器机制使客户端 schema 定义变复杂后台同步让回滚处理变复杂模式三能在处理用户输入时、上下文仍在的情况下发现写入被拒绝而「通过数据库同步」时这个上下文很难重建。文档因此建议如果这条路径的复杂度超出你的承受范围可以考虑使用现有的本地优先框架详见 Electric 官方 Writes 指南的 tools 一节。七、如何运行与验证主文档 README 给出了标准启动流程。需要在仓库根目录先安装依赖并构建全部包pnpm install pnpm run -r build然后在示例目录启动 Docker 后端会拉起 Postgres 与 Electric 同步服务并自动执行db:migrate应用上述两份迁移pnpm backend:up启动开发服务器package.json 中的dev脚本用concurrently同时运行 Vite 与node shared/backend/api.jspnpm dev结束后拆除后端容器pnpm backend:down运行后打开页面即可看到四个模式组件并排运行。想验证离线行为可以在浏览器 DevTools 的 Network 面板把网络切换为 Offline如示例截图所示然后观察模式一写入会一直等待模式二在组件内立即显示模式三即使在刷新页面后本地写入仍然保留localStorage模式四则完全由本地 PGlite 接管恢复网络后后台自动补齐同步。仓库还提供 playwright.config.ts 与 Dockerfile可供端到端验证与容器化部署参考。八、四种模式对比与选型建议维度1. Online writes2. Optimistic state3. Shared persistent4. Through the DB写路径HTTP API在线API 组件内乐观状态API 共享持久化乐观状态本地 PGlite ChangeLog 后台同步离线写入不支持等待重试支持不持久支持持久支持持久多组件一致天然一致不一致陈旧一致一致页面刷新保留—丢失保留localStorage保留PGlite代码复杂度最低低中高嵌入式 DB 触发器 schema回滚能力简单简单较精准困难建议用更精细策略典型场景仪表盘、AI 云端生成、支付类追求响应速度的交互应用、移动端本地优先 SaaS、协作创作纯本地优先、移动/桌面应用从源码结构与各子文档的结论可以看出一条清晰的演进主线把网络从写路径上逐步移除。模式一把全部信任放在在线 API 上模式二用useOptimistic把「等待」从用户感知中抹掉模式三用共享 持久化解决了组件一致性与刷新丢失问题代价仅是引入一个轻量状态库模式四则用嵌入式数据库把「本地优先」贯彻到底——应用只跟一个数据库视图打交道但复杂度也转移到本地 schema、触发器和后台同步上。最终选型建议能用模式三解决就用模式三文档称之为设计空间中 UX/DX 的甜点只有当你确定需要去掉 API、纯本地优先的移动/桌面体验时才投入模式四并务必为回滚设计更细致的策略。【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表