我用 Node.js 写了一个离线可用的笔记工具,踩过的 7 个坑

举报
yd_232652410 发表于 2026/10/23 20:01:11 2026/10/23
【摘要】 记录一个离线笔记工具的完整开发过程:IndexedDB 选型、Safari 假死、Service Worker 缓存策略、版本更新、多标签页冲突、数据合并、写入节流等 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 是最省事但最容易丢数据的方案。

---

以上代码都在生产环境跑过,可以直接参考。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。