☰
离线优先:实现“断网也能用”的缓存、同步与冲突处理
2026/10/6 9:31:29 网站建设 项目流程

“断网也能用”这个说法,放在几年前还像是天方夜谭。但今天我们手里的地图、笔记、协同表格、视频号,很多都在悄悄做这件事。我在实际项目里接过不少这类需求,最早听到“断网也能用”时,第一反应也是“用户是不是在提不可能的需求”。做下来之后才明白,这恰恰是当下应用架构里最值得深挖的一块:把原本依赖网络的链路,改造成本地优先、同步兜底的形态。这背后不是黑魔法,是一整套缓存、存储和同步机制的组合。

这篇就专门拆一下“断网也能用”到底是怎么实现的。我会把底层原理讲透,顺手给一套可直接参考的落地路径,覆盖浏览器缓存、Service Worker、本地数据库、同步冲突处理这些核心环节。适合正在纠结“要不要做离线能力”“该怎么设计离线逻辑”的同学,也适合纯好奇、想知道这类功能底层长什么样的人。

1. 先说清楚:“断网也能用”到底是个什么需求

1.1 从生活场景看离线能力的真实存在感

先想一个最普通的场景:你打开手机地图,查好路线,进地铁前点了开始导航。接下来一两站路,4G信号时断时续,但地图照常播报,画面里的路网、沿街店铺、路口放大图都还在。你根本没有意识到自己已经处于弱网甚至断网状态。这不是地图App“碰巧缓存了”,而是它主动把离线导航所需的瓦片、路线数据提前拉到了本地。类似的还有小说阅读器、知识类App,很多人进隧道、坐飞机前会主动下载几十章内容,边飞边看。这个需求本质就是一句话:服务的连续性不能依赖网络的连续性。

放到生产力工具里就更典型。我接过一个外勤巡检类的需求:一线人员跑到偏远厂区,手机经常没信号,但他得边看管路图纸边填巡检记录。以前的做法是拿纸质表,回来再录电脑,费时还容易抄错。改造后的做法是,打开App自动同步最近一周的任务和图纸,巡检过程中填写的内容先落本地数据库,退出系统后再自动上传。看起来很简单,但背后涉及的技术选型,和做一个小型离线优先系统差不多。

这类需求有一个共同特征:数据量可以预估、业务状态可以通过“本地操作+事后同步”收敛、用户能接受短暂的可见延迟。反过来说,如果是股票交易、在线直播互动、实时音视频这种,延迟几个毫秒都不可接受,那“断网也能用”就不适合作为设计目标。所以第一件事永远是判断边界,而不是堆技术。

1.2 离线、本地优先、离线优先:概念别搞混

很多人会把“离线能用”和“断网了数据还在”画等号,其实中间有区别。

  • 离线(Offline):指应用在无网络状态下仍然能打开、能操作。核心任务是“可用”。
  • 离线优先(Offline First):在设计阶段就把离线作为一等公民,先假定网络不可靠,所有官方数据均优先读写本地,网络只负责同步。核心任务是“可用且一致”。
  • 本地优先(Local First):偏协作类应用的设计哲学,不只是断网兜底,而是本地是唯一权威,云端退化成备份。核心任务是“我在你不在也能改同一个文档”。

这三个层次对实现方式的要求截然不同。只做到“离线可用”,可能一个简单的浏览器缓存就满足了;要做到“离线优先”,就需要引入Service Worker、IndexedDB、同步队列一套组合;要做到“本地优先”,还得处理多端并发冲突、操作日志、状态合并这些更复杂的机制。我在下面拆原理时会把这几个层次串起来讲。

2. 底层原理拆解:三把锁把数据拉回本地

2.1 第一层:HTTP缓存,系统自带的“断网缓冲垫”

最容易被忽视的离线能力其实是浏览器自带的HTTP缓存。当你在手机上访问一个网页,只要服务器返回过正常的响应,浏览器就会按照响应头里的规则决定要不要留下副本。

常见的两套协议,一套是强缓存,靠Cache-Control和Expires两个头。如果服务器告诉你Cache-Control: max-age=3600,那这一小时内再次访问同一个资源,浏览器根本不会发起网络请求,直接从本地读。另一套是协商缓存,靠Last-Modified / If-Modified-Since和ETag / If-None-Match。这种情况下请求还是会发出,但服务器如果判断文件没变,会返回一个244字节左右的304 Not Modified,告诉浏览器可以继续用本地缓存。

强缓存能解决“省流量、提速”的问题,但在严格断网场景下只是兜底。因为只要用户清缓存、换设备或者从隐私模式进入,缓存就没了。而且Cache-Control只能命中浏览器默认管理的缓存区域,真正的离线能力还得再往上走。

2.2 第二层:Service Worker,浏览器里的“本地中间人”

Service Worker是PWA(渐进式Web应用)的核心,也是“断网也能用”的一个关键门槛。它本质是浏览器在后台维持的一个独立脚本,独立于网页本身运行,可以监听页面发出的所有网络请求。

正常请求流程是:页面 → 网络 → 服务器返回。

接入Service Worker后变成:页面 → Service Worker(决定从网络拿或从缓存拿)→ 返回。

Service Worker手上维护着一套预缓存列表。既然是代码,就可以在安装阶段主动把JS、CSS、图片、字体下载到浏览器的Cache Storage中。当用户下一次访问时,即使完全断网,Service Worker也能直接从缓存里把资源捞出来返回给页面。

这里有个取舍问题:缓存策略怎么定。我实际用过四种:

缓存策略表现适合场景
仅缓存只读本地,不请求网络离线包、纯静态站点
缓存优先、回退到网络先读缓存,miss时请求网络图片、头像、版本化静态资源
网络优先、回退到缓存先请求网络,失败时读缓存实时性高的数据,比如行情
流畅且更新先从缓存渲染,后台静默更新资讯列表、首页框架

仅缓存适合资源长久不变的情况;流畅且更新则适合既想秒开又想内容最新的场景,这是很多内容型App首屏的策略。做Service Worker选型时先想清楚数据的实时性要求,再看走哪种策略。

2.3 第三层:本地存储,把运行数据真正“落在屋子里”

缓存解决了“资源可用”,但还没解决“用户数据可写”。你要是断网时能看页面却写不了数据,这就不叫“断网也能用”。所以系统里还得有一层本地数据库。

浏览器端有几种选择:localStorage、sessionStorage、IndexedDB。前两个大家可能更熟悉,但它们的定位是同页面的键值数据,数据量上限一般是5~10MB,只能存字符串。适合存偏好设置、临时标志位,不适合存完整业务数据。

真正的本地数据库是IndexedDB。它是一个浏览器内置的非关系型对象数据库,能直接存结构化数据、Blob、File,容量通常采用磁盘配额,可用空间比localStorage大得多。支持索引、游标、事务。

对“断网也能用”需求来说,IndexedDB的任务是:把用户断网期间产生的每条操作记录存下来,作为待同步队列。举个例子,外勤人员在离线状态下填了10条巡检记录,不是一条条往线上服务发送,而是每条都插入到IndexedDB的pending_sync表里,附带时间戳、操作类型、校验信息。等网络恢复后,后台任务再按顺序读出队列,逐条发送到服务器。

这里有个细节经常被忽略:IndexedDB的写入也要走事务,而且频繁的小事务性能一般。实际操作时可以做一个批量缓冲,比如1秒内收集到的新记录合并成一次写入。

2.4 同步与冲突:最难的部分永远是“补回来的账”

资源有了、本地数据有了,剩下最复杂的是数据同步。服务器不知道用户断网的这段时间本地发生了哪些变化,如果直接全量覆盖,多端用户的数据就会互相吃掉。这是离线系统事故的重灾区。

同步方案有几类不同取舍:

  • 全量同步:简单,但浪费流量,数据大一点就会卡。
  • 增量同步:基于时间戳或版本号拉取差异,适合大多数业务场景,但对“删除”这类操作不友好。
  • 操作日志同步:每条对数据对象的改动用日志形式记录,服务端重放日志。精确但复杂度高。
  • 基于CRDT(无冲突复制数据类型):多端并发编辑时自动合并,适合文档协作,业界知名案例是Notion/Figma这类工具的数据层。这类方案设计难度大,生命周期也长。

无论用哪种,都不可避免要面对冲突。最常见的处理后端逻辑是:单条记录上维护一个版本号或更新时间,谁后写入谁覆盖。简单、实用,但有可能丢失另一端的改动。进阶的做法是把冲突保留下来,让用户或业务规则决定怎么合并。比如两个人在同一条工单上改了不同字段,系统可以把title改成一个用户的值、status改成另一个用户的值,合并完成后再写回。

在我做过的项目中,从离线优先切入,最稳妥的路径是:先保证“本地操作必不丢”,再考虑“多端合并友好”。第一条通过待同步队列+失败重试保证,第二条靠版本号+字段级合并实现。

3. 实操记录:从零做一个离线优先的采集工具

3.1 需求边界怎么划

先还原一个典型需求。某设备维护团队需要在机房和野外做巡检,经常没有信号。要求是:现场能看到设备清单和历史工单,填巡检记录,拍故障照片,断网时也能完整操作,回到有网后自动同步。

划需求边界时我先定了三条规则:

  • 设备清单和工单资料是“只读资源”,可以打包到本地,按天或按版本更新。
  • 巡检记录和照片是“新建数据”,只能写本地,网络恢复后上传。
  • 已经提交的工单,离线期间不做编辑。这样能避开“本地改一半,线上又有人改”的复杂合并。

这三条规则直接决定了技术方案的复杂度。你会发现很多时候项目做复杂,不是技术不够,而是边界没定清楚。边界越明确,技术选型越简单。

3.2 前端离线链路代码级实现

我用一个精简版的Web示例来说明。技术栈为:Service Worker + Cache Storage + IndexedDB。

先注册Service Worker:

// app.js if ('serviceWorker' in navigator) { window.addEventListener('load', async () => { try { await navigator.serviceWorker.register('/sw.js') console.log('Service Worker registered') } catch (err) { console.error('SW registration failed', err) } }) }

Service Worker内部做三件事:安装时预缓存资源、监听fetch请求并决定缓存策略、监听来自页面的同步消息。

// sw.js const CACHE_NAME = 'inspection-cache-v3' const PRECACHE_URLS = [ '/', '/index.html', '/app.js', '/styles.css', '/manifest.webmanifest', ] self.addEventListener('install', (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)) ) self.skipWaiting() }) self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))) ) ) self.clients.claim() }) self.addEventListener('fetch', (event) => { // 只拦截同源 GET 请求,自己发的数据请求不拦 if (event.request.method !== 'GET' || !event.request.url.startsWith(self.location.origin)) { return } event.respondWith( caches.match(event.request).then((cached) => { if (cached) return cached return fetch(event.request) .then((response) => { if (response.ok) { const copy = response.clone() caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy)) } return response }) .catch(() => { // 网络不可用时尝试返回 HTML 回退页 if (event.request.mode === 'navigate') { return caches.match('/index.html') } return new Response('offline', { status: 503, statusText: 'offline' }) }) }) ) })

预缓存列表要定期更新。最朴素的做法是每次发版改版本号,比如从inspection-cache-v3改成v4,激活阶段再删掉旧缓存。这个过程可以全自动,但要做版本映射管理,避免用户在弱网下加载了“新壳配旧资源”的混合状态。

3.3 本地写入与待同步队列的实现

数据层我用IndexedDB存两类东西:一类是设备清单快照(只读),另一类是巡检记录待提交队列(可写)。

这里给出一个简化版的写入逻辑:

// idb.js const DB_NAME = 'inspection-db' const STORE_QUEUE = 'pending_sync' const STORE_ASSETS = 'asset_snapshot' function openDb() { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1) req.onupgradeneeded = () => { const db = req.result if (!db.objectStoreNames.contains(STORE_QUEUE)) { const queueStore = db.createObjectStore(STORE_QUEUE, { keyPath: 'localId', autoIncrement: true }) queueStore.createIndex('by_created_at', 'createdAt') } if (!db.objectStoreNames.contains(STORE_ASSETS)) { db.createObjectStore(STORE_ASSETS, { keyPath: 'assetId' }) } } req.onsuccess = () => resolve(req.result) req.onerror = () => reject(req.error) }) } // 新建巡检记录:先落 IndexedDB,再触发同步 async function saveInspection(data) { const db = await openDb() const tx = db.transaction(STORE_QUEUE, 'readwrite') const store = tx.objectStore(STORE_QUEUE) await store.add({ ...data, status: 'pending', createdAt: Date.now(), syncedAt: null, }) notifySync() } // 进入待同步队列后,尝试通过 Service Worker 发消息 function notifySync() { if (!navigator.serviceWorker?.controller) return navigator.serviceWorker.controller.postMessage({ type: 'SYNC_NOW' }) }

Service Worker收到消息后,因为后台线程可以进行定时任务,会尝试唤醒网络请求来刷新同步;如果请求失败,就保留队列,等待下次网络恢复事件再重试。

真实项目中常会加一层navigator.onLine监听,在线时每隔一段时间执行一次flushQueue(),把pending_sync里的记录按顺序发送到服务端。这个队列设计有几个关键点:每条数据要带唯一的本地ID,服务端返回成功后标记syncedAt并删除记录;发送失败的记录不能一直阻塞队列,要单独记录下来,设置重试上限,避免死循环。

3.4 服务端同步接口怎么设计

很多人只盯着前端“断网也能用”,却忽略了一个事实:如果后端的接口不支持批量、不支持幂等,离线同步根本转不起来。

至少要把以下三个接口做到位:

  • 批量提交接口:一次上传多条巡检记录,减少弱网下的握手成本。
  • 幂等键:每条记录带localId或自定义requestId,服务端用幂等键去重,防止网络超时重试时重复落库。
  • 增量拉取:客户端上传本地时间戳,服务端只返回该时间点之后有变化的设备清单和工单,降低首次同步流量。

幂等这一点,是离线系统里最容易踩但最不容易意识到的坑。你想想看,用户断网时的请求发出去了,其实服务端已经收到并写库了,但网络断开导致客户端没收到成功响应,客户端只能重发。如果没有幂等键,这条数据第二天就会出现在数据库里两遍。有了幂等键,服务端看到同一个requestId来了两次,第二次直接返回之前处理的结果,不重复写。

4. 排查实录:离线方案最常见的几类疑难杂症

这部分直接进入经验阶段。离线系统和普通Web应用不同,很多问题不在线上“显眼”,而是藏在用户本地,排障手段和思路要跟着调整。

4.1 Service Worker一直不生效

新写SW时最容易碰到“怎么注册都不生效”的情况。大概率不是代码逻辑问题,而是浏览器安装SW的前提没满足:SW脚本要求在同源、HTTPS(或localhost)下运行,并且响应头Content-Type必须是application/javascript。如果服务器把它当文本返回,浏览器会直接拒绝安装。

另外,SW的生效范围受脚本路径控制。/sw.js能控制根目录下的所有页面,/static/sw.js只能管理/static/路径下的页面。我见过有人把SW放在CDN域名下,结果发现它根本管不了主站页面的请求,原因就是跨域和scope都越界了。

4.2 更新了资源,但用户还是旧版本

这是开发期最容易疯掉的问题。版本号已经改了,代码已经部署了,但手机上的页面还是旧UI。原因在于CACHE_NAME没有更新。SW的激活是“冷”的,即使新旧SW都有了,旧的SW如果在控制页面,新SW也只会等待,不会立刻替换。代码里加了self.skipWaiting()和self.clients.claim()只能从安装上加速接管,但页面本身得刷新一次才能用上新版资源。

实操上建议做成“双重版本校验”:资源文件用带哈希或版本号的文件名,避免“同名文件被长期缓存”;SW版本号放配置文件里,发布时自动修改。这样更新链路清晰,不会出现“改了半天但用户看到的还是老版本”的乌龙。

4.3 弱网环境下同步数据丢记录

排查这类问题要先抓住“队列持久化”这个根。很多丢失记录的原因就是待同步数据只存在内存里,用户退出页面或浏览器被系统杀掉,队列就没了。正确做法是用IndexedDB落本地,每一次写操作都先落库再返回成功。有些开发者为了性能把队列放在内存,再周期性地刷入缓存,这在强健的离线场景下就是给自己买的“定时炸弹”。

丢记录还有一个常见原因:服务端接收后返回的错误处理不当。客户端拿到4xx/5xx直接丢进catch里,没有区别对待。正确做法是:4xx错误说明请求本身有问题,记入错误队列供人工排查;5xx说明服务端暂时不可用,应当原样保留并延迟重试;超时则要配合幂等键重试。

4.4 缓存与业务数据交错导致“幽灵数据”

这里有类典型事故:页面里静态列表缓存了,动态数据走实时接口,断网时静态列表内容还在,但详情页和数据面板全空。用户会觉得“断网也能用”名存实亡。

正确思路是在缓存设计阶段就把“资源缓存”和“业务数据缓存”分开。静态资源走Cache Storage,业务数据走IndexedDB。不能把API返回的JSON也直接丢进Cache Storage,否则版本一变、缓存一乱,很容易读到过期或混合的数据。

5. 哪些场景值得做离线?边界和选型建议

5.1 适合做成离线优先的典型场景

根据我实践过的项目,适合“断网也能用”的业务有几个共同特点:数据可以在本地镜像、操作能够延续网络恢复后的状态、多端并发概率低或可接受覆盖。

具体列表可以是:

  • 外勤类工具:巡检、盘点、维修报单
  • 内容消费类应用:小说、课程、地图离线包、资讯预加载
  • 表格/文档轻量协作:单人多端或弱网编辑为主
  • 会话消息:IM聊天记录本地留存,收发附件走队列
  • 数据采集类表单:医疗建档、市场调查、地质勘探

5.2 不适合硬做离线的场景

反向也列一下。对实时权威性要求极高、需要多方强一致、或资源变化的代价极大的场景,做离线的性价比很低。比如实时在线支付、证券委托下单、多人实时对战。这类业务可以允许“弱网”下的延迟优化,但绝不应该设计成完全断网可用的形态。即使做,也要明确提示用户“当前为离线状态,数据可能不是最新”,并且在恢复网络后强制拉取校验。

5.3 框架选型思路

如果我们做的是新项目,可以优先考虑成熟的离线框架或数据同步方案:React生态下有workbox来管理SW缓存策略;本地数据管理可以选Dexie.js(IndexedDB封装)或rxdb,后者直接提供了本地同步、远程同步适配能力;移动端还可以考虑SQLite+自研同步协议。

有一个需要注意的问题:框架给你的是便利,不是全部。不管选什么框架,冲突处理、版本管理和幂等设计最终都需要自己根据业务来定。框架只能帮你把“数据持久化”做好,不能替你做“业务决策”。

6. 离线优先的“魔法”其实是一组工程共识

回到标题本身,“断网也能用”没有任何魔法成分,它只是把web研发默认假设的“网络永远在线”改成了“网络随时可能断开”,然后让系统的每一层都围绕这个假设做事。HTTP缓存兜底静态资源,Service Worker拦截请求并管理更新,IndexedDB持久化业务数据,同步队列处理断点续传,幂等接口保证不重复,最后再辅以一套清晰的冲突处理规则。

我在实际项目中踩过最深刻的坑,是当时把离线能力只理解成“做了Service Worker就是完成了”。后来发现用户反馈最多的其实是两件事:一个是在线时更新的数据在离线后拿不到,另一个是离线提交的工单在线后重复出现了。前者是缓存策略没选对,后者是幂等没做好。如果让我重新给刚接触离线方案的人挑三个最重要的关键词,那一定是:缓存策略、幂等设计、同步队列。这三件事想清楚,离线系统已经成功了一大半。

最后再分享一个小技巧:做离线系统时间长了容易陷进“本地必须做得完美”的思路,总想做到真正的“本地优先”,把合并能力都建好。这通常会拖垮迭代节奏。我个人的建议是,第一版先做“离线可用”,做挂起队列和重传机制,能解决90%的线下业务。等真有大量并发编辑需求时,再考虑CRDT或操作日志级别的合并方案。这套路不仅省力,而且用户体验是一样的:断网也一样能干,恢复网络也没丢。

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

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

立即咨询