返回随笔工程笔记 · 本地优先

本地优先(Local-First)架构实践:文件、SQLite 与同步边界

本地优先不是“顺便做个离线缓存”,而是把本地数据当作唯一事实来源,把网络同步降级为可选的增强能力。

为什么是本地优先

tMarkdown 和 Renamer 都选择了同一条路线:用户打开文件、编辑、保存,整个过程不依赖任何服务器。这样做带来的不是某个功能点,而是一整类问题的消失——没有登录墙,没有上传等待,没有服务停运后的数据焦虑,也没有“你的文件正在被用于训练”的隐私疑问。

对于桌面工具,本地优先还有一个更朴素的好处:文件系统本来就是最好的数据库。用户知道文件在哪里,可以用任何工具打开,可以自己备份。软件只是这堆文件的一个视图。

先定义同步边界

很多团队一上来就引入 CRDT,最后发现真正的难点不在合并算法,而在“哪些状态根本不应该同步”。我的做法是先画出三层边界:

  • 用户资产: 文档、图片、重命名规则。必须完整保存在本地,同步只是复制。
  • 设备偏好: 窗口大小、主题、最近打开列表。可以同步,但丢失也无妨。
  • 瞬时状态: 滚动位置、选中的块、命令面板的搜索结果。永远不进入同步层。

把第三层坚决排除之后,同步模型的复杂度往往会下降一个数量级。大部分所谓的冲突,其实来自把不该同步的东西也同步了。

CRDT 不是银弹

当确实需要多人同时编辑同一份文本时,CRDT 很有价值。但它也带来两个隐性成本:元数据膨胀,以及“看起来合并成功、语义却不对”的错觉。对于结构化文档,字节级合并未必等于块级正确。

因此我倾向于一个更保守的组合:单写者场景使用“最后写入 + 版本向量”,只在真正需要协作的字段上启用 CRDT。把复杂性限制在最小的数据类型里,而不是让它渗透整个模型。

// 本地是事实来源;远端只是另一个副本
type DocumentStore = {
  root: 'local-file';
  cache: 'opfs';
  remote: 'optional';
  conflict: 'manual-first';
};

async function save(doc: Document) {
  await writeFile(doc.path, serialize(doc));
  // 同步失败不应阻塞保存
  queueSync(doc).catch(reportQuietly);
}

工程上的取舍

本地优先会让“数据迁移”变成一件更严肃的事。用户可能三年不打开这个工具,再打开时,文件格式已经变了两版。所以从第一版开始,我就把格式版本号写进文件头,并为每次迁移保留往返测试。

另一个取舍是搜索。没有服务端,就意味着要在客户端建立索引。小规模数据用内存倒排足够;规模再大,就需要把索引持久化到 SQLite,并接受首次构建的时间成本。这些都可以接受,只要用户始终拥有“关掉网络也能用”的确定性。

一句话总结: 先问“什么不该同步”,再问“怎么同步”。大多数本地优先项目的复杂度,来自边界定义不清,而不是算法不够先进。

回到顶部

威威牛 独立手艺人

独立数字手艺人。打磨 tMarkdown、tPicCraft、Renamer 等小工具,记录创造与探索的全过程。相信优雅的代码与真诚的文字能够经受时间淘洗。