☰
构建离线优先Web应用:IndexedDB持久化与多端同步实战
2026/10/9 14:17:22 网站建设 项目流程

你有没有遇到过这种情况:在地铁上,手机信号断断续续,你打开浏览器里的笔记应用,文思泉涌地写了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在线时修改了内容。如果仅仅按时间戳覆盖,总会有一方的修改完全丢失。真正的同步需要冲突检测与解决策略。

所以,一个健壮的离线笔记应用,必须解决三个核心问题:

  1. 可靠持久化:数据必须以一种可靠、大容量、非阻塞的方式存储在客户端。
  2. 离线优先:应用逻辑应默认从本地存储读取和写入,网络仅用于同步。
  3. 智能同步:当网络恢复时,能安全、智能地将多端的变更合并,而不是粗暴覆盖。

接下来,我们将用一个渐进式的项目,一步步解决这些问题。

2. 技术栈选型与核心原理

我们将构建一个名为“SolidNote”的简易离线笔记应用原型。为了聚焦核心问题,前端我们使用纯原生 JavaScript (ES6+) 配合一些现代浏览器 API,后端同步服务使用 Node.js + Express 搭建。

核心浏览器 API

  • IndexedDB: 客户端核心数据库。提供异步、事务化、支持索引和游标的大容量存储(通常数百MB甚至更多)。它是解决localStorage所有缺陷的答案。
  • Service Worker: 实现“离线优先”架构的关键。它可以拦截网络请求,从缓存返回资源,甚至在后端同步数据。它让应用像原生App一样,在离线时也能加载。
  • Cache API: 通常与 Service Worker 配合,用于缓存静态资源(HTML, CSS, JS, 图片),保证应用壳(App Shell)的离线可用性。
  • Background Sync API: (实验性)允许 Service Worker 在网络恢复后,自动执行推迟的任务,如提交本地数据到服务器。

同步策略选型

对于数据同步,业界主要有两种成熟模型:

  1. 操作转换 (Operational Transformation, OT):Google Docs 使用的技术。它通过转换并排序所有客户端的操作(如“在位置5插入‘A’”)来达成一致性。逻辑复杂,但实时协作体验好。
  2. 冲突无关的数据类型 (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.md

3.2 初始化前端依赖

我们不需要复杂的构建工具。在public/index.html中引入我们的脚本,并创建一个简单的 UI。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>SolidNote - 可靠的离线笔记</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> <h1>SolidNote</h1> <div id="status">检测网络状态...</div> <div> <button onclick="createNewNote()">新建笔记</button> <button onclick="saveCurrentNote()">保存</button> <button onclick="triggerSync()">手动同步</button> </div> <div style="display: flex;"> <div style="width: 30%;"> <h3>笔记列表</h3> <div id="noteList">加载中...</div> </div> <div style="width: 70%;"> <h3>编辑区</h3> <input type="text" id="noteTitle" placeholder="笔记标题" style="width:100%; padding: 10px; margin-bottom: 10px;"> <textarea id="editor" placeholder="开始记录..."></textarea> </div> </div> <script src="db.js"></script> <script src="sync.js"></script> <script src="app.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> </html>

4. 核心流程拆解:从持久化到同步

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类做了几件关键事:

  1. 建立了两个对象仓库:notes存放笔记数据,syncQueue存放待同步的操作日志。
  2. 每次保存笔记 (saveNote) 时,会自动递增_version并标记_synced = false,同时将操作记录到syncQueue。这是实现离线编辑和后续同步的基础。
  3. 提供了获取待同步项和清理已同步项的方法。

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.js

6. 运行结果与效果验证

  1. 启动服务:在server目录下运行node index.js。
  2. 访问应用:用浏览器打开http://localhost:3000(你需要一个简单的静态文件服务器来托管public目录。可以使用npx serve public或任何你喜欢的静态服务器)。
  3. 离线编辑:
    • 打开浏览器开发者工具 (F12),切换到Network标签,选择Offline模拟断网。
    • 在应用中新建或编辑笔记,点击保存。你会看到“已保存到本地数据库”的提示。
    • 即使刷新页面(因为 Service Worker 缓存了资源),你依然能看到刚才编辑的内容。
    • 关闭浏览器标签页,甚至关闭浏览器,重新打开应用,笔记依然存在(数据在 IndexedDB 中)。
  4. 同步测试:
    • 将网络状态恢复为Online。
    • 点击“手动同步”按钮。观察控制台网络请求,会看到向/api/sync发送的 POST 请求。
    • 打开另一个浏览器窗口(或不同设备),访问同一应用。等待几秒或手动同步,应该能看到第一个窗口创建的笔记。
  5. 冲突模拟:
    • 在两个窗口(代表设备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 src="sync.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. 最佳实践与工程建议

上面的原型演示了核心概念,但要投入生产环境,还需要考虑更多:

  1. 使用成熟的同步库:不要重复造轮子。对于复杂的协作场景,强烈考虑使用现有的 CRDT 库,如:

    • Yjs:一个功能强大的 CRDT 框架,支持多种网络协议和持久化后端,内置富文本、数组、Map等数据结构。
    • Automerge:一个基于 JSON 的 CRDT 库,数据结构直观,易于理解。
    • ShareDB:基于 OT 的实时同步后端,与前端库配合使用。
  2. 优化 IndexedDB 操作:

    • 使用批量读写 (getAll,put数组) 提升性能。
    • 为频繁查询的字段建立合适的索引。
    • 注意事务的生命周期,避免长时间持有事务导致阻塞。
  3. 增强 Service Worker:

    • 实现更精细的缓存策略,如 Stale-While-Revalidate。
    • 利用Background SyncAPI 实现真正的后台数据同步,即使用户关闭了标签页。
    • 考虑使用Workbox库来简化 Service Worker 的开发和维护。
  4. 安全与认证:

    • 为每个用户或设备分配唯一 ID。
    • 同步 API 必须进行身份验证(如 JWT Token)。
    • 服务器端需要对操作进行权限校验和合法性检查。
  5. 数据模型设计:

    • 为每个文档或笔记维护一个版本向量或逻辑时钟,用于准确判断事件发生的先后顺序。
    • 存储完整的操作历史,而不仅仅是最终状态,以便进行回溯、撤销和更复杂的合并。
  6. 用户体验:

    • 在 UI 上清晰显示当前同步状态(“已保存”、“同步中”、“有冲突”)。
    • 提供冲突解决界面,让用户查看差异并手动选择。
    • 实现增量同步,只传输差异部分,节省流量。
  7. 测试策略:

    • 单元测试核心的合并算法。
    • 进行端到端测试,模拟网络断开、延迟、数据包乱序等场景。
    • 使用浏览器的开发者工具模拟不同的网络条件(低速 3G、离线)进行测试。

从“一关浏览器全没了”到“离线编辑、多端无损同步”,其间的鸿沟并非不可逾越。核心在于放弃对浏览器默认行为的幻想,主动接管数据的生命周期。通过IndexedDB实现可靠存储,通过Service Worker实现离线可用和后台同步,再通过一个精心设计的、基于版本控制和冲突解决算法的同步层,将孤岛连接起来。

本文提供的原型是一个起点,它展示了完整的技术栈和核心逻辑。你可以在此基础上,引入Yjs这样的工业级 CRDT 库来替换我们简易的合并算法,用Workbox来强化 Service Worker,并构建一个更健壮的后端服务。记住,可靠的数据同步不是一个功能,而是一个需要从数据模型、网络层到 UI 层通盘考虑的系统性工程。

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

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

立即咨询