本地优先不是“顺便做个离线缓存”,而是把本地数据当作唯一事实来源,把网络同步降级为可选的增强能力。
为什么是本地优先
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,并接受首次构建的时间成本。这些都可以接受,只要用户始终拥有“关掉网络也能用”的确定性。
一句话总结: 先问“什么不该同步”,再问“怎么同步”。大多数本地优先项目的复杂度,来自边界定义不清,而不是算法不够先进。
