渐进式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只是恰好在同一运行时中可用的一种机制。
Discover how at OpenReplay.com.
本地优先的真正含义
本地优先是一种数据架构:用户设备持有应用数据的主副本,应用对本地数据库进行读写,而服务器(如存在)是具有特殊权威的同步对等节点,而非必须审批每次读写的守门人。从用户视角来看,对本地数据库的读写是同步完成的,与服务器或其他设备的同步则在后台异步进行。这一架构转变重新定义了客户端的角色:它不再是一个需要申请权限才能展示数据的薄视图层,而是成为持有完整副本的全权参与者。
权威参考资料是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后端之上实现部分复制和按用户数据同步的应用。
- Replicache 和 Zero(均来自Rocicorp)— 以查询驱动的同步系统,优先保障本地优先的用户体验,而非行级复制。非常适合围绕客户端变更、乐观更新和服务端协调构建的应用。
- Triplit — 内置同步能力的全栈数据库,客户端与服务端数据库共享同一套心智模型而非两套;适合希望将同步作为默认能力的全新项目。
- Yjs 和 Automerge — 面向协作编辑的CRDT库;当多用户需要并发编辑同一富文本或结构化文档,且需要字符级合并时,这是正确的选择。
对于大多数需要同步用户自有记录的业务线PWA,基于现有数据库的行复制引擎比CRDT库更为合适。仅当实时协作文本编辑本身是核心产品功能时,才应选用Yjs或Automerge,而不应将其作为通用的冲突解决方案。
冲突解决
当两个副本在未感知彼此变更的情况下修改了同一数据,同步引擎必须对其进行协调。冲突可分为三类,每类对应不同的解决策略。
-
结构性冲突——字段级最后写入胜出。 字段级最后写入胜出(last-write-wins)可处理典型应用中的大多数冲突:若两个用户在离线状态下编辑了同一记录的不同字段,两处变更均会保留;若编辑了同一字段,则时间戳较晚的版本胜出。这是本地优先社区广泛采用的经验法则,而非经过精确测量的定论——Martin Kleppmann的Designing Data-Intensive Applications深入探讨了最后写入胜出的一致性权衡。
-
协作文本冲突——CRDT。 当两个用户同时在同一段落中输入内容时,最后写入胜出会丢弃其中一人的输入。无冲突复制数据类型(CRDT)在字符级别合并并发编辑,使两组字符都能连贯地呈现。Yjs和Automerge均实现了这一机制;合并机制有所不同,但保证相同——并发编辑无需中央协调者即可收敛。
-
语义冲突——服务端验证。 某些冲突在结构层面可以干净合并,但合并结果却违反了领域不变量。示例: 两个离线用户各自预订了同一会议室的下午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.