ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

离线优先实战:Service Worker与IndexedDB构建断网可用的应用

2026/10/8 3:17:19 拓冰建站 浏览量
离线优先实战:Service Worker与IndexedDB构建断网可用的应用 你大概也有过这种时刻电梯里、地铁隧道里、地下停车场里明明手机信号显示满格但一打开某个网页或者应用它就像被按了暂停键一样转圈、报错、页面空白。可另一类产品却完全不受影响——离线笔记还能继续写地图还能继续导航订阅的资讯还能往下翻页断网前后的体验几乎没有断层。这种“断网也能用”的能力很多人觉得像魔法其实在工程圈里它是一个非常明确的设计思想离线优先offline-first。这篇文章想把原理彻底拆开揉碎给你看。不管你是普通用户想搞明白 App 为什么这么“耐造”还是开发人员打算给自己的项目加上离线能力下面这些内容都能帮你建立起一条清晰的思路链条。我会结合服务端、浏览器端和本地端各自该干什么讲清楚那几个决定成败的关键环节也附上我自己踩过坑换来的实操经验。1. 离线的本质不是“没有网络”而是“提前把家底备好”1.1 打破一个常见的误解离线不是网络归零先明确一件事任何“断网也能用”的产品都不代表它能脱离数据凭空捏造内容。真正发生的是它在网络一切正常的那部分时间里已经把“接下来可能要用到的东西”悄悄搬运到了本地设备上。这很像你家平时会把冰箱塞满菜——不是为了天天停电而是为了防止哪天突然停电时还能吃上一口热饭。也就是说离线能力不是一个独立开关而是一个完整的运行策略。它的核心逻辑是在网络可用时做好预取和同步在网络劣化时保持本地可用在网络恢复后自动对齐全局状态。整条链路里网络只是一个有时驻留、有时消失的传输通道而本地设备才是一等公民。这个思维翻转非常关键因为如果你把网络当作默认假设那么离线需求永远只能是一个补丁反过来把离线当作默认假设整个系统架构会从一开始就变得有弹性。1.2 数据分层不是所有东西都必须冲到本地我经常跟朋友打比方离线和“全量拷贝”是两码事。你要离线的是“能支撑用户体验的关键资产”而不是把整个服务器搬到手机上。实际工程中我们一般把数据分成以下几层。数据类别典型例子离线优先级存储方式静态外壳HTML、CSS、JS、字体、图标极高几乎必做HTTP 缓存 Service Worker 缓存业务数据笔记正文、购物车、订单记录高IndexedDB / SQLite 等本地库个性化视图首页推荐、搜索结果、实时的价格库存中低缓存 过期校验网络恢复后刷新身份认证登录令牌、用户信息只能“提前持有”安全存储离线时不能重复登录实时事件新消息、直播间状态、在线人数无法真正离线需要网络通道离线只做到降级提示你看真正适合离线的是前两类。它们的特点是“内容相对稳定”或“用户主动产生的数据”。而像实时推送、验证码这类东西本质上依赖于网络的即时性这就不是缓存能解决的问题。理解这个边界对产品设计和开发都非常重要。1.3 预取和缓存把“万一会用到的东西”先搬回家预取prefetch听起来很专业其实就是提前向服务器请求某些资源趁网络条件好的时候塞进本地。缓存cache则是把已经拿到的资源保存下来下次直接用。两者配合起来的策略有很多种核心目的只有一个减少对网络的实时依赖。拿我最常举的笔记应用做例子。你在公司工位上打开应用它会把你的最近 50 条笔记列表、离线需要的编辑器脚本、甚至附件元信息都推到浏览器本地数据库。到了飞机上你打开同一个应用时看到的不是一个空页面而是从本地秒开的、上次同步过的内容。你写了新段落点了保存它不会一直转圈而是先放心落进本地库等你落地有网了再自动上传。听起来是不是像人肉搬家公司对离线系统本质上就是一个聪明的搬家公司什么时候搬、搬多重的货、先搬哪一批这些细节决定了体验好坏。2. 浏览器里的关键角色Service Worker、Cache 和 IndexedDB2.1 第一层HTTP 缓存的“记忆能力”先说说最基础的一层——HTTP 缓存。它可能不算新鲜但对离线体验至关重要。服务器在返回资源时会带上 Cache-Control、ETag 这类响应头告诉浏览器“这个东西你可以保存多久”“这个东西有没有更新”。浏览器严格遵守这些规则把响应体存进本地磁盘下次再遇到同样的 URL 时如果资源没过期就直接用本地版本根本不发请求。HTTP 缓存适合处理不经常变化的 JS、CSS、图片这类静态资源。它的问题是“不会主动变”一旦内容更新而缓存没失效用户就会看到老版本。所以工程上通常配合文件名指纹比如 app.abc123.js来使用文件内容一变文件名就变旧的缓存自然失效新的文件又会重新缓存。这层机制你不需要写太多代码但它像一个守门员先挡掉了一大部分重复请求。2.2 第二层Service Worker 是真正的“离线总指挥”如果 HTTP 缓存是守门员那 Service Worker 就是教练兼战术板。它是一个运行在浏览器后台的 JavaScript 线程可以拦截页面的所有网络请求然后决定是走网络、走缓存还是两者都走再合并结果。只要浏览器支持 Service Worker这个“总指挥”就能在你断网时把已经缓存的页面和接口数据一股脑地吐给界面。注册一个 Service Worker 很简单核心代码也就几行if (serviceWorker in navigator) { window.addEventListener(load, () { navigator.serviceWorker.register(/sw.js).then(() { console.log(Service Worker 注册成功); }); }); }真正的工作写在 sw.js 里。安装阶段我们通常把 App 外壳HTML、CSS、JS全部放进 Cache Storage请求阶段再根据策略拦截self.addEventListener(install, (event) { event.waitUntil( caches.open(app-shell-v1).then((cache) { return cache.addAll([/, /index.html, /assets/app.js, /assets/app.css]); }) ); }); self.addEventListener(fetch, (event) { const request event.request; // 页面导航请求网络优先失败时回退到缓存 if (request.mode navigate) { event.respondWith( fetch(request) .then((response) { const clone response.clone(); caches.open(app-shell-v1).then((cache) cache.put(request, clone)); return response; }) .catch(() caches.match(/index.html)) ); return; } // 静态资源缓存优先后台再悄悄更新 event.respondWith( caches.match(request).then((cached) { const networkFetch fetch(request) .then((response) { if (response.ok) { const clone response.clone(); caches.open(dynamic-v1).then((cache) cache.put(request, clone)); } return response; }) .catch(() cached); return cached || networkFetch; }) ); });这里不要求你把代码背下来关键是理解它的两个意图第一把外壳资源留在本地即便断网也能打开应用第二动态内容采用“先缓存后回源”的思路既保证速度又尽量保证新鲜度。实际上Service Worker 还能做后台同步、消息推送这些更高级的事离线的魔法可以说有一半握在它手里。2.3 缓存策略矩阵按需求选对应方式不是所有请求都适合“无脑先给缓存”。我整理了一张基于实际项目的选型表你可以直接照着挑策略逻辑适合场景风险Cache First缓存有就直接返回没有才走网络静态资源、头像、logo更新需要额外地图控制Network First先发请求成功就用新数据失败回退缓存列表页、个人首页慢网络下体验塌方Stale-While-Revalidate先返回缓存同时发起网络请求静默更新文章详情、信息流用户可能看到旧数据混搭Network Only所有请求必须实时联网支付、验证码断网时完全不可用我个人的习惯是首屏外壳用 Cache First业务列表用 Network First内容页用 Stale-While-Revalidate涉及钱和身份的操作绝不走缓存。这套组合不是标准答案但它背后的判断逻辑值得借鉴你把用户最关心的事情弄清楚再把“速度”和“新鲜度”分别交给不同策略就不会出现“功能是能用但数据全是旧的”的尴尬局面。2.4 结构化数据存哪IndexedDB 才是“本地保险箱”缓存的 HTTP 响应管的是一个页面整体但业务数据比如一条一条的笔记还需要更结构化的存储。很多人第一反应是 localStorage它能用但真心不推荐作为离线数据的“主业”。localStorage 容量小通常 5MB 左右、同步读取容易阻塞界面、还不能建索引和事务。而 IndexedDB 是浏览器自带的非关系型数据库支持事务、索引、游标容量大得多还允许存储 Blob 这类二进制数据。这样说可能太抽象了。你想象一下localStorage 就像家里的鞋盒放两双鞋还行想堆一百双就顶不开盖子了IndexedDB 像独立的储物间有货架有标签你可以按品牌分、按季节分还能一次性把一整排都盘点。真要做离线优先IndexedDB 几乎是唯一正经可行的浏览器数据库方案。IndexedDB 的 API 比较啰嗦但在现代写法里你可以用 Promise 包一层。大致流程是打开数据库、建立对象仓库object store、增删改查。记笔记场景下的核心代码大概长这样const request indexedDB.open(note-db, 1); request.onupgradeneeded (event) { const db event.target.result; const store db.createObjectStore(notes, { keyPath: id }); store.createIndex(updatedAt, updatedAt); }; request.onsuccess (event) { const db event.target.result; const tx db.transaction(notes, readwrite); const store tx.objectStore(notes); store.put({ id: local-001, title: 飞机上写的草稿, body: ..., updatedAt: Date.now() }); };你保存一条笔记时不是先把数据发给服务器等着确认而是立刻写进 IndexedDB界面同时给用户一个“已保存”的反馈。等到网络恢复这段本地数据再去和服务器握手把这个“保存草稿”升级成“正式同步”。从这个角度说IndexedDB 承担的是“本地主数据”的角色服务器反而成了云端备份和对账中心。3. 本地数据在但云端不同步怎么办同步与冲突的通用套路3.1 离线写入的“待办小纸条”用户离线时创建的笔记、修改的页面都存在本地。但这些改动迟早要交给服务器。工程上最常见的做法是维护一个“待同步队列”。行为类似于写一封邮件时先放到发件箱等有网络时再自动发送只不过这里每个“小纸条”都记录了一个操作新增了哪条内容、修改了哪个字段、删除了哪条记录。这批小纸条要设计得足够健壮因为网络可能刚恢复又断掉同步可能半途而废。一般会为每一条本地记录附加状态字段pending等待推送syncing正在推送synced已同步error服务器拒绝或网络失败拿到网络信号时应用把 pending 的记录逐条上传。服务器接口成功返回后客户端把它标记为 synced如果失败则保留状态并等待下一次重试绝不能悄悄丢掉。这也是离线应用最容易出错的地方本地数据丢了就等于沉默地背叛用户问题会比同步延迟严重得多。3.2 多人协作与多设备时的冲突谁说了算“把本地改动上传到服务器”听起来简单但真实世界有个头疼问题冲突。比如你在手机离线改了一篇文档的标题同时你的同事在网页端也把同一篇文档的标题改成了另一个版本。等俩人的改动一起到达服务器服务器该听谁的这时就要引入冲突处理规则。最低配的做法是“最后写入者获胜”谁的时间戳更晚就覆盖谁简单但容易误伤。稍微好一点的是“版本号机制”服务器给每条记录一个递增的版本号客户端上传时带着自己上次看到的版本号如果服务器发现版本对不上就返回“存在冲突”由用户决定保留哪一版。更精细的系统会做“字段级合并”比如标题冲突保留一个正文冲突保留另一个甚至把冲突双方都展示出来让用户手动合并。我建议团队一开始就把冲突策略写进产品文档不要留到开发后期再拍脑袋。很多人觉得“我们的用户不会同时改一条数据”结果一上线就被打脸。移动办公场景里离线编辑、多端登录、协同文档这三样东西凑在一起冲突概率比你想象的高得多。3.3 比同步最终状态更稳的办法同步“操作日志”还有一种思路我想重点推荐给集成复杂业务的人不要直接同步“变更后的结果”而是同步“变更动作”。举个通俗例子。如果我们要同步一个购物车最终结果可能被中间过程影响用户先加了一个商品后来发现买错了又删除最后又加了一个新的。如果你只把最终结果推给服务器服务器根本不知道你经过了几个步骤万一网络中途断开它恢复出来的状态就可能和用户看到的不一致。但如果客户端把每次“加入”“删除”“修改数量”都当作一条操作日志外加序号记录下来服务器端只要按顺序重放这些操作就能精确还原用户的每一步操作。这就像一个喜欢录 vlog 的人他不用费心去描述“我今天干了什么”只要把每个事件片段的视频按时间顺序贴出来别人自然就能看懂整个过程。操作日志的好处在于可重放、可校验、可审计也为“跨设备恢复”提供了非常可靠的基础。当然它不是银弹若用户删掉了某条操作记录重放就会出错所以日志也要设计成幂等的——每一条操作即使被重放两次结果也必须相同。4. 拆解一个真实场景离线优先的笔记应用到底是怎么跑起来的4.1 先定义边界哪些能离线哪些必须联网光谈概念不过瘾我拿最典型的离线应用——笔记软件来走一遍完整链路。假设我要做一个笔记应用功能清单包括查看笔记列表、打开笔记正文、创建草稿、编辑、删除、同步。里面既有业务数据也有用户的主动操作。一开始就定义清楚边界功能离线表现说明打开应用秒开App 外壳已缓存查看笔记列表显示本地副本上次同步快照新建/编辑笔记正常并标记待同步本地数据库写入删除笔记正常并标记待同步本地逻辑删除搜索笔记基于本地索引可用无需实时请求多人实时协同不可用或降级仅在上线后同步合并这个边界意味着你要在产品里明确告诉用户离线时能看能写但看不到别人的实时更新也暂时无法搜到网络新增的内容。不要试图用离线方案去模拟实时协作那是另一种复杂度。4.2 打开、读到保存一条完整的用户操作链路我在原型里会这么设计。用户用手机打开笔记应用Service Worker 立刻接管页面导航从本地缓存拉起 HTML、JS、CSS界面 0 延迟出现在屏幕上。接着页面脚本打开 IndexedDB把上次缓存的笔记列表渲染出来。因为是离线看起来一切正常。用户点了一条笔记进入编辑页改了几个字点击保存。这时的保存按钮永远不应该转圈等服务器。我们的代码执行顺序是这样的从表单收集最新内容生成一条本地记录如果是新笔记就生成本地 ID写入 IndexedDB同时标记该记录状态为 “pending”在界面上提示“已保存在本机云端同步将在网络恢复后进行”。然后应用监听浏览器的在线/离线事件。一旦检测到网络恢复就遍历所有 pending 记录逐条调用服务器的同步接口。如果服务器返回成功客户端将记录状态改为 “synced”并拉取最新版本替换本地数据如果服务器返回冲突则根据我们前面设置的冲突规则处理。整个流程对用户来说几乎是隐形的你只会觉得“这应用真聪明断网也不慌”。4.3 离线同步引擎的代码骨架我在这里给一个离线同步引擎的简化伪代码方便你“抄”// 保存在本地的待同步事项 async function saveDraft(note) { note.status pending; note.id note.id || local-${Date.now()}; await db.notes.put(note); renderSavedTip(); } // 网络恢复后触发同步 async function syncPendingNotes() { const pending await db.notes.where(status).equals(pending).toArray(); for (const note of pending) { try { const remote await api.syncNote(note); // 服务端返回确认真实版本后 note.status synced; note.updatedAt remote.updatedAt; await db.notes.put(note); } catch (error) { // 保留 pending等待下次同步 logSyncError(note.id, error); } } }这个骨架看起来很基础但它已经覆盖了离线应用最关键的三件事本地写入、状态标记、自动重试。你要在这个骨架上继续加东西主要就是三点一是给同步接口加幂等标识防止网络超时后重试造成重复提交二是给每条本地记录一个“本地版本”当服务器返回新版本时能以覆盖写或合并的方式来处理三是把同步过程做成队列式的避免成千上万条 pending 同时上传把接口打爆。4.4 不只是笔记这个思路可以扩展到地图、资讯、聊天我补充说明一下这套离线优先的思路能用到很多产品形态。地图应用会把用户常去的城市周边切片提前下载到本地进入无信号隧道时依然能显示道路和标记资讯类客户端会把文章正文和图片提前推送给客户端用户在只有弱网信号时依然能流畅阅读聊天应用会把历史消息缓存在本地离线时你能翻聊天记录只是不能发新消息和收新消息。它们是同一个骨架在不同场景下的变体提前拿数据、本地存数据、遇到了再对账。5. 实操中那些反直觉的坑每一条都是真金白银换来的5.1 缓存不是存了就万事大吉失效更新才是重头戏缓存失效是计算机领域公认的难搞问题放在离线场景里更是如此。假如你的应用设计成了 Cache First那你必须想清楚一个问题这些缓存内容什么时候算过期什么时候需要强制从网络更新我见过不少项目把版本号硬编码在 Service Worker 里每发一次版就手改一个版本名然后清掉旧缓存。这个方法简单直观但人工维护版本号很容易漏改。更稳妥的做法是用内容哈希构建后把静态资源的文件名自动加上哈希后缀再在 Service Worker 里动态读取预取清单。文件没变缓存名不变文件变了缓存名必变旧缓存自然被废弃。这套机制能大幅减少“用户还在看老页面”的问题。5.2 别让用户产生“断网也能用”的误判界面状态必须诚实另一个反直觉的点技术做好了产品表现却可能翻车。如果你的应用显示“本机已保存”但用户根本不知道这是本地保存、尚未同步他就会误以为记录已经发给了服务器。等他换个设备打开发现内容不见了第一反应就是“App丢了我的数据”。这不是技术问题是沟通问题。所以离线优先应用必须在界面上明确告知三类状态在线且已同步、离线但可用本地副本、离线但操作将在恢复后同步。你可以在列表页顶部放一个状态条也可以给每条数据加一个小标记。这个“诚实”非常重要它决定了用户对你的信任基线。5.3 存储有配额、清除机制不可控浏览器的本地存储不是无限的。IndexedDB 一般会分配域名配额而且浏览器可能会在磁盘空间紧张时自动清除一部分不常用的站点数据。这意味着你把所有数据都赌在本地缓存上本身就是一种风险。工程上要有两手准备一是通过navigator.storage.persist()向浏览器申请持久化存储尽量降低被自动清理的概率二是设计“缓存重建”机制比如检测到本地数据库为空且网络可用时自动把服务器上的关键数据重新拉回本地。这样即使浏览器抽风把本地数据清空了用户重新进入应用后也能自动恢复而不是看到一个干巴巴的空白页。5.4 时间、时区、标识符离线世界的暗雷最后一个重点也是我觉得最容易被人忽视的离线客户端生成的数据与在线服务端生成的数据在很多维度上天然不一致。拿 ID 来说在线系统喜欢用数据库自增 ID但离线客户端没法等服务器返回 ID所以要主动生成 UUID 或本地 ID。这看起来只是一个字段的事但真实项目里经常因为 ID 冲突导致数据覆盖或关联错乱。我的建议很简单客户端所有新建数据一律用 UUID 或 ULID服务器认这个 ID并把客户端 ID 作为全局唯一键。时间也是个奇坑。用户手机时间可能被改错跨时区飞行时本地时间可能完全混乱。如果你用本地时间戳做“上次更新时间”同步时很可能会把新旧关系判错。稳妥做法是客户端记录“本地创建时间”用于展示记录 UTC 时间戳用于逻辑判断服务端用“服务器接收时间”作为最终的更新基准。5.5 建议你在测试时做一件非常“笨”的事把应用放进飞行模式清空所有缓存然后在断网状态下做完整的新用户流程注册不行、登录不行、看首页不行、写笔记不行……你会惊讶地发现很多“离线可用”的产品只是“首屏能开”稍微往深走一步就碎了。别只测试理想路径把断网、恢复网、弱网反复切换也要把“先离线写入几十条、再重放同步”这种压力场景跑几遍。能不能优雅地处理这些边缘情况才是离线优先的真正分水岭。我自己在这些项目里最深的体会是真正的离线体验不是靠一两个缓存“魔法”堆出来的而是一整套从数据分层到同步策略再到界面反馈的完整设计。它值得你花时间打磨因为用户一旦感受到“断网也不慌”的踏实就很难再退回那种动不动就转圈的应用了。下次写代码前你可以先把服务器的接口停掉逼自己以“对方永远不在线”的视角去设计产品大概率会少走一半弯路。