我用 Node.js 写了一个离线可用的笔记工具,踩过的 7 个坑
我用 Node.js 写了一个"离线可用"的笔记工具,踩过的 7 个坑
给团队做一个内部笔记工具,需求很朴素:打开网页就能写,断网也能用,换设备数据能同步。
听起来简单,真做起来踩了一堆坑。这篇把关键决策和踩坑记录整理出来,都是可以直接抄的代码。
## 一、为什么最后选了 IndexedDB 而不是 localStorage
第一版我用 localStorage,写到第 3 天就崩了。
**问题**:localStorage 只有 5MB,而且同步阻塞主线程。笔记里有图片 base64,几百条就把配额撑爆。
```javascript
// localStorage 写入图片,直接报错
localStorage.setItem('note-1', JSON.stringify({
title: 'xxx',
images: [base64String] // 一张图 500KB
}));
// Uncaught DOMException: Failed to execute 'setItem' on 'Storage':
// Setting the value of 'note-1' exceeded the quota.
```
换成 IndexedDB 后,配额变成磁盘的 60%(Chrome)或 15%(Safari),基本不可能写满。
```javascript
const DB_NAME = 'notes-db';
const STORE = 'notes';
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(STORE)) {
const store = db.createObjectStore(STORE, { keyPath: 'id' });
store.createIndex('updatedAt', 'updatedAt');
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function saveNote(note) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put({ ...note, updatedAt: Date.now() });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
```
## 二、坑:Safari 的 IndexedDB 会"假死"
这个坑让我调了整整一个下午。
Safari 在隐私模式下,`indexedDB.open()` 不报错,也不触发 `onsuccess`,就一直挂着。
**解决**:加超时保护,同时检测隐私模式。
```javascript
function openDBWithTimeout(timeoutMs = 3000) {
return Promise.race([
openDB(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('IndexedDB timeout')), timeoutMs)
)
]);
}
// 使用
try {
const db = await openDBWithTimeout(3000);
// 正常使用
} catch (err) {
if (err.message === 'IndexedDB timeout') {
console.warn('可能是隐私模式,降级到内存存储');
useMemoryFallback();
}
}
```
## 三、坑:离线缓存策略选错,用户看到旧版本
Service Worker 缓存策略有三类,用错了很麻烦。
```javascript
// ❌ 错误:Cache First,用户永远看到旧版本
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then(r => r || fetch(event.request))
);
});
// ✅ 正确:HTML 用 Network First,静态资源用 Cache First
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// HTML 走网络优先,离线时回落缓存
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request)
.then((resp) => {
const clone = resp.clone();
caches.open('html-cache').then(c => c.put(event.request, clone));
return resp;
})
.catch(() => caches.match(event.request))
);
return;
}
// 静态资源(js/css/图片)走缓存优先
if (/\.(js|css|woff2|png|jpg|svg)$/.test(url.pathname)) {
event.respondWith(
caches.match(event.request).then(r => r || fetch(event.request))
);
return;
}
// 其他走网络
event.respondWith(fetch(event.request));
});
```
**关键**:HTML 永远不要 Cache First。否则发版后用户刷新十次还是旧页面。
## 四、坑:版本号没变,Service Worker 不更新
改完 Service Worker 代码,测试环境正常,线上用户还是旧逻辑。
原因:浏览器对比 `sw.js` 的字节内容,如果内容没变(哪怕是注释改了但压缩后一样),就不触发更新。
**解决**:每次构建时给 sw.js 注入版本号。
```javascript
// build.js
const fs = require('fs');
const sw = fs.readFileSync('sw.js', 'utf8');
const version = Date.now();
fs.writeFileSync('dist/sw.js', sw.replace('__VERSION__', version));
// sw.js
const VERSION = '__VERSION__';
const CACHE_NAME = `notes-v${VERSION}`;
```
同时在注册时主动检查更新:
```javascript
// app.js
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then((reg) => {
// 每小时检查一次更新
setInterval(() => reg.update(), 60 * 60 * 1000);
reg.addEventListener('updatefound', () => {
const worker = reg.installing;
worker.addEventListener('statechange', () => {
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
// 有新版本,提示用户刷新
showUpdateToast();
}
});
});
});
}
```
## 五、坑:多标签页数据冲突
用户同时开两个标签页,在 A 页改笔记,B 页看不到,然后在 B 页保存,A 页的修改就丢了。
**解决**:用 BroadcastChannel 同步状态。
```javascript
const channel = new BroadcastChannel('notes-sync');
// 保存后广播
async function saveNote(note) {
await dbSave(note);
channel.postMessage({ type: 'note-updated', id: note.id });
}
// 监听其他标签页的变更
channel.onmessage = (e) => {
if (e.data.type === 'note-updated') {
reloadNoteFromDB(e.data.id);
showToast('笔记已在其他标签页更新');
}
};
```
BroadcastChannel 兼容性现在已经很好(Safari 15.4+),不支持的话降级到 `storage` 事件。
## 六、坑:冲突合并搞成了"最后写入胜出"
同步到云端的逻辑我第一版用的"最后写入胜出"(LWW),结果用户丢了数据。
**问题场景**:
- 设备 A 离线写了 3 条笔记
- 设备 B 在线改了同一条
- A 上线,时间戳比 B 晚,直接覆盖
- B 的修改没了
**解决**:给每条笔记加版本号和设备 ID。
```javascript
async function mergeNotes(local, remote) {
if (local.version === remote.version) return local;
// 版本不同,检查是不是同一设备改的
if (local.deviceId === remote.deviceId) {
// 同设备,取版本高的
return local.version > remote.version ? local : remote;
}
// 不同设备,保留两份,标记冲突
return {
...(local.updatedAt > remote.updatedAt ? local : remote),
conflict: {
local: local.content,
remote: remote.content
}
};
}
```
界面上给用户一个"合并冲突"的提示,让用户自己选。
## 七、坑:写操作太频繁,把电池和 CPU 吃光
每次按键都触发保存,用户打字快的时候 1 秒写 20 次 IndexedDB,笔记本风扇都转起来了。
**解决**:防抖 + 定期落盘。
```javascript
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const saveDebounced = debounce(saveNote, 800);
// 打字时只更新内存
editor.addEventListener('input', () => {
currentNote.content = editor.value;
saveDebounced(currentNote); // 800ms 后才真正落盘
});
// 关闭页面前强制保存
window.addEventListener('beforeunload', (e) => {
if (hasUnsavedChanges) {
saveNote(currentNote); // 同步保存
e.preventDefault();
e.returnValue = '';
}
});
```
## 最后
整个工具做完,去掉框架,最终产物 38KB(gzip 后 12KB)。核心经验就三条:
**1. 存储选型跟着数据量走**
5MB 以内 localStorage,以上 IndexedDB。图片一定要单独存 Blob,不要 base64 塞 JSON。
**2. 离线能力要能"降级"**
Service Worker 不可用时,至少保证内存可用 + 提示用户。不要假设 API 一定存在。
**3. 同步逻辑比想象的复杂**
只要涉及多设备,就必须处理冲突。LWW 是最省事但最容易丢数据的方案。
---
以上代码都在生产环境跑过,可以直接参考。
- 点赞
- 收藏
- 关注作者
评论(0)