尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

构建离线优先Web应用:IndexedDB持久化与多端同步实战

构建离线优先Web应用:IndexedDB持久化与多端同步实战 你有没有遇到过这种情况在地铁上手机信号断断续续你打开浏览器里的笔记应用文思泉涌地写了2000多字。到站了你随手关掉浏览器标签页想着反正有“自动保存”。结果当你回到家打开电脑准备继续编辑时发现那2000字——消失了。浏览器缓存被清空或者仅仅是关闭了标签那些心血就仿佛从未存在过。更令人崩溃的场景是协作你和同事同时在修改同一份项目需求文档你这边刚调整完一个关键参数他那边也提交了修改。最后同步时要么你的修改被覆盖要么他的内容丢失甚至产生一堆无法理解的冲突文件。这不仅仅是“笔记丢了”这么简单它背后暴露的是现代Web应用尤其是那些标榜“离线可用”的应用在数据持久化与多端同步这两个核心体验上的设计缺陷。很多应用只是简单依赖浏览器的localStorage或sessionStorage或者对“同步”的理解停留在“有网络时上传一下”。今天我们不只谈问题更要给出一个可落地、高可用的解决方案。本文将深入剖析离线Web应用的数据持久化策略并构建一个具备冲突自动合并能力的多端同步机制。你会看到从基础的IndexedDB使用到利用Service Worker实现真正的离线优先再到设计一个基于操作转换OT或最终一致性模型的同步服务每一步都有具体的代码和架构思考。无论你是想为自己的个人项目增加可靠的离线能力还是需要在团队产品中解决令人头疼的同步冲突这篇文章都将提供从理论到实践的完整路径。我们不止步于“是什么”更要搞清楚“为什么”以及“如何做得更好”。1. 问题的本质为什么“保存”会失效在深入技术方案前我们必须先戳破几个常见的认知误区。很多人认为“离线笔记”就是让应用在没网时也能运行数据存浏览器里就行。这种想法是灾难的起点。误区一localStorage是万能的离线存储。localStorage确实简单易用setItem和getItem就搞定。但它有致命缺陷容量限制通常每个域名下只有 5MB。一篇带图片的长文就可能突破上限。同步阻塞它是同步 API大量数据读写会阻塞页面主线程导致应用卡顿。仅限字符串存储复杂对象需要JSON.stringify和parse性能有损耗且不适用于二进制数据。无事务、无索引无法进行复杂的查询也无法保证一系列操作的原子性。误区二关浏览器标签等于应用“正常退出”。浏览器标签页的关闭行为非常复杂。用户可能直接关闭浏览器窗口也可能只是关闭标签。浏览器在内存紧张时可能会主动清除非活动页面的数据。依赖beforeunload事件做最后的“挽救式保存”是不可靠的因为这个事件可能根本不会被触发例如浏览器崩溃、系统断电。误区三同步就是“定时上传备份”。这是最危险的误区。简单的“最后修改者胜”策略Last-Write-Wins在协作场景下就是数据毁灭者。想象一下设备A离线时修改了标题设备B在线时修改了内容。如果仅仅按时间戳覆盖总会有一方的修改完全丢失。真正的同步需要冲突检测与解决策略。所以一个健壮的离线笔记应用必须解决三个核心问题可靠持久化数据必须以一种可靠、大容量、非阻塞的方式存储在客户端。离线优先应用逻辑应默认从本地存储读取和写入网络仅用于同步。智能同步当网络恢复时能安全、智能地将多端的变更合并而不是粗暴覆盖。接下来我们将用一个渐进式的项目一步步解决这些问题。2. 技术栈选型与核心原理我们将构建一个名为“SolidNote”的简易离线笔记应用原型。为了聚焦核心问题前端我们使用纯原生 JavaScript (ES6) 配合一些现代浏览器 API后端同步服务使用 Node.js Express 搭建。核心浏览器 APIIndexedDB: 客户端核心数据库。提供异步、事务化、支持索引和游标的大容量存储通常数百MB甚至更多。它是解决localStorage所有缺陷的答案。Service Worker: 实现“离线优先”架构的关键。它可以拦截网络请求从缓存返回资源甚至在后端同步数据。它让应用像原生App一样在离线时也能加载。Cache API: 通常与 Service Worker 配合用于缓存静态资源HTML, CSS, JS, 图片保证应用壳App Shell的离线可用性。Background Sync API: 实验性允许 Service Worker 在网络恢复后自动执行推迟的任务如提交本地数据到服务器。同步策略选型对于数据同步业界主要有两种成熟模型操作转换 (Operational Transformation, OT)Google Docs 使用的技术。它通过转换并排序所有客户端的操作如“在位置5插入‘A’”来达成一致性。逻辑复杂但实时协作体验好。冲突无关的数据类型 (Conflict-Free Replicated Data Types, CRDT)一种更优雅的数学模型。设计良好的 CRDT 数据结构保证无论操作以何种顺序执行最终状态都是一致的。它正逐渐成为离线同步的新宠如 Figma、Linear 等产品都在使用。为了平衡复杂度和演示效果我们的原型将采用一种简化版基于版本向量的最终一致性模型并实现一个简单的文本差异合并算法来处理冲突。这虽然不是完整的 CRDT但足以让你理解核心思想并应对大多数个人或小团队场景。架构概览[用户设备A] --- [浏览器中 SolidNote 应用] | | (IndexedDB) (Service Worker) | | ———— [网络] ———— [同步服务器 (Node.js)] | (中央数据库) | ———— [网络] ———— [同步服务器] | | [用户设备B] --- [浏览器中 SolidNote 应用] | | (IndexedDB) (Service Worker)在这个架构中每个设备都拥有数据的完整本地副本。编辑操作首先写入本地 IndexedDB然后尝试通过 Service Worker 或前台逻辑与中央服务器同步。服务器不负责业务逻辑只作为“真相”的协调者存储所有设备的操作日志或最终状态并负责分发变更。3. 环境准备与项目初始化我们从前端开始。确保你使用的是一个现代浏览器Chrome 80 Firefox 75 Edge 80并开启开发者工具中的“Application”面板我们将频繁使用它来查看 Storage 和 Service Worker。3.1 前端项目结构创建一个新的项目目录solid-note-demo结构如下solid-note-demo/ ├── public/ # 静态资源 │ ├── index.html │ ├── app.js # 主应用逻辑 │ ├── db.js # IndexedDB 封装 │ ├── sync.js # 同步逻辑 │ └── sw.js # Service Worker 脚本 ├── server/ # 同步服务器 │ └── index.js ├── package.json └── README.md3.2 初始化前端依赖我们不需要复杂的构建工具。在public/index.html中引入我们的脚本并创建一个简单的 UI。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleSolidNote - 可靠的离线笔记/title style body { font-family: sans-serif; max-width: 800px; margin: 20px auto; padding: 20px; } #status { padding: 10px; margin-bottom: 20px; border-radius: 5px; } .online { background-color: #d4edda; color: #155724; } .offline { background-color: #f8d7da; color: #721c24; } #noteList { border: 1px solid #ccc; min-height: 200px; margin-bottom: 20px; padding: 10px; } .note-item { padding: 8px; border-bottom: 1px solid #eee; cursor: pointer; } .note-item:hover { background-color: #f8f9fa; } #editor { width: 100%; height: 300px; padding: 10px; font-size: 16px; box-sizing: border-box; } button { padding: 10px 15px; margin-right: 10px; cursor: pointer; } /style /head body h1SolidNote/h1 div idstatus检测网络状态.../div div button onclickcreateNewNote()新建笔记/button button onclicksaveCurrentNote()保存/button button onclicktriggerSync()手动同步/button /div div styledisplay: flex; div stylewidth: 30%; h3笔记列表/h3 div idnoteList加载中.../div /div div stylewidth: 70%; h3编辑区/h3 input typetext idnoteTitle placeholder笔记标题 stylewidth:100%; padding: 10px; margin-bottom: 10px; textarea ideditor placeholder开始记录.../textarea /div /div script srcdb.js/script script srcsync.js/script script srcapp.js/script script // 注册 Service Worker if (serviceWorker in navigator) { window.addEventListener(load, () { navigator.serviceWorker.register(/sw.js).then(() { console.log(Service Worker 注册成功); }).catch(err { console.log(Service Worker 注册失败: , err); }); }); } // 监听网络状态 window.addEventListener(online, updateOnlineStatus); window.addEventListener(offline, updateOnlineStatus); function updateOnlineStatus() { const statusEl document.getElementById(status); if (navigator.onLine) { statusEl.textContent ✅ 在线 - 可以同步; statusEl.className status online; } else { statusEl.textContent ⚠️ 离线 - 编辑将保存在本地; statusEl.className status offline; } } updateOnlineStatus(); // 初始化 /script /body /html4. 核心流程拆解从持久化到同步4.1 第一步用 IndexedDB 实现可靠持久化我们首先封装 IndexedDB 的操作。在public/db.js中我们将创建一个NoteDB类。// public/db.js class NoteDB { constructor(dbName SolidNoteDB, version 1) { this.dbName dbName; this.dbVersion version; this.db null; } // 打开或创建数据库 open() { return new Promise((resolve, reject) { const request indexedDB.open(this.dbName, this.dbVersion); request.onerror (event) reject(event.target.error); request.onsuccess (event) { this.db event.target.result; resolve(this.db); }; // 仅在版本更新时创建对象仓库 request.onupgradeneeded (event) { const db event.target.result; // 创建笔记存储仓库 if (!db.objectStoreNames.contains(notes)) { const noteStore db.createObjectStore(notes, { keyPath: id }); // 创建索引以便按标题或更新时间查询 noteStore.createIndex(updatedAt, updatedAt, { unique: false }); noteStore.createIndex(title, title, { unique: false }); } // 创建待同步操作队列 if (!db.objectStoreNames.contains(syncQueue)) { const queueStore db.createObjectStore(syncQueue, { keyPath: id, autoIncrement: true }); queueStore.createIndex(noteId, noteId, { unique: false }); queueStore.createIndex(type, type, { unique: false }); // create, update, delete } }; }); } // 获取所有笔记按更新时间倒序 async getAllNotes() { if (!this.db) await this.open(); return new Promise((resolve, reject) { const transaction this.db.transaction([notes], readonly); const store transaction.objectStore(notes); const index store.index(updatedAt); const request index.openCursor(null, prev); // 反向游标最新的在前 const notes []; request.onsuccess (event) { const cursor event.target.result; if (cursor) { notes.push(cursor.value); cursor.continue(); } else { resolve(notes); } }; request.onerror (event) reject(event.target.error); }); } // 保存或更新笔记 async saveNote(note) { if (!this.db) await this.open(); // 确保笔记有 id 和更新时间戳 note.id note.id || note_${Date.now()}_${Math.random().toString(36).substr(2, 9)}; note.updatedAt Date.now(); note._version (note._version || 0) 1; // 本地版本号用于冲突检测 note._synced false; // 标记为未同步 return new Promise((resolve, reject) { const transaction this.db.transaction([notes, syncQueue], readwrite); const noteStore transaction.objectStore(notes); const queueStore transaction.objectStore(syncQueue); // 1. 保存笔记本身 const putRequest noteStore.put(note); putRequest.onsuccess () { // 2. 将更新操作记录到同步队列 const queueItem { noteId: note.id, type: update, data: note, timestamp: Date.now() }; queueStore.add(queueItem); resolve(note); }; putRequest.onerror (event) reject(event.target.error); }); } // 获取待同步的操作 async getPendingSyncItems(limit 50) { if (!this.db) await this.open(); return new Promise((resolve, reject) { const transaction this.db.transaction([syncQueue], readonly); const store transaction.objectStore(syncQueue); const request store.getAll(null, limit); request.onsuccess (event) resolve(event.target.result); request.onerror (event) reject(event.target.error); }); } // 清除已同步的操作 async clearSyncedItems(ids) { if (!this.db) await this.open(); return new Promise((resolve, reject) { const transaction this.db.transaction([syncQueue], readwrite); const store transaction.objectStore(syncQueue); ids.forEach(id store.delete(id)); transaction.oncomplete () resolve(); transaction.onerror (event) reject(event.target.error); }); } } // 创建全局实例 window.noteDB new NoteDB();这个NoteDB类做了几件关键事建立了两个对象仓库notes存放笔记数据syncQueue存放待同步的操作日志。每次保存笔记 (saveNote) 时会自动递增_version并标记_synced false同时将操作记录到syncQueue。这是实现离线编辑和后续同步的基础。提供了获取待同步项和清理已同步项的方法。4.2 第二步实现离线优先与后台同步接下来我们创建 Service Worker 文件public/sw.js。它的首要任务是缓存静态资源让应用在离线时也能加载。// public/sw.js const CACHE_NAME solid-note-v1; const STATIC_ASSETS [ /, /index.html, /app.js, /db.js, /sync.js ]; // 安装阶段预缓存关键资源 self.addEventListener(install, event { console.log([Service Worker] 安装中...); event.waitUntil( caches.open(CACHE_NAME) .then(cache { console.log([Service Worker] 缓存静态资源); return cache.addAll(STATIC_ASSETS); }) .then(() self.skipWaiting()) // 强制激活新的 SW ); }); // 激活阶段清理旧缓存 self.addEventListener(activate, event { console.log([Service Worker] 激活中...); event.waitUntil( caches.keys().then(cacheNames { return Promise.all( cacheNames.map(cacheName { if (cacheName ! CACHE_NAME) { console.log([Service Worker] 清除旧缓存:, cacheName); return caches.delete(cacheName); } }) ); }).then(() self.clients.claim()) // 立即控制所有客户端 ); }); // 拦截网络请求优先从缓存返回失败则请求网络 self.addEventListener(fetch, event { // 对于笔记数据的 API 请求我们走网络并希望失败时能有兜底这里先简单处理 if (event.request.url.includes(/api/)) { // 对 API 请求使用“网络优先失败无缓存”策略 event.respondWith( fetch(event.request).catch(error { console.error([SW] API 请求失败:, error); // 可以在这里返回一个默认的离线响应比如空数组 return new Response(JSON.stringify({ offline: true, data: [] }), { headers: { Content-Type: application/json } }); }) ); } else { // 对于静态资源使用“缓存优先”策略 event.respondWith( caches.match(event.request) .then(response { // 缓存命中则返回 if (response) { return response; } // 否则请求网络并缓存对于非核心资源也可以不缓存 return fetch(event.request).then(response { // 只缓存成功的响应 if (response response.status 200) { const responseToCache response.clone(); caches.open(CACHE_NAME).then(cache { cache.put(event.request, responseToCache); }); } return response; }); }) ); } }); // 监听后台同步事件需要浏览器支持并已注册 self.addEventListener(sync, event { if (event.tag sync-notes) { console.log([Service Worker] 后台同步触发); event.waitUntil(syncPendingNotes()); } }); // 后台同步逻辑 async function syncPendingNotes() { // 这里需要与主页面通信或者通过 IndexedDB 直接读取 syncQueue // 为了简化我们假设主页面会处理同步这里仅作日志记录 console.log([SW] 执行后台同步逻辑); // 实际项目中这里应调用 sync.js 中的同步函数 }这个 Service Worker 确保了应用外壳HTML, JS, CSS被缓存离线可访问。对静态资源的请求优先从缓存读取提升加载速度并实现离线访问。为未来的后台同步 (sync事件) 预留了接口。当用户离线时我们可以将同步任务推迟待网络恢复后由 Service Worker 自动执行。4.3 第三步构建同步逻辑与冲突解决这是最核心的部分。我们在public/sync.js中实现与服务器的同步以及简单的冲突合并。首先我们定义一个简单的差异计算和合并函数这里使用一个非常基础的算法生产环境建议使用diff-match-patch等成熟库。// public/sync.js class SyncManager { constructor(serverBaseUrl http://localhost:3000/api) { this.serverBaseUrl serverBaseUrl; this.isSyncing false; } // 计算文本差异简易版仅用于演示 static calculateDiff(oldText, newText) { // 这是一个极其简化的示例。实际应使用 Myers diff 等算法。 // 这里我们假设差异就是整段替换并记录变化部分。 if (oldText newText) { return null; // 无变化 } // 简单找出第一个不同字符的位置和最后一个不同字符的位置 let start 0; let oldEnd oldText.length; let newEnd newText.length; while (start oldEnd start newEnd oldText[start] newText[start]) { start; } while (oldEnd start newEnd start oldText[oldEnd - 1] newText[newEnd - 1]) { oldEnd--; newEnd--; } return { type: replace, start, oldLength: oldEnd - start, newText: newText.substring(start, newEnd) }; } // 应用差异到文本 static applyDiff(text, diff) { if (!diff) return text; if (diff.type replace) { return text.substring(0, diff.start) diff.newText text.substring(diff.start diff.oldLength); } return text; } // 合并两个变更简易冲突解决策略合并差异 static mergeChanges(localNote, remoteNote) { // 如果远程版本更新或者本地未修改直接采用远程 if (remoteNote._serverVersion localNote._serverVersion || localNote._synced) { console.log(采用远程版本); return { ...remoteNote, _synced: true }; } // 如果本地版本更新尝试合并内容 console.log(尝试合并本地与远程版本); const mergedNote { ...localNote }; // 合并标题如果都修改了以本地为主可定义更复杂规则 if (localNote.title ! remoteNote.title remoteNote.title ! localNote._lastSyncedTitle) { mergedNote.title localNote.title; // 或 remoteNote.title或标记冲突 } // 合并内容计算并应用差异 const localDiff this.calculateDiff(localNote._lastSyncedContent || , localNote.content); const remoteDiff this.calculateDiff(localNote._lastSyncedContent || , remoteNote.content); let mergedContent localNote._lastSyncedContent || ; if (localDiff) mergedContent this.applyDiff(mergedContent, localDiff); if (remoteDiff) mergedContent this.applyDiff(mergedContent, remoteDiff); mergedNote.content mergedContent; // 更新版本和同步状态 mergedNote._serverVersion Math.max(localNote._serverVersion || 0, remoteNote._serverVersion || 0) 1; mergedNote._synced false; mergedNote.updatedAt Date.now(); return mergedNote; } // 执行同步获取待同步项发送到服务器处理响应 async performSync() { if (this.isSyncing) { console.log(同步正在进行中跳过); return; } this.isSyncing true; try { const pendingItems await window.noteDB.getPendingSyncItems(); if (pendingItems.length 0) { console.log(无待同步项); this.isSyncing false; return; } console.log(开始同步 ${pendingItems.length} 项); // 这里简化处理一次发送所有待同步项 const response await fetch(${this.serverBaseUrl}/sync, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ operations: pendingItems }) }); if (!response.ok) { throw new Error(同步请求失败: ${response.status}); } const result await response.json(); console.log(服务器响应:, result); // 处理服务器返回的冲突或确认信息 if (result.conflicts result.conflicts.length 0) { console.warn(发现冲突:, result.conflicts); // 对于每个冲突调用合并逻辑并更新本地数据库 for (const conflict of result.conflicts) { const localNote await this.getNoteFromDB(conflict.noteId); const merged SyncManager.mergeChanges(localNote, conflict.remoteNote); await window.noteDB.saveNote(merged); // 保存合并后的笔记会生成新的待同步项 } } // 同步成功清除已处理的队列项 const syncedIds pendingItems.map(item item.id); await window.noteDB.clearSyncedItems(syncedIds); // 拉取服务器上的最新变更 await this.pullUpdates(); } catch (error) { console.error(同步过程发生错误:, error); // 可以在这里实现指数退避重试逻辑 } finally { this.isSyncing false; } } async getNoteFromDB(noteId) { // 这里需要实现从 IndexedDB 获取单条笔记的逻辑为简洁省略 // 假设有一个 noteDB.getNote(id) 方法 return Promise.resolve({}); // 示例 } // 从服务器拉取更新 async pullUpdates() { try { const response await fetch(${this.serverBaseUrl}/notes?since${lastSyncTimestamp}); const updates await response.json(); for (const update of updates) { // 将服务器更新合并到本地 const localNote await this.getNoteFromDB(update.id); const merged SyncManager.mergeChanges(localNote || {}, update); await window.noteDB.saveNote(merged); } // 更新最后同步时间戳 // localStorage.setItem(lastSyncTimestamp, Date.now()); } catch (error) { console.error(拉取更新失败:, error); } } } // 创建全局同步管理器实例 window.syncManager new SyncManager();4.4 第四步编写主应用逻辑最后我们在public/app.js中编写 UI 交互逻辑将数据库和同步模块串联起来。// public/app.js let currentNoteId null; async function initApp() { await window.noteDB.open(); await loadNoteList(); // 监听网络恢复事件自动同步 window.addEventListener(online, () { console.log(网络恢复尝试同步); triggerSync(); }); // 也可以设置定时同步例如每2分钟一次 // setInterval(triggerSync, 120000); } async function loadNoteList() { const notes await window.noteDB.getAllNotes(); const listEl document.getElementById(noteList); listEl.innerHTML ; notes.forEach(note { const div document.createElement(div); div.className note-item; div.textContent ${note.title || 无标题} (${new Date(note.updatedAt).toLocaleTimeString()}); div.onclick () loadNoteForEdit(note.id); listEl.appendChild(div); }); } async function loadNoteForEdit(noteId) { // 这里需要实现从 IndexedDB 获取单条笔记为演示我们假设从 getAllNotes 结果中找 const notes await window.noteDB.getAllNotes(); const note notes.find(n n.id noteId); if (note) { currentNoteId note.id; document.getElementById(noteTitle).value note.title || ; document.getElementById(editor).value note.content || ; } } async function createNewNote() { const newNote { title: 新笔记, content: , createdAt: Date.now(), updatedAt: Date.now() }; const savedNote await window.noteDB.saveNote(newNote); currentNoteId savedNote.id; document.getElementById(noteTitle).value savedNote.title; document.getElementById(editor).value savedNote.content; await loadNoteList(); // 刷新列表 } async function saveCurrentNote() { const title document.getElementById(noteTitle).value.trim(); const content document.getElementById(editor).value; if (!currentNoteId) { // 如果没有当前笔记视为创建 const newNote { title, content }; await window.noteDB.saveNote(newNote); } else { // 更新现有笔记 const notes await window.noteDB.getAllNotes(); const note notes.find(n n.id currentNoteId); if (note) { note.title title; note.content content; await window.noteDB.saveNote(note); } } await loadNoteList(); alert(已保存到本地数据库); } async function triggerSync() { if (!navigator.onLine) { alert(当前离线无法同步。请检查网络连接。); return; } alert(开始同步...); await window.syncManager.performSync(); await loadNoteList(); // 同步后刷新列表 alert(同步完成); } // 自动保存防抖处理 let saveTimer null; document.getElementById(editor).addEventListener(input, () { if (saveTimer) clearTimeout(saveTimer); saveTimer setTimeout(saveCurrentNote, 2000); // 2秒后自动保存 }); document.getElementById(noteTitle).addEventListener(change, saveCurrentNote); // 初始化应用 window.onload initApp;5. 后端同步服务器实现前端已经具备了离线编辑和同步能力现在我们需要一个简单的服务器来协调数据。在server/index.js中我们使用 Node.js 和 Express 搭建。// server/index.js const express require(express); const bodyParser require(body-parser); const cors require(cors); const app express(); const PORT process.env.PORT || 3000; // 模拟一个简单的内存数据库 let notesDB {}; // key: noteId, value: { note data, serverVersion } let serverVersion 0; app.use(cors()); app.use(bodyParser.json()); // 获取自某个时间戳后的更新 app.get(/api/notes, (req, res) { const since parseInt(req.query.since) || 0; const updates Object.values(notesDB).filter(note note.updatedAt since); res.json(updates); }); // 同步端点接收客户端操作处理冲突返回结果 app.post(/api/sync, (req, res) { const clientOperations req.body.operations; const conflicts []; console.log(收到 ${clientOperations.length} 条操作); // 简化处理遍历每个操作尝试应用到服务器状态 for (const op of clientOperations) { const noteId op.noteId; const serverNote notesDB[noteId]; serverVersion; if (op.type update) { const clientNote op.data; // 冲突检测如果服务器存在该笔记且版本比客户端知道的更新 if (serverNote serverNote._serverVersion (clientNote._serverVersion || 0)) { console.log(检测到笔记 ${noteId} 的冲突); conflicts.push({ noteId, remoteNote: serverNote // 将服务器较新的版本返回给客户端解决 }); // 服务器保留自己的版本等待客户端解决后重新提交 continue; } // 无冲突接受客户端更新 notesDB[noteId] { ...clientNote, _serverVersion: serverVersion, updatedAt: Date.now() }; console.log(笔记 ${noteId} 更新到服务器版本 ${serverVersion}); } // 可以处理 create 和 delete 操作... } res.json({ success: true, serverVersion, conflicts, message: 已处理 ${clientOperations.length - conflicts.length} 条操作发现 ${conflicts.length} 处冲突 }); }); app.listen(PORT, () { console.log(SolidNote 同步服务器运行在 http://localhost:${PORT}); });运行服务器cd server npm init -y npm install express body-parser cors node index.js6. 运行结果与效果验证启动服务在server目录下运行node index.js。访问应用用浏览器打开http://localhost:3000你需要一个简单的静态文件服务器来托管public目录。可以使用npx serve public或任何你喜欢的静态服务器。离线编辑打开浏览器开发者工具 (F12)切换到Network标签选择Offline模拟断网。在应用中新建或编辑笔记点击保存。你会看到“已保存到本地数据库”的提示。即使刷新页面因为 Service Worker 缓存了资源你依然能看到刚才编辑的内容。关闭浏览器标签页甚至关闭浏览器重新打开应用笔记依然存在数据在 IndexedDB 中。同步测试将网络状态恢复为Online。点击“手动同步”按钮。观察控制台网络请求会看到向/api/sync发送的 POST 请求。打开另一个浏览器窗口或不同设备访问同一应用。等待几秒或手动同步应该能看到第一个窗口创建的笔记。冲突模拟在两个窗口代表设备A和设备B同时打开同一篇笔记。设备A离线修改内容并保存。设备B在线修改同一篇笔记的不同部分并保存触发同步。设备A恢复网络点击同步。在控制台你应该能看到服务器返回了conflicts数组并且我们的前端合并逻辑SyncManager.mergeChanges会被调用尝试合并两处修改。通过这个流程你验证了可靠持久化数据离线保存浏览器关闭不丢失。离线优先应用在无网络时完全可用。后台同步Service Worker 缓存了应用资源。冲突处理服务器检测到冲突客户端尝试自动合并。7. 常见问题与排查思路问题现象可能原因排查方式解决方案应用打开空白控制台报错Service Worker 注册或缓存失败1. 检查sw.js路径是否正确。2. 在开发者工具Application-Service Workers查看状态。3. 查看Console是否有 JS 错误。1. 确保sw.js可通过根路径访问。2. 尝试unregister旧的 Service Worker 并刷新。3. 检查STATIC_ASSETS缓存列表中的文件是否存在。笔记保存后刷新页面消失IndexedDB 未成功写入1. 在开发者工具Application-IndexedDB查看SolidNoteDB和notes对象仓库。2. 检查db.js中saveNote方法的 Promise 是否被正确处理。1. 确保await window.noteDB.open()在操作前已完成。2. 在saveNote的onsuccess回调中添加日志确认写入完成。同步按钮点击无反应网络请求未发出syncManager未定义或网络请求被阻止1. 检查sync.js是否被正确引入控制台是否有ReferenceError。2. 检查浏览器是否跨域 (CORS) 错误。1. 确保script srcsync.js标签在db.js之后。2. 确保后端服务器 (server/index.js) 已启用 CORS 并运行在正确端口。同步后冲突的修改被覆盖冲突合并策略过于简单或错误1. 在sync.js的mergeChanges方法中添加console.log查看合并逻辑。2. 检查服务器返回的conflicts数据结构是否正确。1. 实现更健壮的合并策略如使用三路合并 (Three-way merge)基于共同祖先版本。2. 对于无法自动解决的冲突提供用户界面让用户手动选择。Service Worker 更新后不生效旧 Service Worker 仍控制着页面1. 在开发者工具Application-Service Workers查看可能显示“waiting to activate”。1. 在install事件中调用self.skipWaiting()。2. 在activate事件中调用self.clients.claim()。3. 手动点击Update或Unregister。在隐身模式下 IndexedDB 无法使用某些浏览器隐身模式限制存储尝试在普通窗口打开应用。提示用户离线功能在隐身模式下可能受限建议使用普通模式或提醒数据可能被清除。8. 最佳实践与工程建议上面的原型演示了核心概念但要投入生产环境还需要考虑更多使用成熟的同步库不要重复造轮子。对于复杂的协作场景强烈考虑使用现有的 CRDT 库如Yjs一个功能强大的 CRDT 框架支持多种网络协议和持久化后端内置富文本、数组、Map等数据结构。Automerge一个基于 JSON 的 CRDT 库数据结构直观易于理解。ShareDB基于 OT 的实时同步后端与前端库配合使用。优化 IndexedDB 操作使用批量读写 (getAll,put数组) 提升性能。为频繁查询的字段建立合适的索引。注意事务的生命周期避免长时间持有事务导致阻塞。增强 Service Worker实现更精细的缓存策略如 Stale-While-Revalidate。利用Background SyncAPI 实现真正的后台数据同步即使用户关闭了标签页。考虑使用Workbox库来简化 Service Worker 的开发和维护。安全与认证为每个用户或设备分配唯一 ID。同步 API 必须进行身份验证如 JWT Token。服务器端需要对操作进行权限校验和合法性检查。数据模型设计为每个文档或笔记维护一个版本向量或逻辑时钟用于准确判断事件发生的先后顺序。存储完整的操作历史而不仅仅是最终状态以便进行回溯、撤销和更复杂的合并。用户体验在 UI 上清晰显示当前同步状态“已保存”、“同步中”、“有冲突”。提供冲突解决界面让用户查看差异并手动选择。实现增量同步只传输差异部分节省流量。测试策略单元测试核心的合并算法。进行端到端测试模拟网络断开、延迟、数据包乱序等场景。使用浏览器的开发者工具模拟不同的网络条件低速 3G、离线进行测试。从“一关浏览器全没了”到“离线编辑、多端无损同步”其间的鸿沟并非不可逾越。核心在于放弃对浏览器默认行为的幻想主动接管数据的生命周期。通过IndexedDB实现可靠存储通过Service Worker实现离线可用和后台同步再通过一个精心设计的、基于版本控制和冲突解决算法的同步层将孤岛连接起来。本文提供的原型是一个起点它展示了完整的技术栈和核心逻辑。你可以在此基础上引入Yjs这样的工业级 CRDT 库来替换我们简易的合并算法用Workbox来强化 Service Worker并构建一个更健壮的后端服务。记住可靠的数据同步不是一个功能而是一个需要从数据模型、网络层到 UI 层通盘考虑的系统性工程。
返回列表