Back

渐进式Web应用的本地优先架构

渐进式Web应用的本地优先架构

Service Worker将应用迁移到用户设备;本地优先则将数据迁移到用户设备。这一根本区别解释了为什么你的PWA外壳能在离线状态下即时加载,而每个列表、记录和表单却仍在等待一个fetch()请求——一旦网络断开,这些请求便会立即失败。外壳已被缓存,数据却没有。它们存在于不同的层级,而大多数PWA所谓的”离线支持”,正是悄然崩溃于这两层之间的鸿沟。

本文面向已经上线过PWA的开发者——manifest配置有效、Service Worker已缓存资源、应用可安装到主屏幕——他们隐约感觉”本地优先”的含义不止于已有的离线优先工作。确实如此。本地优先是一种数据架构,而非运行时机制。本文将清晰地区分这些层级,展示本地优先PWA作为一个可扩展技术栈的形态(而非需要推倒重建的方案),梳理存储与同步的各类选项,并提供一份针对PWA场景的实用指南,帮助你判断何时值得引入这一模式的复杂性。

核心要点

  • Service Worker负责缓存资源并提供离线外壳;本地优先是一种数据架构,其中设备持有数据的主副本,服务器(如存在)作为同步对等节点而非数据守门人。
  • 本地优先PWA由三个可组合的层级构成:Service Worker(资源与离线外壳)、本地数据库(数据读写)以及同步引擎(与服务器的最终一致性)——你在不替换第一层的基础上,叠加第二层和第三层。
  • IndexedDB是本地数据存储的实用下限;编译为WebAssembly并以OPFS为持久化后端的SQLite是当前的上限,可在浏览器中提供完整的关系型数据库能力。
  • 字段级最后写入胜出(last-write-wins)可解决典型应用中的大多数冲突;协作文本编辑需要CRDT,而语义冲突(例如两个用户预订同一会议室)则需要在同步期间进行服务端验证。
  • 本地优先适用于可安装的笔记应用、现场服务应用和协作编辑PWA;对于服务端生成数据的仪表盘,或依赖ACID保证的场景,本地优先并无助益。

为何你的PWA数据仍然依赖网络

一个拥有缓存外壳但数据层依赖fetch()的PWA,其离线能力仅停留在最狭义的层面——界面框架无需网络即可加载,但随后每个需要数据的组件都会陷入停滞。Service Worker可以从Cache API中重放HTML、CSS和JavaScript而无需访问网络,因此应用能够在离线状态下渲染。但这些组件所展示的数据仍来自网络请求,而当动态用户数据从未被缓存、或自上次缓存后已发生变化时,拦截fetch()ServiceWorker便无从返回任何有效内容。

这正是离线优先PWA工作止步于外壳层的结构性原因。缓存策略——缓存优先、网络优先、stale-while-revalidate——回答的是提供哪个版本的资源以及可接受多大程度的过期,而非用户数据存放在哪里、谁持有权威副本。Cache API以请求为键存储HTTP响应,它不是一个可供组件查询、写入和读取的数据库。要弥合这一差距,你需要一个真正的本地数据存储,以及一套保持其与服务器一致的策略。这就是本地优先。

本地优先不等于离线优先,两者也都不等于Service Worker

本地优先、离线优先、PWA和Service Worker缓存是四个相互独立、可以组合但不可互换的概念。混淆它们是这一领域最常见的误解。

术语含义所在层级
PWA一种交付模型:可安装、基于manifest、支持推送通知的Web应用打包层
Service Worker一种运行时机制,拦截请求并提供缓存资源网络/运行时层
离线优先一种设计目标:在网络不可用时优雅降级,服务器仍为数据的权威来源行为层
本地优先一种数据架构:设备持有主副本,服务器作为同步对等节点数据层

离线优先意味着应用能够优雅地处理网络中断,但服务器仍是数据的权威来源——本地缓存只是一份便利副本,最终以远端为准。本地优先则颠覆了这一关系:用户设备持有数据的主副本,应用对本地数据库进行读写,而服务器(如存在)只是在后台参与协调的众多节点之一。这一清晰的区分至关重要,因为它告诉你哪些东西可以复用。你的Service Worker和框架无需改动,你只是在其下方新增一个数据层。

Plainvanilla的本地优先演示将前端后端(backend-for-frontend)作为架构的一部分内嵌到Service Worker中。这是一种可行的实现方式,而非定义本身。请保持各层的独立性:本地优先关注的是数据存放的位置以及哪份副本具有权威性;Service Worker只是恰好在同一运行时中可用的一种机制。

本地优先的真正含义

本地优先是一种数据架构:用户设备持有应用数据的主副本,应用对本地数据库进行读写,而服务器(如存在)是具有特殊权威的同步对等节点,而非必须审批每次读写的守门人。从用户视角来看,对本地数据库的读写是同步完成的,与服务器或其他设备的同步则在后台异步进行。这一架构转变重新定义了客户端的角色:它不再是一个需要申请权限才能展示数据的薄视图层,而是成为持有完整副本的全权参与者。

权威参考资料是Ink & Switch于2019年发表的文章Local-First Software: You Own Your Data, in Spite of the Cloud,其中阐述了七项理想特性——快速、多设备、离线、协作、长寿命、私密以及用户可控。其中改变编码方式的核心特性是第一条:由于读写均针对本地数据库,读操作无需isLoading标志,写操作可立即更新本地状态。本地写入即是状态,同步与冲突解决则在后台进行。

在组件代码中,这将繁琐的fetch-and-cache流程简化为直接查询。与其使用返回{ data, isLoading, error }useQuery,本地优先查询直接订阅本地数据库,并在数据变化时触发重新渲染:

// 本地优先:查询读取本地数据库,并在数据变化时重新渲染。
// 无需 isLoading,无需读取错误边界,无需缓存失效处理。
function TaskList({ projectId }) {
  const tasks = useLiveTasks(projectId); // 订阅本地数据库
  return (
    <ul>
      {tasks.map((t) => (
        <li key={t.id}>{t.title}</li>
      ))}
    </ul>
  );
}

useLiveTasks hook由你所选择的同步引擎或查询层提供;重要的是其形态。写操作是对本地存储的普通插入,订阅随即触发,UI随之更新。向服务器的同步则在网络允许时随时进行。

本地优先PWA的三层架构

本地优先PWA由三个可组合的层级构成:缓存资源并提供离线外壳的Service Worker、持有用户数据并处理所有读写操作的本地数据库(IndexedDB或以OPFS为后端的SQLite),以及在后台将本地数据库与服务器进行协调的同步引擎。每一层各司其职,对其他层的内部实现一无所知。

层级职责负责范围不负责范围
第三层 — 同步引擎将本地数据库与服务器进行协调。冲突解决、最终一致性、后台推送/拉取。渲染、资源缓存。
第二层 — 本地数据库持有用户数据。IndexedDB或以OPFS为后端的SQLite(WASM)。UI执行的所有读写操作。网络、离线外壳。
第一层 — Service Worker缓存资源并提供离线外壳。资源缓存、离线外壳服务、install/activate生命周期、推送通知。用户数据。

在现有PWA中引入本地优先,并不需要替换Service Worker或应用框架——而是需要添加一个数据层,供应用在本地进行读写,并配备一个同步引擎来保持该层与服务器的一致性。第一层在你的PWA中已经存在。你要做的是在当前调用fetch()的组件下方引入第二层和第三层,并将这些组件改为从本地数据库读取数据。

数据存放在哪里:本地存储入门

localStorage并非答案。它是同步的,会阻塞主线程;只能存储字符串;根据MDN Web Storage API文档,其容量较小且因浏览器而异——用于存储主题偏好设置尚可,作为数据层则完全不适用。

IndexedDB是实用的下限——异步、在所有现代浏览器中均可用,存储容量远超localStorage,尽管存储配额基于源(origin)且因浏览器而异,并非固定上限。其原生API较为底层且冗长——单次写操作需要开启事务、指定对象存储,并连接请求回调——这也是为什么大多数应用会选择封装库的原因。

上限则是编译为WebAssembly并持久化到源私有文件系统(OPFS)的SQLite。以OPFS为后端的SQLite在浏览器中提供了完整的关系型数据库——事务、索引,以及在客户端本地运行的熟悉SQL接口。OPFS通过createSyncAccessHandle()支持高性能同步文件访问,该方法仅在Web Worker内部可用——这正是SQLite所需的访问模式。官方SQLite WebAssembly发行版将OPFS列为支持的持久化后端。

在代码层面,通过SQLite-over-WASM执行写操作与服务端SQL别无二致——对关系型存储执行一条SQL语句:

// 通过官方 sqlite-wasm 包使用 SQLite(WASM):标准SQL语法。
await db.exec({
  sql: "INSERT INTO tasks (id, title, done) VALUES (?, ?, 0)",
  bind: [crypto.randomUUID(), "Review draft"],
});

截至2025年底,OPFS的浏览器支持已相当广泛——请参阅MDN文件系统API兼容性表格获取最新的各浏览器支持状态,这是对照你的支持矩阵时应参考的权威来源,而非固定的版本列表,因为它会持续更新。一个常见的生产环境陷阱是:OPFS在不同浏览器引擎和嵌入环境中的行为可能存在细微差异,因此对于OPFS不可用、受限或与主目标平台行为不一致的浏览器和环境,保留一条基于IndexedDB的降级路径仍然值得。

同步引擎全景

同步引擎是使本地优先超越离线优先的关键层级:它负责将本地副本与服务器及其他设备进行协调。你无需自行构建。目前已有多款面向不同场景的生产级和新兴引擎,选择哪款取决于你的应用形态和现有后端。由于Web领域尚无同步标准,每款引擎都定义了自己的协议——请保持同步层的抽象性,以便在必要时能够切换引擎。

  • PowerSync — 将Postgres后端的数据复制到客户端SQLite数据库,并提供回写路径;非常适合已运行Postgres且希望在不重构服务端的情况下添加离线支持的场景。
  • Electric — 基于”Shapes”概念构建的Postgres同步引擎,用于定义每个客户端接收哪些数据。非常适合需要在现有Postgres后端之上实现部分复制和按用户数据同步的应用。
  • ReplicacheZero(均来自Rocicorp)— 以查询驱动的同步系统,优先保障本地优先的用户体验,而非行级复制。非常适合围绕客户端变更、乐观更新和服务端协调构建的应用。
  • Triplit — 内置同步能力的全栈数据库,客户端与服务端数据库共享同一套心智模型而非两套;适合希望将同步作为默认能力的全新项目。
  • YjsAutomerge — 面向协作编辑的CRDT库;当多用户需要并发编辑同一富文本或结构化文档,且需要字符级合并时,这是正确的选择。

对于大多数需要同步用户自有记录的业务线PWA,基于现有数据库的行复制引擎比CRDT库更为合适。仅当实时协作文本编辑本身是核心产品功能时,才应选用Yjs或Automerge,而不应将其作为通用的冲突解决方案。

冲突解决

当两个副本在未感知彼此变更的情况下修改了同一数据,同步引擎必须对其进行协调。冲突可分为三类,每类对应不同的解决策略。

  1. 结构性冲突——字段级最后写入胜出。 字段级最后写入胜出(last-write-wins)可处理典型应用中的大多数冲突:若两个用户在离线状态下编辑了同一记录的不同字段,两处变更均会保留;若编辑了同一字段,则时间戳较晚的版本胜出。这是本地优先社区广泛采用的经验法则,而非经过精确测量的定论——Martin Kleppmann的Designing Data-Intensive Applications深入探讨了最后写入胜出的一致性权衡。

  2. 协作文本冲突——CRDT。 当两个用户同时在同一段落中输入内容时,最后写入胜出会丢弃其中一人的输入。无冲突复制数据类型(CRDT)在字符级别合并并发编辑,使两组字符都能连贯地呈现。Yjs和Automerge均实现了这一机制;合并机制有所不同,但保证相同——并发编辑无需中央协调者即可收敛。

  3. 语义冲突——服务端验证。 某些冲突在结构层面可以干净合并,但合并结果却违反了领域不变量。示例: 两个离线用户各自预订了同一会议室的下午2点时段。两次写操作针对不同记录,因此字段级合并会接受两者——结构上没有问题,但造成了双重预订。语义冲突——数据合并干净但结果违反领域不变量——需要在同步期间进行服务端验证。避免数据丢失的模式是:接受冲突写入,标记违规,并以可解决通知的形式呈现给用户,而非静默拒绝——拒绝会导致客户端持有服务器拒绝承认的记录。

本地优先PWA中最难排查的bug不是同步失败——那些会出现在日志中。而是静默覆写:乐观写入在本地成功,同步无错误完成,但用户的变更被赢得最后写入胜出竞争的远端版本悄然替换。这类失败对服务端日志不可见(显示成功)、对错误监控不可见(无异常抛出)、对网络追踪不可见(请求已通过);只有在能够记录UI状态序列的工具中——同时捕获乐观更新随后的静默回滚——这种不一致才会以可回放的序列呈现,而非一条干净的日志行。会话回放(Session replay)是为数不多能够将这种不匹配呈现为可观测序列的工具之一。

本地优先何时适合你的PWA

当应用管理的数据由用户所有,且能从即时本地交互中获益时,本地优先适合PWA;当数据由服务器生成或依赖强事务保证时,本地优先则无所助益。决定性问题不是抽象的应用类别——而是数据是否属于设备。

PWA使用场景本地优先是否有帮助?原因
可安装的笔记应用用户自有数据,读写即时,离线编辑是核心价值;可安装外壳与本地数据相辅相成。
网络不稳定环境下的现场服务应用工作发生在网络薄弱之处;写操作必须在离线状态下成功,并在恢复覆盖后同步。
协作编辑工具并发编辑是核心产品;基于CRDT的同步无需每次击键都进行网络往返即可实现实时合并。
数据分析仪表盘数据由服务器生成;缓存外壳有助于提升加载速度,但对用户不拥有或不修改的数据,本地优先毫无助益。
电商结账流程支付和库存需要ACID保证和单一权威数据库;最终一致性可能导致库存超卖或重复扣款。
社交信息流信息流由服务器排序和管理;客户端是消费者而非创作者。

该模式与PWA优势高度契合的场景,正是用户创建并拥有数据的场景。对于笔记PWA,可安装外壳、离线写入和后台同步共同构成了接近原生应用的体验。对于仪表盘,Service Worker可以缓存外壳以加快加载速度,但数据仍来自服务器——本地优先解决的问题在这个场景中根本不存在。

你是在添加数据层,而非重建应用

将本地优先改造到现有PWA中,意味着新增两个层级,而非推倒重建:在当前依赖fetch()的组件下方插入本地数据库和同步引擎,同时保持Service Worker和框架原封不动。你并非在推翻已有的PWA工作——你是在完善它,以Service Worker迁移应用的方式,将数据也迁移到用户侧。具体的下一步很小:选取一个用户拥有并编辑其数据的功能,用IndexedDB或以OPFS为后端的SQLite为其提供支撑,将其组件改为从该存储读取数据,并让同步引擎在后台进行协调。这单个功能将比任何进一步的阅读都更快地告诉你,你应用的其余部分是否也适合这条路。

常见问题

通常仍然需要,但其角色会发生转变。在本地优先架构中,服务器不再是审批每次读写的守门人,而是成为跨设备协调本地数据库、持久化保存副本,并在语义冲突(如双重预订)时执行服务端验证的同步对等节点。需要ACID保证、支付处理或权威共享状态的应用仍然需要真正的后端;只是UI的读写路径迁移到了本地数据库。

不会。它们运行在不同的层级,相互组合而非相互竞争。Service Worker负责缓存资源并提供离线外壳;本地优先在当前调用fetch的组件下方添加了本地数据库和同步引擎。将本地优先改造到现有PWA中,意味着保留Service Worker和框架,在其下方引入数据层和同步层,而非重写第一层。

当对同一富文本或结构化文档的并发编辑本身就是核心产品功能时,应选择CRDT——因为当两人同时在同一段落中输入时,最后写入胜出会丢弃其中一人的输入。无冲突复制数据类型在字符级别合并并发编辑,使两组字符无需中央协调者即可收敛。对于大多数同步用户自有记录的业务线PWA,基于行复制引擎的字段级最后写入胜出更为合适;仅在实时协作文本是核心需求时,才选用Yjs或Automerge。

这是一次静默覆写。乐观写入在本地成功,同步无错误完成,但远端版本赢得了最后写入胜出的竞争,悄然替换了本地变更。这类失败对服务端日志不可见(显示成功)、对错误监控不可见(无异常抛出)、对网络追踪不可见(请求已通过)。只有在能够记录UI状态序列的工具中——同时捕获乐观更新和随后的回滚——这种不一致才会变得可观测。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay