本地优先:为什么我重新爱上了本地工具 里聊过为什么本地优先值得追求。这篇换到工程视角:真的把「本地优先」落进一个具体项目,会遇到什么问题,怎么拆解,哪些方案值得用,哪些是过度设计。
本地优先三部曲
- 本地优先:为什么我重新爱上了本地工具 — 理念与心态
- 本地优先架构在个人项目中的实践 — 工程落地(本文)
- 博客写作语法指南 — 数字花园的语法工具箱
本地优先不是什么
先划清边界。本地优先不等于完全离线,也不等于不需要网络。它要解决的核心问题其实很简单:
数据的主副本在你手里,不在某个服务商的服务器上。
就这么回事。网络是协作工具,不是数据仓库。服务跑在云上没问题,但你的数据要能完整地在本地存一份。
三层架构
大多数本地优先应用可以拆成三层:
┌─────────────────────────────────────────┐
│ UI 层(渲染、交互) │
├─────────────────────────────────────────┤
│ 本地存储层(SQLite / IndexedDB / 文件) │
├─────────────────────────────────────────┤
│ 同步层(后台推拉、服务端副本) │
└─────────────────────────────────────────┘
UI 层和本地存储层紧耦合,读写都先走本地,本地完成了再异步推到远端。这里有个关键点:同步层失败了,不能让 UI 跟着卡住。这是一切体验的前提。
存储层:选什么
纯文件(Markdown / JSON)
最简单,也是最推荐的起点。
content/
blog/
my-post.md
config.json
优点是可迁移、可版本控制、任意编辑器都能打开、备份方案成熟(Git、Rsync、S3 都行)。缺点呢?两个人同时改一个文件会打架,不过对个人项目来说,这基本不是问题。
对于个人项目,文件是踩坑最少的方案。我的博客内容就是这套,所有文章是 Markdown,存在 Git 仓库里,GitHub 既是协作平台,也是天然备份。
SQLite
数据需要结构化查询、写入频率也不低的时候,SQLite 是个好选择。单文件数据库,可以和文件一起备份,同时有 SQL 查询的便利性。
// 轻量的 JS SQLite wrapper(Browser 环境用 sql.js)
import initSqlJs from 'sql.js';
const SQL = await initSqlJs();
const db = new SQL.Database();
// 初始化表
db.run(`
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT,
updated_at INTEGER
);
`);
// 写入
db.run(
"INSERT INTO posts VALUES (?, ?, ?, ?)",
[id, title, content, Date.now()]
);
// 查询
const result = db.exec("SELECT * FROM posts ORDER BY updated_at DESC");
Browser 端用 sql.js 需要加载 WASM,大约 1MB,写入时也要自己处理事务,这是它和文件方案比起来麻烦的地方。
IndexedDB
Browser 原生的 NoSQL 数据库。适合数据量大、结构不规整、或者需要索引的场景——比如要做搜索。
const DB_NAME = 'my-app';
const STORE = 'posts';
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve(req.result);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(STORE)) {
const store = db.createObjectStore(STORE, { keyPath: 'id' });
store.createIndex('updated', 'updatedAt');
}
};
});
}
// 写入
async function savePost(post) {
const db = await openDB();
const tx = db.transaction(STORE, 'readwrite');
await tx.objectStore(STORE).put({ ...post, updatedAt: Date.now() });
return tx.complete;
}
IndexedDB 比 SQLite 更适合建搜索索引,可以用多个 index 做复合查询,但 API 偏冗长,一般会包一层再用。
存储选型建议:
- 个人博客 / 文档类 → 纯文件
- 有结构化数据、偶尔查询 → SQLite
- Browser 端需要快速读写 → IndexedDB
- 混合场景 → 文件存内容 + SQLite/IndexedDB 存元数据
同步层:怎么把远端用起来又不失去主控权
这是本地优先最难的部分。不是说技术有多难,而是边界条件多。设备 A 离线时改了东西,设备 B 在线时也改了,网络还抖了一下,最后冲突了怎么办。
方案 A:单向拉取(最简单的可用方案)
数据主副本在 GitHub(或者任何 Git 托管服务),本地改了之后手动 git push,其他设备 git pull 来同步。
本地修改 → git commit → git push
另一台设备 → git pull
这套方案不需要额外的基础设施,Git 的版本历史本身就是冲突记录。对不追求多设备实时一致的个人项目来说,这套完全够用。我的博客文章管理就是走的这条路——用 Obsidian 写完草稿,commit 上去,CI 构建时从 Git 拉取发布。
方案 B:Polling 拉取(折中)
定时轮询远端,发现变化就合并进来。不需要 WebSocket,实现简单,断网时自动跳过不丢数据。但有延迟,最小粒度是轮询间隔,合并策略也得自己写。
// 简单 polling 示例
async function pollChanges(since) {
const resp = await fetch(`/api/changes?since=${since}`);
const { items, cursor } = await resp.json();
for (const item of items) {
await localDB.put(item); // 写入本地
}
return cursor;
}
setInterval(async () => {
const lastCursor = localStorage.getItem('sync_cursor');
const cursor = await pollChanges(lastCursor);
localStorage.setItem('sync_cursor', cursor);
}, 30_000); // 每 30 秒轮询一次
方案 C:事件驱动同步(自建服务推荐用)
服务端维护一个变更日志(Change Log),客户端通过 WebSocket 或 Server-Sent Events 订阅变更,实时收到推送。
服务端变更 → 写 Change Log → WebSocket 推送 → 客户端合并 → 更新本地
需要自己搭服务端吗?不一定,已有成熟服务可以接入:
- GitHub + Webhooks:Git 仓库有变更时触发 Webhook,推送给自建服务,广播给所有客户端
- Cloudflare D1 + Durable Objects:CF 的全球同步数据库,KV 层支持原子操作
- Twikoo(评论系统):云端存储评论,但每条评论有完整的增删改记录,客户端做 diff merge
我博客的访客评论就是这个思路。Twikoo 在腾讯云存储评论,每次打开页面从 Twikoo API 拉最新评论。如果想做得更彻底,把评论缓存到 IndexedDB,离线时从缓存读,在线时拉取更新就行。
// 评论同步的简化逻辑
async function syncComments() {
const local = await db.getAll('comments');
const remote = await twikoo.getComments();
// 简单 LWW(Last Write Wins)合并
const merged = mergeByTimestamp(local, remote);
await db.clear('comments');
for (const c of merged) await db.put('comments', c);
}
冲突处理:最被高估的部分
很多人设计本地优先系统时,会在冲突处理上花大量精力,研究 CRDT、OT、向量时钟……然后发现实际场景里冲突少之又少。
动手之前先问自己三个问题:
- 这个数据的并发写入频率有多高?(个人项目通常很低)
- 冲突的后果有多严重?(评论多一条少一条 vs 账本数字错乱)
- 用户能接受什么程度的自动合并?(文字冲突让用户手动选 vs 系统猜一个)
大多数个人项目,前两个问题的答案都是「很低」和「可接受」。第一步就上 CRDT,有点用火箭筒打蚊子的意思。
简单有效的冲突策略
LWW(Last Write Wins):每条数据记录 updatedAt,发生冲突时取时间戳更大的那个。实现最简单,大多数场景够用。
function mergeLWW(local, remote) {
const map = new Map();
for (const item of local) map.set(item.id, item);
for (const item of remote) {
const existing = map.get(item.id);
if (!existing || item.updatedAt > existing.updatedAt) {
map.set(item.id, item);
}
}
return Array.from(map.values());
}
字段级合并:对结构化对象,逐字段比较更新时间,取较新的值。
function mergeFields(local, remote) {
return {
id: remote.id ?? local.id,
title: remote.updatedAt > local.updatedAt ? remote.title : local.title,
content: remote.updatedAt > local.updatedAt ? remote.content : local.content,
updatedAt: Math.max(local.updatedAt, remote.updatedAt),
};
}
CRDT 真正的用武之地是多人实时协同编辑,多个人同时改同一段落——这是 Notion 和 Linear 解决的问题。自己的项目没有这个需求的话,就不用上 CRDT 了。
案例:博客评论的半本地优先改造
我的博客评论系统(Twikoo)本来是完全云端方案。去年加了个小改造:评论缓存到 IndexedDB。
用户打开页面
→ 先从 IndexedDB 读缓存评论(立即渲染,无loading)
→ 同时从 Twikoo API 拉取最新评论
→ 比对版本号,有更新则写入 IndexedDB 并刷新 UI
这个改造大约只增加了 50 行代码,体验提升却很明显。打开页面时评论区域不再是空白的 loading,而是直接显示上次的内容。只有首次访问(冷缓存)时会有短暂等待,之后就顺滑了。
这是本地优先改造的正确姿势,不需要重构整个系统,只需要在「远端数据」上加一层「本地缓存」,让 UI 永远有东西可读。
再往前走一步,可以做成 Service Worker 拦截 API 响应,缓存所有 GET 请求,完全不需要改业务代码。但这已经接近 PWA 范畴了,要不要做,取决于你对这个细节的在意程度。
什么时候本地优先是过度设计
说了这么多正向例子,也要诚实说几句反话。
不需要本地优先的场景:
- 数据完全由机器生成,没有用户编辑(比如监控指标、API 日志)
- 单设备使用,永远不会多端同步
- 数据本身不重要,丢了无所谓(比如临时草稿)
- 强实时协作是核心功能(多人同时编辑同一个表格)
本地优先不适合的场景:
- 数据量极大(TB 级别的文件库,带宽和存储都撑不住全量同步)
- 需要强一致性的事务操作(金融、库存系统)
- 团队协作场景(这不是个人项目的范畴)
什么时候工程上要喊停:
当发现自己花了两周写同步逻辑,但这个功能只影响 5% 的用户行为时,就是停下来重新想一想的时机。工具的价值在于解决问题,不是在每个维度都追求极致。
结语
本地优先的核心工程原则其实很朴素:
- 本地是主副本,远端是镜像和备份
- UI 永远先读写本地,不依赖网络
- 同步是后台异步的,失败了重试,不阻塞用户
- 冲突处理从简单方案开始,按需升级复杂度
- 存储选型匹配数据特征,文件够用就不上数据库
做到这五点,已经是一个合格的本地优先系统了。剩下的优化,都是锦上添花。
有问题或想法,评论区见。
三部曲结尾
如果这是你本地优先之旅的起点,建议从 本地优先:为什么我重新爱上了本地工具 开始;如果你是来找工程方案的,欢迎继续阅读这篇,或直接跳到 博客写作语法指南 看看数字花园里还有什么写作工具。
种下你的想法
在花园里留下一条评论,和这篇文章一起生长。