首页 / 文章

将离线优先前端设计为数据副本:队列、重试与冲突处理

了解为何“离线优先”模式会将浏览器转变为数据副本,以及本地存储、持久化变更队列、幂等重试和冲突规则是如何相互配合的。

发布者:REACTAPP.TOP阅读约 12 分钟2736 词

来源: The Architecture Behind an Offline-First Frontend

在快速稳定的网络连接下表现良好的前端其实并没有什么价值。真正有趣的情况出现在请求挂起、Wi-Fi断开、火车驶入隧道、笔记本电脑在多个网络间切换,或者有人在点击保存后手机立即失去信号时。传统的前端架构将所有这些情况都视为异常:调用API,等待,更新状态,然后继续执行。

离线优先的设计基于相反的假设:网络可能会出故障,但应用程序仍需保持可用性。这听起来像是用户体验上的优化,但实际上是一种架构层面的变革。一旦浏览器能够读取数据、接受修改、持久化存储并随后同步,它就不再是一个轻量级客户端,而变成了分布式系统中的一个节点。本指南将详细阐述其在实际应用中的含义:数据应存储在何处、如何安全地排队处理写入操作并重试、用户界面该如何传达同步状态,以及为何冲突处理最终属于产品决策范畴。

将网络从关键路径中移除

想象一个简单的笔记应用。有人打开一条笔记,修改标题后保存。在传统设计中,操作流程如下:

User → React → Server
              ↓
            Data

服务器直接参与用户交互过程。服务器响应缓慢会导致界面操作迟缓;连接中断则会使保存功能停止工作;请求失败时,应用必须立即决定如何处理用户的修改内容。

采用离线优先的设计后,很大一部分责任被转移到了浏览器中:

User → React → Local Store
                    ↓
                Sync Later

服务器并非变得无关紧要。对于大多数系统而言,它依然是存储共享数据的地方。不同的是,它不再需要在每次用户交互时都处于可访问状态。在确认用户操作与将其传递到后端之间建立这种分离,是所有其他功能的基础。

现在浏览器负责存储重要的状态数据

如果应用需要在无网络连接的情况下继续运行,它就需要设备上具备可靠的存储功能。对于那些超出简单偏好设置的数据,通常需要使用 IndexedDB 或在其之上构建的更高级数据库。具体使用的库并不如由此带来的后果重要:浏览器现在掌握了你绝不能丢失的状态信息。

这些数据包括缓存记录、未发送的草稿、待发布的变更内容、时间戳和版本号等同步元数据,以及关于已上传内容的记录。这就引出了一个普通请求-响应型应用无需面对的问题:当浏览器端的副本与服务器端的副本出现不一致时该如何处理?在以离线优先为设计原则的应用中,这种情况并非异常情况,而是常态。

仅缓存读取并不等同于以离线优先为设计原则

这种区别很容易被混淆。一个应用虽然可以缓存API响应,但仍完全依赖网络。假设它获取了一组项目列表并将其存储在本地,这样在没有网络连接的情况下列表依然会显示出来。这固然有用,但仅适用于读取操作。

只有当用户能够在断网状态下修改数据时,设计才真正具备离线优先的特性:创建项目、编辑文档、完成任务、调整设置。这些操作不会因为没有发出HTTP请求就消失不见。应用需要一个地方来记录用户想要执行的操作,这样这些意图才能在能够被执行之前得以保存。

每一次离线写入都意味着同步义务

当有人在离线状态下编辑文档时,通常的做法是先将更改应用到本地存储中,然后再记录那些仍需发送到服务器的操作。简而言之:

User Saves
    ↓
Save Locally
    ↓
Update UI
    ↓
Add To Queue
    ↓
Sync When Online

注意操作顺序:本地写入和界面更新会在任何内容被放入队列之前完成,这就是为什么界面能够立即响应的原因。最后的队列值得更多关注——它并非一堆出错的请求,而是用户希望系统完成的各项任务的持久日志。

这有具体的要求。该队列必须能够承受页面刷新、浏览器重启、短暂的服务器错误以及网络变化,这意味着它应该存储在持久化存储中而非 JavaScript 数组里。此外,当某项操作被发送两次或更多次时,它也必须能正确运行。这一要求导致了整个设计中最棘手的故障模式之一。

成功与失败难分伯仲之时

想象以下流程:用户进行保存操作,请求送达,服务器成功写入数据。但在响应返回之前,连接就中断了。客户端会认为保存失败,而服务器却知道操作已成功完成。

Client
  |
  | Save
  v
Server
  |
  | Saved
  X
Response Lost
  |
  v
Client Retries

如果客户端只是重新尝试,服务器可能会两次应用相同的更改。对于那些可能无害的幂等“设置标题”操作来说问题不大,但对于“添加项目”、“创建订单”或“增加计数器”这样的操作,则会导致数据重复。

这就是为什么幂等性对离线优先系统至关重要。每次数据变更都需要一个唯一的标识,这样服务器才能判断重新尝试的是同一逻辑操作而非新操作。常见的处理方法是在客户端首次记录变更时生成一个操作ID,将其与待处理的变更一起存储,并在每次尝试时都发送该ID。服务器会记录已处理过的ID,遇到重复的ID时会予以确认而不再执行相应操作。

这并非性能优化手段,而是为防止不可靠的网络将普通的重试操作变成重复劳动而设计的机制。有一个重要的细节需要处理妥当:该标识符只需创建一次并随队列条目一同保存,不应每次请求都重新生成,否则每次重试都会被视为新的操作。

乐观式用户界面——意图的体现

乐观更新通常被宣传为让应用显得更快捷的方式。在以离线优先的应用中,它的作用更为重要。如果用户在离线状态下完成某项任务,而界面却要等待服务器响应后才进行同步显示,那么该应用就会显得有故障。本地更改应当立即显现,后续再通过同步将远程端的数据更新到位。

这样一来,职责划分就更加清晰:

  • 本地状态反映的是用户实际完成的操作。
  • 同步状态反映了共享系统是否已完成同步。
  • 这两者相互关联但并不完全相同,将它们分开处理能让用户界面比单一的在线/离线指示器更准确地传达信息。例如,某个项目可以被标记为:

    • 已保存在本设备上
    • 正在排队同步
    • 当前正在同步
    • 已同步
    • 需要用户处理

    看到“正在排队同步”的用户会明白相关操作已安全完成但尚未共享,这比模糊的旋转图标或错误提示要令人安心得多。

    让本地存储管理数据,由 React 进行渲染

    导致意外复杂性的常见情况如下:数据先存储在 IndexedDB 中,再加载到 React 状态中,随后又被复制到另一个独立的客户端存储中,最后还需要手动保持所有数据的一致性。这样一来,应用就会为相同的记录保留多种版本,从而引发过时数据、竞态条件、不必要的重新渲染以及完全存在于前端内部的同步错误。

    更简洁的模型将持久化数据的所有权交给可靠的本地存储,而 React 则仅作为其视图存在:

    Local Store
         ↓
      React UI
         ↓
       User
         ↓
    Local Store
    

    循环才是关键:用户界面从本地存储中读取数据,用户进行操作后,该操作会将数据写回本地存储,进而再次驱动用户界面更新。React负责处理交互和界面展示,而数据库则负责管理需要持久保存的客户端状态。实际上,这意味着组件应直接订阅对本地存储的查询操作,而非将查询结果复制到组件状态中。随着应用规模的扩大,这种方式的优势愈发明显,因为当某个值出现异常时,只需在唯一的地方查找问题所在。

    当两台设备的数据都正确时

    冲突解决正是“离线优先”从存储问题转变为分布式系统问题的关键点。假设有人在笔记本电脑和手机上打开同一份文档,随后两者都断开了网络连接。在笔记本电脑上,文档标题被改为前端架构;而在手机上则变为离线架构。每台设备都认为自己的修改是有效的,且彼此并不知情。当两者重新连接时,就会发生冲突:

    Laptop              Phone
       |                   |
       v                   v
    "Frontend App"     "Offline App"
       |                   |
       +--------+----------+
                |
                v
             Conflict
    

    并不存在一种普遍正确的解决方式。应该是最新的修改占上风吗?还是服务器端的版本更具优先性?这些字段能否合并?应该由用户来选择吗?不同的字段是否需要遵循不同的规则?

    关键在于,冲突解决其实是一种看似技术性的产品决策。对于诸如用户界面偏好这类内容,采用“最后写入者胜出”的规则是完全可接受的。但对于协作文档、财务数据、库存数量或重要业务记录而言,这种做法则不可行,因为默默忽略某一方会导致实际工作成果丢失或产生错误的总数。架构必须明确每种数据对应的“正确”标准,正因如此,产品负责人需要参与这一讨论,而不能仅交给编写同步代码的人来处理。还需注意,“最新”这个概念本身也很模糊:设备时钟会出现偏差,因此如果排序依据是客户端的时间戳,就很可能选错获胜方,除非排序依据来自更可靠的信息源,比如服务器分配的版本号。

    远程更改也需要相同的入口点

    离线优先并不意味着只有在应用启动时才进行同步。其他用户和其他设备会修改相同的数据,服务器也会持续接收来自其他地方的变更。因此,实时通道增加了第二个数据流动方向:

    Local Changes
          ↓
         Sync
          ↓
        Server
          ↓
      New Changes
          ↓
     Local Store
          ↓
          UI
    

    这引出了一条重要规则:来自服务器的变更应当最终存储在UI当前读取的同一本地数据模型中。如果本地编辑走一条路径,而远程更新走另一条路径,React组件就需要处理复杂的逻辑来协调两者。将本地存储设为唯一的数据交汇点能让渲染过程保持简单:UI无需关心变更是在当前设备还是其他设备上产生的。

    假设浏览器本身也会出现故障

    还有一种容易被忽视的故障模式:网络恢复之前浏览器可能已经关闭。标签页被关闭、浏览器崩溃、操作系统终止进程、笔记本电脑电池耗尽,或者同步过程中进程突然消失。所有仅存在于内存中的未完成任务都会随之丢失。

    正因如此,同步状态必须具备持久性。一个真正的离线优先应用应将浏览器视为不可靠的节点,其本地数据库必须存储足够的信息,以便在应用重新启动时能够还原未完成的任务:哪些操作处于待处理状态、哪些正在执行中,以及服务器上最后的已知状态是什么。一个实用的测试方法是在开发过程中在同步进行到一半时关闭标签页,然后确认下次启动时没有数据丢失或重复。

    服务工作线程只是其中的一部分,而非整个架构

    服务工作线程具有重要作用,尤其擅长缓存应用资源及拦截网络请求;如需回顾相关原理,请参阅使用服务工作线程实现离线支持。但仅注册服务工作线程并不足以让应用成为“以离线优先”的架构:

    • 已注册的服务工作线程无法防止用户未保存的编辑内容丢失。
    • 虽然缓存过的资源能让应用在离线状态下启动,但并未规定离线写入的内容应存储何处。
    • 从缓存中提供过期的 API 响应并不构成任何冲突处理策略。

    离线优先是架构本身的特性,而非可开启的浏览器功能。真正使其发挥作用的是本地数据层、持久化的数据变更、同步流程、明确的错误处理机制、冲突解决策略,以及能够接受远程同步可能需要一定时间的用户界面。

    复杂性只是转移了位置,并未消失

    令人不安的事实是,离线优先的软件并不更简单,只是复杂性被转移到了其他地方。传统应用只需发送请求并等待回复即可,而离线优先的应用则必须处理一系列更复杂的问题:

    • 如果无法连接到服务器怎么办?
    • 如果在同步完成前浏览器关闭了怎么办?
    • 如果请求已发送但未收到响应怎么办?
    • 如果在其他设备上同一条记录被修改了怎么办?
    • 如果两种修改都是有效的怎么办?
  • 对于这款产品来说,哪种结果才算正确?
  • 这些都是典型的分布式系统问题,而浏览器作为参与方之一并不会让这些问题变得更简单。在决定采用离线优先策略之前,值得先思考该产品是否真的需要支持离线写入功能。对于仅供用户阅读的仪表板,通过缓存读取并显示明确的离线提示,或许就能以更低的成本实现大部分功能价值。

    本地数据也需要服务器级的安全保障

    存储在设备上的数据依然是数据,随之而来的是各种责任。如果敏感信息保存在浏览器中,就必须考虑设备被入侵、多人共用设备,或是某个账户登出后由其他人使用设备时的情况。本地数据的持久化存储应当遵循与后端存储相同的安全原则:

    • 仅存储客户端真正需要的内容。
    • 明确设定过期规则以及用户登出时的处理方式。
    • 不要将浏览器存储视为安全的保险库。
    • 慎重决定设备上允许存在哪些记录。

    离线优先模式虽能提升可用性,但绝不能以泄露本就不应存储在本地的数据为代价。

    围绕故障场景而非理想流程进行设计

    离线优先模式的一个显著特点是,一旦停止为理想流程设计,架构就会变得更加清晰。需要思考的问题也会随之改变:

    • 不要问“如何获取这些数据?”,而要思考在网络断开时数据应存储在哪里。
    • 不要问“如何重试此请求?”,而要思考客户端如何判断服务器是否已经处理了该请求。
  • 不要纠结于如何将数据推入 React 状态,而应思考系统中哪一部分真正拥有这些数据。
  • 不要把冲突的请求视为需要修复的漏洞,而应思考对于该产品而言“正确性”意味着什么。
  • 始终如一地回答每个问题的第二种问法,就能打造出更稳健的设计。

    目标是让用户几乎察觉不到其存在

    用户对同步引擎、数据库事务、重试策略、合并算法或复制机制毫无兴趣。他们只想继续工作。他们希望断开连接后草稿依然存在,希望在火车上开始的任务能保持完成状态,也希望应用在 Wi-Fi 状态改变时不会立刻失去功能。底层的机制可以十分复杂,但用户的使用体验却不应如此。

    关键要点

    将整个设计串联起来的核心理念是:以离线优先为原则的前端其实是一种分布式系统,浏览器会存储数据的第二个副本。从这一视角来看,各个组件所承担的角色就更加清晰了:

    • IndexedDB并非单纯的浏览器存储工具,而是应用程序数据层的一部分。
    • 变异队列并非用于重试的辅助工具,而是用户操作意图的持久化记录,每条记录都需要一个稳定的ID以便实现幂等重试。
    • 乐观式UI并非提升速度的技巧,而是为了在后台需要处理数据期间仍能保持产品的可用性。
    • 冲突处理并非边缘情况,而是产品定义正确性的重要组成部分。
    • 同步并非最后才添加的功能,它本身就是架构的一部分。

    网络会出现故障,服务器响应迟缓,设备之间会短暂出现数据不一致的情况,浏览器也会毫无预兆地消失。一个设计良好的离线优先前端架构能够确保用户的数据在所有这些情况下依然完好无损,即便网络连接不可用时也能履行其承诺。