
1. 为什么写这篇文章从“飞行模式”到“离线优先”飞机起飞后不久机长会要求乘客把电子设备切换到飞行模式。此时你的手机仍然能看照片、记笔记、玩单机游戏唯独收不到网络数据。如果把一个 Web 应用放到这种环境里它还能正常运行吗这是很多前端开发者在做移动端 H5、PWA、内嵌 WebView 项目时思考过的问题当网络不可用时应用不应该直接白屏。标题里的“我将独自飞行无人理会”其实很接近离线场景下应用的处境——没有服务端响应没有 CDN 资源没有后端兜底它只能依靠本地缓存与本地数据继续工作。这篇文章围绕“飞行模式AIRPLANE MODE”这个意象展开一套完整的离线优先Offline First应用设计与实现方案。我会从原理讲起再带领大家用原生 HTML JavaScript Service Worker IndexedDB 实现一个支持离线使用的待办事项应用并重点讲解离线缓存策略、数据同步队列和常见排错思路。如果你之前对 PWA、Service Worker、本地存储选型、断网数据同步比较模糊这篇文章可以作为一份系统性入门笔记。代码全部可以在本地直接运行建议一边读一边动手实践。2. 环境准备与项目概览2.1 运行环境这个实战项目不依赖后端服务不需要安装数据库也不强制要求 Node.js。你需要准备的是一个现代浏览器推荐 Chrome 或 Edge因为 Service Worker 调试工具比较完善。一个本地静态服务器。Service Worker 不能直接在file://协议下注册所以本地预览需要使用http://localhost环境。一个趁手的代码编辑器VS Code 即可。如果你想使用本文提供的本地服务器命令Python 环境也可以但不是必须。本地启动静态服务器的方式很多我习惯用 Python 自带模块cd offline-todo python -m http.server 8080如果本机没有 Python也可以用 VS Code 的 Live Server 插件或者 Node.js 的npx serve只要最终能通过http://localhost访问项目目录即可。有一点需要提前说明技术栈版本更新很快本文的重点不是固定在某一个版本上而是把设计思路讲清楚。你本地浏览器版本只要不是太旧代码都能直接运行。2.2 核心技术栈原生 HTML / CSS / JavaScript降低学习成本不引入框架也能讲清楚离线优先的核心链路。Service Worker浏览器后台脚本负责拦截网络请求、管理缓存、控制离线资源加载。Cache Storage API用于缓存静态资源比如 HTML、CSS、JS 文件。IndexedDB浏览器内置的非关系型数据库用来存储结构化业务数据也就是我们的待办事项和同步队列。navigator.onLine 与在线状态事件用于感知网络状态变化触发数据同步。这五块组合起来就是一个完整的最小离线应用闭环资源离线可访问 数据本地可写 网络恢复后自动同步。2.3 项目结构规划在动手写代码之前先规划文件结构。清晰的结构能帮我们避免“代码到处丢”的问题。offline-todo/ ├── index.html ├── manifest.json ├── sw.js ├── css/ │ └── style.css └── js/ ├── db.js └── app.js各文件职责如下文件职责index.html页面骨架展示待办列表和网络状态manifest.jsonPWA 清单文件让应用支持添加到桌面sw.jsService Worker 脚本负责静态资源缓存与离线回退css/style.css页面样式js/db.jsIndexedDB 数据层封装负责本地读写js/app.js业务逻辑层负责事件绑定、渲染、网络状态监听与同步调度下面我们先把核心原理讲清楚再进入完整实战。3. 核心原理拆解3.1 Service Worker离线缓存的中枢Service Worker 是浏览器提供的一个独立于网页主线程的 JavaScript 运行环境。它可以拦截当前站点作用域下的网络请求并对请求结果进行自定义处理比如返回缓存内容、发起网络请求、把新响应写入缓存等。没有 Service Worker 时浏览器一旦断网页面刷新就是白屏或浏览器默认错误页。有了 Service Worker 后浏览器在断网时会把请求交给 Service Worker 处理Service Worker 可以从 Cache Storage 中返回之前缓存好的资源让页面继续展示。这里有几个关键概念需要区分Install 阶段Service Worker 第一次安装时会触发install事件通常用来预缓存核心静态资源。Activate 阶段安装成功后触发activate事件适合清理旧版本缓存。Fetch 阶段页面发起请求时触发fetch事件这是离线策略的主战场。从开发者的视角看Service Worker 就像一个位于浏览器和网络之间的“中间层”。它能不能正常工作取决于两点第一你的站点必须运行在https或localhost环境第二作用域要覆盖你需要缓存的资源路径。3.2 缓存策略怎么选Service Worker 中最常见的三种缓存策略分别是策略核心逻辑适用场景Cache First先查缓存有就直接返回没有再去请求网络静态图片、版本稳定的 JS/CSS 文件Network First先请求网络成功则更新缓存并返回失败则回退到缓存页面 HTML、需要实时性的接口数据Stale While Revalidate先返回缓存同时后台发起网络请求更新缓存非核心但不希望太旧的资源在本文的待办应用中我们使用Network First作为静态资源的兜底策略。原因很简单开发调试时希望页面内容永远是最新的而断网时又希望页面能通过缓存打开。Network First 刚好兼顾这两种需求。但要注意Service Worker 的fetch事件只能拦截同源或已注册 CORS 的请求而且默认不会拦截跨域资源。实际项目中如果有 CDN 资源需要额外判断请求的 URL 和模式。3.3 IndexedDB、localStorage 与同步队列localStorage 很常用但它有几个不适合存储业务数据的短板只能存储字符串读写大量对象时需要手动序列化和反序列化。同步 API 设计数据量大时有阻塞主线程的风险。存储空间较小官方建议一般只有 5MB 左右。无法创建索引不能按条件高效查询。IndexedDB 则是浏览器内置的真正的数据库支持对象存储、索引、事务和异步 API。它非常适合存储待办事项这类结构化数据。离线应用的数据流通常分成两条线读路径页面优先从 IndexedDB 读取本地数据保证启动时立即渲染。写路径用户操作先写本地再尝试同步到服务端如果离线则把操作记录到同步队列中等网络恢复后按顺序补推。同步队列的引入很关键。如果不加队列离线期间产生的数据变更会丢失或者恢复网络后无法准确知道哪些数据要同步。更专业的方案还会给每一条操作增加唯一 ID保证重复同步时不会产生重复数据也就是幂等设计。4. 完整实战一个能“独自飞行”的离线待办应用下面我们来实现一个完整的离线待办应用。它的能力可以概括为在线时正常添加、勾选、删除待办。断网时页面依然可打开本地数据依然可增删改。离线期间产生的操作会进入同步队列。网络恢复后手动或自动触发同步把本地数据推送至“远程存储”。这里要强调一下为了避免引入后端依赖我会用浏览器localStorage的某个前缀命名空间来模拟远程服务器。这只是一个演示技巧真实项目中应该替换成fetch调用后端接口。4.1 第一步创建项目结构在任意目录下创建一个名为offline-todo的文件夹并按下面结构创建空文件。mkdir -p offline-todo/css offline-todo/js touch offline-todo/index.html touch offline-todo/manifest.json touch offline-todo/sw.js touch offline-todo/css/style.css touch offline-todo/js/db.js touch offline-todo/js/app.js如果你使用 Windows 系统也可以直接在资源管理器里手动创建文件夹和文件结构保持一致即可。4.2 第二步编写页面基础结构打开index.html写入以下内容!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title飞行模式待办 · Offline Todo/title link relstylesheet href./css/style.css / link relmanifest href./manifest.json / /head body header classapp-header h1飞行模式待办/h1 span idnetworkStatus classstatus offline离线/span /header main classapp-main form idtodoForm classtodo-form input typetext idtodoInput placeholder输入待办事项按回车添加 autocompleteoff required / button typesubmit添加/button /form ul idtodoList classtodo-list/ul section classsync-panel button idsyncBtn typebutton立即同步/button span idsyncState暂无待同步数据/span /section /main script src./js/db.js/script script src./js/app.js/script /body /html页面中几个关键元素的用途networkStatus显示当前在线还是离线。todoForm待办输入表单。todoList待办列表容器所有待办项由 JavaScript 动态创建。syncBtn手动同步按钮在网络自动触发失败时给用户一个兜底操作入口。syncState当前同步状态文案。4.3 第三步编写基础样式打开css/style.css写入以下内容* { box-sizing: border-box; } body { margin: 0; font-family: system-ui, -apple-system, PingFang SC, Microsoft YaHei, sans-serif; background: #f5f7fa; color: #2d3748; } .app-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; background: #2b6cb0; color: #fff; } .app-header h1 { margin: 0; font-size: 20px; } .status { padding: 4px 10px; border-radius: 999px; font-size: 14px; } .status.online { background: #38a169; color: #fff; } .status.offline { background: #c05621; color: #fff; } .app-main { max-width: 720px; margin: 0 auto; padding: 20px; } .todo-form { display: flex; gap: 8px; margin-bottom: 16px; } .todo-form input { flex: 1; padding: 10px 12px; border: 1px solid #cbd5e0; border-radius: 6px; font-size: 16px; } .todo-form button { padding: 10px 20px; border: none; border-radius: 6px; background: #2b6cb0; color: #fff; font-size: 16px; cursor: pointer; } .todo-form button:hover { background: #2c5282; } .todo-list { list-style: none; margin: 0 0 16px; padding: 0; } .todo-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: #fff; border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 8px; } .todo-item.has-pending { border-left: 4px solid #d69e2e; } .todo-title { flex: 1; } .todo-title.completed { text-decoration: line-through; color: #a0aec0; } .todo-status { font-size: 12px; color: #d69e2e; background: #fefcbf; padding: 2px 8px; border-radius: 999px; } .delete-btn { border: none; background: transparent; color: #e53e3e; cursor: pointer; font-size: 14px; } .delete-btn:hover { text-decoration: underline; } .empty-tip { text-align: center; color: #a0aec0; padding: 40px 0; } .sync-panel { display: flex; align-items: center; gap: 10px; padding: 12px; background: #edf2f7; border-radius: 6px; } .sync-panel button { padding: 8px 16px; border: none; border-radius: 6px; background: #2f855a; color: #fff; cursor: pointer; } .sync-panel button:disabled { background: #a0aec0; cursor: not-allowed; } #syncState { font-size: 14px; color: #4a5568; }样式不是这篇文章的核心但它能让状态变化更直观尤其是带黄色左边框的“待同步”样式方便我们观察数据同步行为。4.4 第四步实现 IndexedDB 数据层打开js/db.js写入完整的数据层封装代码/** * 数据库封装层 * 使用 IndexedDB 存储待办事项与同步队列 */ const DB_NAME offline-todo-db; const DB_VERSION 1; const STORE_TODOS todos; const STORE_SYNC_OPS syncOps; function openDB() { return new Promise((resolve, reject) { const request indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded (event) { const db event.target.result; if (!db.objectStoreNames.contains(STORE_TODOS)) { const todoStore db.createObjectStore(STORE_TODOS, { keyPath: id }); todoStore.createIndex(syncStatus, syncStatus, { unique: false }); todoStore.createIndex(updatedAt, updatedAt, { unique: false }); } if (!db.objectStoreNames.contains(STORE_SYNC_OPS)) { db.createObjectStore(STORE_SYNC_OPS, { keyPath: opId, autoIncrement: true, }); } }; request.onsuccess (event) resolve(event.target.result); request.onerror (event) reject(event.target.error); }); } function saveTodo(todo) { return openDB().then((db) { return new Promise((resolve, reject) { const tx db.transaction(STORE_TODOS, readwrite); tx.objectStore(STORE_TODOS).put(todo); tx.oncomplete () resolve(todo); tx.onerror (event) reject(event.target.error); }); }); } function getAllTodos() { return openDB().then((db) { return new Promise((resolve, reject) { const tx db.transaction(STORE_TODOS, readonly); const request tx.objectStore(STORE_TODOS).getAll(); request.onsuccess () resolve(request.result || []); request.onerror (event) reject(event.target.error); }); }); } function deleteTodoById(id) { return openDB().then((db) { return new Promise((resolve, reject) { const tx db.transaction(STORE_TODOS, readwrite); tx.objectStore(STORE_TODOS).delete(id); tx.oncomplete () resolve(); tx.onerror (event) reject(event.target.error); }); }); } function markTodoSynced(id) { return openDB().then((db) { return new Promise((resolve, reject) { const tx db.transaction(STORE_TODOS, readwrite); const store tx.objectStore(STORE_TODOS); const request store.get(id); request.onsuccess () { const data request.result; if (data) { data.syncStatus synced; store.put(data); } }; tx.oncomplete () resolve(); tx.onerror (event) reject(event.target.error); }); }); } function addSyncOp(op) { return openDB().then((db) { return new Promise((resolve, reject) { const tx db.transaction(STORE_SYNC_OPS, readwrite); const request tx.objectStore(STORE_SYNC_OPS).add(op); request.onsuccess () resolve(request.result); request.onerror (event) reject(event.target.error); }); }); } function getPendingSyncOps() { return openDB().then((db) { return new Promise((resolve, reject) { const tx db.transaction(STORE_SYNC_OPS, readonly); const request tx.objectStore(STORE_SYNC_OPS).getAll(); request.onsuccess () resolve(request.result || []); request.onerror (event) reject(event.target.error); }); }); } function removeSyncOp(opId) { return openDB().then((db) { return new Promise((resolve, reject) { const tx db.transaction(STORE_SYNC_OPS, readwrite); tx.objectStore(STORE_SYNC_OPS).delete(opId); tx.oncomplete () resolve(); tx.onerror (event) reject(event.target.error); }); }); }这里有几个设计点需要说明todos表以id作为主键每条待办记录本身带有syncStatus字段用来区分“已同步”和“待同步”。syncOps表存放同步操作队列使用自增的opId作为主键。所有数据库操作都封装成 Promise 风格方便上层async/await调用。注意openDB()每次都会重新打开数据库连接。真实项目中可以优化为单例复用避免频繁创建连接。但这只是演示项目重复打开数据库的开销可以忽略。4.5 第五步实现模拟远程 API我们不搭建真实后端但为了让同步逻辑更接近生产形态我使用localStorage模拟一个远程存储空间。打开js/app.js先写入模拟 API 部分。/** * 主逻辑 * 负责网络状态监听、数据读写、同步队列调度 */ const REMOTE_STORAGE_PREFIX remote-todo-; function remoteKey(id) { return REMOTE_STORAGE_PREFIX id; } function simulateNetwork() { return new Promise((resolve, reject) { if (!navigator.onLine) { reject(new Error(当前网络不可用进入飞行模式)); return; } setTimeout(resolve, 200); }); } async function pushAdd(todo) { await simulateNetwork(); localStorage.setItem(remoteKey(todo.id), JSON.stringify(todo)); } async function pushUpdate(todo) { await simulateNetwork(); const old localStorage.getItem(remoteKey(todo.id)); if (!old) { throw new Error(远程数据不存在); } localStorage.setItem(remoteKey(todo.id), JSON.stringify(todo)); } async function pushDelete(id) { await simulateNetwork(); localStorage.removeItem(remoteKey(id)); }simulateNetwork函数通过navigator.onLine判断是否在线。如果你在 DevTools 的 Network 面板里把网络切换为 Offline这个函数就会 reject从而模拟出飞行模式下的离线状态。真实项目中这三个 push 函数应该替换成fetch调用例如async function pushAdd(todo) { const response await fetch(/api/todos, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(todo), }); if (!response.ok) throw new Error(同步失败); }4.6 第六步实现核心业务逻辑继续编写js/app.js加入全局状态、事件绑定、渲染和同步调度逻辑。let todoList []; document.addEventListener(DOMContentLoaded, init); function init() { registerServiceWorker(); bindEvents(); updateNetworkStatus(); loadTodos(); window.addEventListener(online, handleOnline); window.addEventListener(offline, handleOffline); } function bindEvents() { const form document.getElementById(todoForm); const syncBtn document.getElementById(syncBtn); form.addEventListener(submit, handleAddTodo); syncBtn.addEventListener(click, handleManualSync); } function registerServiceWorker() { if (!(serviceWorker in navigator)) { console.warn([SW] 当前浏览器不支持 Service Worker); return; } window.addEventListener(load, async () { try { await navigator.serviceWorker.register(./sw.js); console.log([SW] 注册成功); } catch (err) { console.warn([SW] 注册失败, err); } }); } function updateNetworkStatus() { const el document.getElementById(networkStatus); const online navigator.onLine; el.textContent online ? 在线 : 离线; el.classList.toggle(online, online); el.classList.toggle(offline, !online); } function handleOnline() { updateNetworkStatus(); syncPendingOps(); } function handleOffline() { updateNetworkStatus(); refreshSyncState(); }接下来是待办 CRUD 逻辑async function loadTodos() { todoList await getAllTodos(); todoList.sort((a, b) (b.updatedAt || 0) - (a.updatedAt || 0)); renderTodos(); } async function handleAddTodo(event) { event.preventDefault(); const input document.getElementById(todoInput); const title input.value.trim(); if (!title) return; const now Date.now(); const todo { id: generateId(), title, completed: false, syncStatus: pending, updatedAt: now, }; input.value ; await saveTodo(todo); todoList.unshift(todo); renderTodos(); await enqueueAndTrySync({ type: add, todo, createdAt: now, }); } async function handleToggleTodo(id) { const todo todoList.find((item) item.id id); if (!todo) return; todo.completed !todo.completed; todo.syncStatus pending; todo.updatedAt Date.now(); await saveTodo(todo); renderTodos(); await enqueueAndTrySync({ type: update, todo, createdAt: todo.updatedAt, }); } async function handleDeleteTodo(id) { const todo todoList.find((item) item.id id); await deleteTodoById(id); todoList todoList.filter((item) item.id ! id); renderTodos(); if (todo) { await enqueueAndTrySync({ type: delete, id, createdAt: Date.now(), }); } } function generateId() { if (window.crypto crypto.randomUUID) { return crypto.randomUUID(); } return todo- Date.now() - Math.random().toString(36).slice(2); }这里有一个设计细节每次新增、修改、删除后我们都调用enqueueAndTrySync。这个函数会把操作写入同步队列然后立即尝试同步。如果在线操作会马上推送成功如果离线操作会留在队列中等待网络恢复。同步队列与状态刷新逻辑如下async function enqueueAndTrySync(op) { await addSyncOp(op); await syncPendingOps(); } async function syncPendingOps() { const ops await getPendingSyncOps(); if (!ops.length) { refreshSyncState(); return; } for (const op of ops) { try { await executeSyncOp(op); if (op.todo) { await markTodoSynced(op.todo.id); } await removeSyncOp(op.opId); console.log([Sync] 操作成功:, op.type); } catch (err) { console.warn([Sync] 同步暂停等待网络恢复:, err.message); break; } } refreshSyncState(); loadTodos(); } async function executeSyncOp(op) { switch (op.type) { case add: await pushAdd(op.todo); break; case update: await pushUpdate(op.todo); break; case delete: await pushDelete(op.id); break; default: throw new Error(未知操作类型); } } async function handleManualSync() { const btn document.getElementById(syncBtn); btn.disabled true; try { await syncPendingOps(); } finally { btn.disabled false; } } async function refreshSyncState() { const el document.getElementById(syncState); const ops await getPendingSyncOps(); const pendingLocal todoList.filter( (item) item.syncStatus ! synced ).length; if (!navigator.onLine) { el.textContent 离线模式本地待同步 ${ops.length} 项; } else if (ops.length 0 pendingLocal 0) { el.textContent 所有数据已同步; } else { el.textContent 等待同步 ${ops.length} 项操作; } }最后是渲染逻辑function renderTodos() { const list document.getElementById(todoList); if (!todoList.length) { list.innerHTML li classempty-tip暂无待办添加一项试试吧/li; refreshSyncState(); return; } list.innerHTML ; todoList.forEach((todo) { const li document.createElement(li); li.className todo-item; const checkbox document.createElement(input); checkbox.type checkbox; checkbox.checked todo.completed; checkbox.addEventListener(change, () handleToggleTodo(todo.id)); const titleSpan document.createElement(span); titleSpan.className todo-title; if (todo.completed) { titleSpan.classList.add(completed); } titleSpan.textContent todo.title; const deleteBtn document.createElement(button); deleteBtn.className delete-btn; deleteBtn.textContent 删除; deleteBtn.addEventListener(click, () handleDeleteTodo(todo.id)); li.appendChild(checkbox); li.appendChild(titleSpan); if (todo.syncStatus ! synced) { li.classList.add(has-pending); const statusTag document.createElement(span); statusTag.className todo-status; statusTag.textContent 待同步; li.appendChild(statusTag); } li.appendChild(deleteBtn); list.appendChild(li); }); refreshSyncState(); }到这里js/app.js的完整内容就写完了。你可以把 4.5 和 4.6 两小节的代码合并到同一个文件中。4.7 第七步编写 Service Worker 与 PWA 配置新建sw.js/** * Service Worker * 缓存策略网络优先失败回退缓存 */ const CACHE_VERSION v1; const CACHE_NAME offline-todo-${CACHE_VERSION}; const CORE_ASSETS [ ./, ./index.html, ./css/style.css, ./js/db.js, ./js/app.js, ./manifest.json, ]; self.addEventListener(install, (event) { event.waitUntil( caches .open(CACHE_NAME) .then((cache) cache.addAll(CORE_ASSETS)) .then(() 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)) ) ) .then(() self.clients.claim()) ); }); self.addEventListener(fetch, (event) { if (event.request.method ! GET) { return; } event.respondWith( fetch(event.request) .then((response) { const copy response.clone(); caches.open(CACHE_NAME).then((cache) { cache.put(event.request, copy); }); return response; }) .catch(() caches .match(event.request) .then((cached) cached || caches.match(./index.html)) ) ); });这里解释一下 Service Worker 中的三个生命周期处理逻辑install中预缓存了 HTML、CSS、JS 和 manifest 文件。首次访问时这些文件就会被