本地优先:为什么我重新爱上了本地工具 里聊过为什么本地优先值得追求。这篇换到工程视角:真的把「本地优先」落进一个具体项目,会遇到什么问题,怎么拆解,哪些方案值得用,哪些是过度设计。

本地优先三部曲

本地优先不是什么

先划清边界。本地优先不等于完全离线,也不等于不需要网络。它要解决的核心问题其实很简单:

数据的主副本在你手里,不在某个服务商的服务器上。

就这么回事。网络是协作工具,不是数据仓库。服务跑在云上没问题,但你的数据要能完整地在本地存一份。

三层架构

大多数本地优先应用可以拆成三层:

┌─────────────────────────────────────────┐
│  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、向量时钟……然后发现实际场景里冲突少之又少。

动手之前先问自己三个问题:

  1. 这个数据的并发写入频率有多高?(个人项目通常很低
  2. 冲突的后果有多严重?(评论多一条少一条 vs 账本数字错乱)
  3. 用户能接受什么程度的自动合并?(文字冲突让用户手动选 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% 的用户行为时,就是停下来重新想一想的时机。工具的价值在于解决问题,不是在每个维度都追求极致。

结语

本地优先的核心工程原则其实很朴素:

  1. 本地是主副本,远端是镜像和备份
  2. UI 永远先读写本地,不依赖网络
  3. 同步是后台异步的,失败了重试,不阻塞用户
  4. 冲突处理从简单方案开始,按需升级复杂度
  5. 存储选型匹配数据特征,文件够用就不上数据库

做到这五点,已经是一个合格的本地优先系统了。剩下的优化,都是锦上添花。


有问题或想法,评论区见。

三部曲结尾

如果这是你本地优先之旅的起点,建议从 本地优先:为什么我重新爱上了本地工具 开始;如果你是来找工程方案的,欢迎继续阅读这篇,或直接跳到 博客写作语法指南 看看数字花园里还有什么写作工具。

种下你的想法

在花园里留下一条评论,和这篇文章一起生长。

COMMENTS