Electric 写路径模式(二):基于 React useOptimistic 的乐观状态写入实现指南
2026/9/15 21:20:47 网站建设 项目流程

Electric 写路径模式(二):基于 React useOptimistic 的乐观状态写入实现指南

【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric

导读

本文深入讲解examples/write-patterns示例中的第二种写路径模式——乐观状态(Optimistic state)。该模式使用 Electric 完成读取路径的数据同步,同时利用 React 19 内置的useOptimisticHook 在本地先行渲染写入结果,让应用在离线或弱网环境下依然能"立即响应用户操作"。读完本文,你将掌握如何把useShapeuseOptimisticuseTransitionmatchStream/matchBy组合起来,构建"写入先渲染、后台重试、同步成功后自动收敛"的本地优先交互体验,并理解该模式的适用场景与固有局限。

一、模式概述:把网络从写路径中移除

乐观状态模式是 write patterns 示例 中第二个写路径实现,其完整代码位于 patterns/2-optimistic-state/index.tsx。它的核心思路是:

  • 读取路径交给 Electric:通过 Electric 将 Postgres 中的todos表同步到本地应用;
  • 写入路径做本地乐观更新:用户触发写入时,先把临时的乐观状态合并进待渲染的待办列表,界面立刻反映操作结果,同时后台将写入发送到既有 API;
  • 离线自动重试:如果应用或 API 处于离线状态,写入请求会按照退避算法(backoff algorithm)持续重试,待网络恢复后最终成功;
  • 同步成功自动收敛:写入真正落库后,通过 Electric 的 shape stream 同步回应用,此时本地乐观状态被自动丢弃,UI 以服务端数据为准。

这种设计允许你继续使用已有的 REST API 作为写入通道(不需要引入新的基础设施),同时借助 Electric 的增量同步把最终结果"回流"到界面,从而把网络从写路径中移除——用户不再需要等待 HTTP 请求完成才能看到自己的操作生效。

从项目结构看,本模式复用了1-online-writes(在线写入)模式的全部基础代码,仅在其上增加了乐观状态层。两者的事件处理函数几乎一致,差异在于乐观状态模式额外通过startTransition包装写入流程,并调用addOptimisticState应用本地状态。

二、适用场景与收益

原文档明确列出了该模式的三大收益,并结合示例代码可以得到更具体的印证:

  1. 实现简单:相比后续更复杂的模式(共享持久化乐观状态、直写本地数据库),它只依赖 React 内置能力(useOptimisticuseTransition),无需引入额外依赖,改动面小。
  2. 可以复用既有 API:写入仍然通过你已有的后端接口(见 api.js 中的POST /todosPUT /todos/:idDELETE /todos/:id),不必为本地优先改造后端协议。
  3. 网络移出写路径,读写皆可离线:界面不再等待网络往返,应用在网络抖动或离线时仍能流畅完成读写交互;写入请求由带退避重试的客户端在后台完成。

适合该模式的典型场景包括:

  • 管理类应用与交互式仪表盘:对响应速度敏感,不希望每次操作都出现 loading 转圈;
  • 追求"手感快"的应用:希望在写入时不显示加载指示器,让界面反馈即时可见;
  • 移动端应用:需要抵抗不稳定的网络连接,在弱网或断网环境下依然可用。

三、局限与权衡:乐观状态的作用域与生命周期

原文档同样清楚地指出了该模式的两点固有局限,这两点都源于 ReactuseOptimistic的语义:

  1. 作用域限定在发起写入的组件内:乐观状态只存在于执行写入的那个组件中。其他组件虽然渲染的是同一条数据(同样来自 Electric 同步流),却看不到这份临时状态,可能继续显示旧数据,造成界面不一致。
  2. 状态不持久化:乐观状态仅存于内存,一旦组件卸载或页面刷新,尚未落库的乐观修改就会丢失。

这两点限制正是下一个模式——共享持久化乐观状态模式——要解决的问题:它把乐观状态提升到共享、持久的本地存储中,使离线写入更健壮,并避免多个组件之间数据不同步。如果你的应用存在跨组件渲染同一数据、或需要长时间离线写入的场景,应当优先评估该升级方案(再进一步则是 直写本地数据库模式)。

四、如何运行

本模式包含在examples/write-patterns示例工程中,与另外三种模式作为同一个 React 页面中的组件同时运行,便于横向对比行为差异。运行方式见示例 README 的 How to run 章节,步骤如下:

先在 monorepo 根目录安装依赖并构建所有包:

pnpm install pnpm run -r build

然后在examples/write-patterns目录启动后端容器(Postgres 与 Electric):

pnpm backend:up

backend:up脚本实际会调用仓库根目录的example-backend:up,并随后执行db:migrate应用shared/migrations下的迁移(见 package.json)。

启动开发服务器(Vite 前端 + Express API 后端并行):

pnpm dev

dev脚本通过concurrently同时启动 Vite 和node shared/backend/api.js(API 默认监听http://localhost:3001)。结束后关闭后端容器:

pnpm backend:down

五、核心实现拆解

下面逐段分析 index.tsx 的实现,理解各部件如何协作。

5.1 数据模型与写入操作类型

type Todo = { id: string title: string completed: boolean created_at: Date } type PartialTodo = Partial<Todo> & { id: string } type Write = { operation: `insert` | `update` | `delete` value: PartialTodo }

Todo与数据库迁移 01-create-todos.sql 中的表结构一一对应(id UUIDtitle TEXTcompleted BOOLEANcreated_at TIMESTAMPTZ)。Write类型把乐观状态抽象为"操作 + 值"的联合形态,供useOptimistic的更新函数消费。

5.2 读取路径:useShape 同步 Postgres 数据

const { isLoading, data, stream } = useShape<Todo>({ url: TODOS_URL, parser: { timestamptz: (value: string) => new Date(value), }, })

useShape来自@electric-sql/react,从TODOS_URL(定义于 shared/app/config.ts,默认http://localhost:3001/todos)拉取并订阅 shape。值得注意的两点:

  • parser.timestamptz把数据库返回的时间戳字符串解析为Date对象,保证后续按created_at排序正确;
  • 解构出的stream是整个模式的关键——它用于在下方检测"本机写入是否已经通过 Electric 同步回来"。

随后数据按created_at升序排序,得到服务端基准列表sorted

5.3 乐观层:useOptimistic 合并本地写入

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接收两个参数:当前"真实"状态(即 Electric 同步来的sorted)和一个更新函数。当调用addOptimisticState(write)时,React 会临时把write合并进渲染结果,但不修改底层真实状态

  • insert:先去重(按id判断是否已存在),不存在才追加;
  • update:按id找到目标行,浅合并value中的字段(如翻转completed);
  • delete:按id过滤掉目标行。

由于useOptimistic每次都以最新的同步数据为基准重新应用乐观状态,当 Electric 的数据流更新时,乐观层会自动收敛。

5.4 写入流程:startTransition + 双 Promise 等待

以创建待办为例:

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 const path = `/todos` const data = { id: uuidv4(), title: title, created_at: new Date(), completed: false, } 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]) }) form.reset() }

这里体现了本模式与大多数乐观更新示例的关键差异

  1. addOptimisticState立即应用本地乐观状态,界面瞬间出现新待办;
  2. fetchPromise向既有 API 发送POST /todos(携带uuidv4()生成的客户端id);
  3. syncPromise通过matchStream等待 Electric shape stream 中出现对应这次写入insert变更消息;
  4. await Promise.all([...])意味着只有 HTTP 请求完成且数据经 Electric 同步回流之后,整个 transition 才算结束。乐观状态的生命周期因此覆盖"请求进行中"到"同步回流完成"两个阶段。

updatedelete的处理逻辑完全同构,仅操作符不同(PUT /todos/:idupdate流匹配、DELETE /todos/:iddelete流匹配)。界面上isPending指示器(来自useTransition)在 transition 期间点亮,代表写入尚未完全同步。

5.5 底层的流匹配工具:matchStream 与 matchBy

matchStreammatchBy来自@electric-sql/experimental包(源码见 packages/experimental/src/match.ts)。其工作原理是:

  • matchStream(stream, operations, matchFn, timeout = 60000)订阅 shape stream,持续过滤变更消息(isChangeMessage判定),直到找到一条操作类型匹配(在operations数组中)且匹配函数返回 true的消息;一旦命中即取消订阅并 resolve,默认 60 秒超时则 reject;
  • matchBy(column, value)返回一个匹配函数,判断消息中的value[column] === value

在本例中,matchBy('id', data.id)确保我们等待的是"自己发起的这条写入"从服务端同步回来,而不是其他用户的并发修改。这为后续模式中"按操作级更新键匹配以精确作废本地状态"(见 02-add-write-id.sql 的注释)奠定了设计基础——注释中明确指出:按write_id而非单纯按行id匹配,可以在其他用户并发修改同一行时,只在你自己的写入同步通过时才清除本地状态,从而实现乐观状态的重基(rebase)。

六、离线重试与数据回流链路

6.1 带退避算法的弹性请求客户端

本模式的离线重试能力来自共享的 client.ts。它实现了指数级增长的退避重试:

// 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 async function retryFetch(url, options, retryCount) { if (retryCount > maxRetries) return const delay = retryCount * backoffMultiplier * initialDelayMs return await new Promise((resolve) => { setTimeout(async () => { resolve(await resilientFetch(url, options, retryCount)) }, delay) }) }
  • 最多重试 32 次,总时长约 3 分钟;
  • 每次重试延迟从 1 秒起、以 1.1 倍系数递增;
  • 只有网络错误(fetch抛异常)才触发重试,注释提示如需对 4xx/5xx 也做弹性可自行扩展。

这意味着当 API 离线时,fetchPromise会在后台持续等待并重试,而界面早已通过乐观状态完成了即时反馈——这正是"把网络移出写路径"的落地实现。

6.2 API 服务器与 Electric shape 代理

写入请求到达的 api.js 是一个 Express 服务,它承担两类职责:

  1. 写接口POST /todos(经zodcreateSchema校验id/title/created_at/write_id后插入)、PUT /todos/:id(更新completed)、DELETE /todos/:id,均直接写 Postgres;
  2. 读接口代理GET /todos将请求转发到 Electric 的/v1/shape端点,只透传 Electric 协议参数(ELECTRIC_PROTOCOL_QUERY_PARAMS),服务端固定设置table=todos,并在配置了ELECTRIC_SOURCE_ID/ELECTRIC_SOURCE_SECRET时附加源认证参数,最后把 Web Stream 转为 Node 流回传给前端。

所以前端useShape订阅的TODOS_URL实际是一个"代理后的 shape 端点":客户端每次通过 API 写入的数据,会由 Postgres → Electric 的变更数据捕获流转发到该 shape,matchStream正是借助这一回流链路判断"写入已成功同步",随后 React 会以新到的服务端数据重算sorteduseOptimistic的乐观层随之被覆盖并丢弃,界面自动收敛。

七、模式演进定位

在 write patterns 示例 中,四种模式按"能力与复杂度递增"排列:

模式写路径策略乐观状态持久化
1. 在线写入直接调 API,失败重试无(写入成功后才更新 UI)
2. 乐观状态(本文)调 API + 本地乐观渲染组件内、内存态
3. 共享持久化乐观状态共享本地存储 + 后台同步跨组件共享
4. 直写本地数据库本地嵌入式数据库 + 影子表 + 视图数据库级

本模式位于"极简在线写入"与"共享持久化"之间,用最小的复杂度换取即时的写入反馈,是权衡 UX(用户体验)、DX(开发体验)与实现成本时非常务实的一个落点;当你发现组件间数据不同步或页面刷新丢失写入成为痛点时,再沿链路升级到后续模式即可。另外,写路径的完整演进思路也体现在数据库迁移 02-add-write-id.sql 中——它提前为高级模式预留了write_id列,用于在并发场景下精确匹配"自己的写入",本文模式虽未强制使用该字段,但其matchBy('id', ...)的匹配思路正是该演进方向的雏形。

【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询