Local-First 数据契约:一张表装下 4 类数据
2026 年 Local-First 从宣言变成默认架构,可真正卡住落地的不是 CRDT,而是 schema 迁移。我把待办、核心目标、投入、反思四类数据塞进一张云端多维表格,用 6 个字段打天下,45KB 单文件跑通。这篇拆这份「数据契约」怎么定、在哪几处失守。
一、先看结果:45KB 里装了什么
项目本体只有 5 个文件------一个 HTML 加四份 schema 快照,全部数字都是 statSync 实测:
| 文件 | 体积 | 作用 |
|---|---|---|
personal-desk.html |
45453 B(44.4 KB / 829 行) | 结构 + 样式 + 逻辑全在里面 |
ws_schema.json |
850 B | v1 云端表结构(7 字段) |
ws_schema_v2.json |
784 B | v2(6 字段,砍掉记账) |
ws_canonical.json |
2157 B | 最终版:字段 + field_mapping 映射 |
ws_server_schema.json |
999 B | 云端返回的真实结构(含内部 id) |
HTML 的构成:<style> 13103 B 占 28.8%,<script> 22859 B 占 50.3%,其余是骨架与文案。整个文件 16 个内联 SVG 图标、0 个 <link>、0 个 <img>、只有 1 个 <script> ,断网双击照样完整渲染。53 个函数、19 处 addEventListener,没有构建步骤、没有 npm 依赖、没有自建后端。
功能就三块:今天 (待办 + 逾期高亮 + 已完成折叠区)、核心目标 (一键记录今日投入、连续天数、近 7 天格子)、复盘(月历热力 + 每日反思 +「时间花多了要减少的」)。
二、为什么零后端也要谈「数据契约」
零后端不等于没有数据模型。localStorage 只是缓存,云端多维表格才是跨设备的那份副本。两者之间必须有一份契约:字段叫什么、什么类型、枚举值怎么编码。
| 方案 | 写入延迟 | 跨设备 | 契约成本 | 适合谁 |
|---|---|---|---|---|
| 纯 localStorage | 0 ms | 不支持 | 无 | 单机小工具 |
| 自建后端 + 数据库 | 100 ms+ | 支持 | 高(要写 API 和迁移脚本) | 多人协作 |
| IndexedDB + CRDT(Yjs/Automerge) | 0 ms | 支持 | 高(数据结构要可合并) | 协同编辑 |
| 云端多维表格 SDK(本项目) | 100 ms+ | 支持 | 中(一份 schema 就是契约) | 单人 + 多设备 |
多维表格这条路最省事的地方在于:schema 本身就是文档。它把「字段 → 类型 → 枚举选项」显式存在云端,前端只负责编码和解码。代价是一旦 schema 变了,前端那份硬编码映射就立刻过时。
三、三版 schema:一次真实的断裂
项目里躺着三份 schema 快照,正好记录了一次完整的契约变更:
| 版本 | 字段 | 字段列表 |
|---|---|---|
| v1 | 7 | 类型 / 内容 / 日期 / 状态 / 金额 / 收支 / 分类 |
| v2 | 6 | 类型 / 内容 / 日期 / 状态 / 时长 / 备注 |
| canonical & server | 6 | 同 v2 |
实测 diff:删除 3 个(金额/收支/分类)、新增 2 个(时长/备注)、保留 4 个(类型/内容/日期/状态)。字段存活率 57.1%,契约变更率 55.6%------一半以上的字段定义被推翻了。
更狠的是枚举选项的 id。多维表格里 select 字段的每个选项都有独立 id,前端落库时写的是 id 而不是文本:
| 枚举 | 选项文本 | v1 的 id | v2 的 id |
|---|---|---|---|
| 类型 | 待办 | a1b2c3d4e5f6g7h3 |
t1a2b3c4d5e6f7g1 |
| 状态 | 未完成 | s1t2u3v4w5x6y7h1 |
s1a2b3c4d5e6f7g1 |
| 状态 | 已完成 | s1t2u3v4w5x6y7h2 |
s1a2b3c4d5e6f7g2 |
三个跨版本同名的选项,id 全部变了,漂移率 100%。也就是说:文本看起来没换,但老代码写进新表一律对不上号。好消息是 v2、canonical、server 三份快照的 option id 完全一致(实测 true),说明断裂只发生在 v1→v2 这一次,后面两份只是确认快照。
项目当时的做法是新建一张表 + 本地 key 换成 wb_desk_records_v2,旧表直接作废,而不是做平滑迁移。对个人项目这很务实:没有历史数据要保,硬切换比写迁移脚本便宜得多。
回头看,这份契约其实分三层,断裂的代价也依次递增:字段名 错了是写不进去,字段类型 错了是值被截断,而枚举 option id 错了最阴------请求照样返回成功,只是数据悄悄落到了错误的选项上。下面第六节的坑 3 和坑 4 就是后两层的实例。
四、单表多态:一张表装 4 类数据的代价
6 个字段要服务 4 种数据,只能靠「同一字段在不同类型下语义不同」来复用,这在关系建模里叫单表多态(polymorphic association)。实测的语义矩阵:
| 字段 | 待办 | 核心目标 | 投入 | 反思 |
|---|---|---|---|---|
| 类型 | 标识行种类 | 标识行种类 | 标识行种类 | 标识行种类 |
| 内容 | 待办标题 | 目标名称 | 目标名称(冗余) | 反思正文 |
| 日期 | 截止日 | 创建日 | 投入日期 | 反思日期 |
| 状态 | 未完成/已完成 | 进行中/已达成 | 恒为已完成 | 恒为已完成 |
| 时长 | 恒 0(占位) | 每次投入预设值(默认60) | 实际投入分钟 | 恒 0(占位) |
| 备注 | 空(占位) | 空(占位) | 空(占位) | 「时间花多了要减少的」 |
24 个单元格里有 5 个是恒空占位,空转率 20.8% 。用真实 seed() 的 11 条数据序列化后统计,空值字段占 JSON 的 10.9%。
最典型的是「时长」------在核心目标行里它表示「每次投入的预设分钟数」(默认 60,可编辑后存回云端),在投入行里表示「实际投入分钟数」。同一个 number 字段两种语义,全靠「类型」列区分。好处是加一种数据类型不用改表,代价是任何按「时长」做聚合的查询都必须先过滤类型。
五、关键代码:契约的编码器与解码器
1. 编码器:文本 → option id
js
// 源码 344-347 行:类型/状态的文本到 option id 全部硬编码在前端
var OPT = {
"类型": {"待办":"t1a2b3c4d5e6f7g1","核心目标":"t1a2b3c4d5e6f7g2","投入":"t1a2b3c4d5e6f7g3","反思":"t1a2b3c4d5e6f7g4"},
"状态": {"未完成":"s1a2b3c4d5e6f7g1","已完成":"s1a2b3c4d5e6f7g2","进行中":"s1a2b3c4d5e6f7g3","已达成":"s1a2b3c4d5e6f7g4"}
};
// 源码 383-392 行:本地对象 → 云端 property 结构,这就是契约编码器
function toProps(rec){
return {
"类型":{select:OPT["类型"][rec.类型]}, // 落库的是 option id,不是文本
"内容":{text:rec.内容||""},
"日期":{date:rec.日期||todayStr()},
"状态":{select:OPT["状态"][rec.状态]||OPT["状态"]["未完成"]}, // 兜底到「未完成」
"时长":{number:Number(rec.时长)||0},
"备注":{text:rec.备注||""}
};
}
实测把 toProps() 的真实输出拿去和三版 schema 对账:
| schema | 越界字段 | 遗漏字段 | option id 不存在 | 结果 |
|---|---|---|---|---|
| v1 | 时长 / 备注(2) | 金额 / 收支 / 分类(3) | 类型 / 状态(2) | 拒绝 |
| v2 | 无 | 无 | 无 | 通过 |
| canonical | 无 | 无 | 无 | 通过 |
一句大白话:新代码写不进老表。如果云端还停在 v1,两个 select 字段直接指向不存在的 option,剩下三个必填字段又一行都没写。
2. 解码器:也是数据蒸发的现场
js
// 源码 406-417 行
function refreshFromCloud(){
if(!hasSDK) return Promise.resolve();
return db.query({databaseId:DATABASE_ID, pageSize:400}).then(function(res){
var rows=(res&&res.results)||[];
records = rows.map(function(r){ // ← 整体替换,本地没推上去的行直接消失
return { _id:r._id, 类型:r["类型"], 内容:r["内容"],
日期:r["日期"]?String(r["日期"]).slice(0,10):"", // ← 只截前 10 位
状态:r["状态"], 时长:Number(r["时长"])||0, 备注:r["备注"]||"" };
});
saveLocal(); // ← 云端反过来覆盖本地盘
}).catch(function(e){ console.error("query失败",e); records=loadLocal(); });
}
这段是整套同步的核心,也是后面一半坑的源头:云端是唯一权威,本地只是投影。写入失败时,本地刚敲的内容会被下一次拉回无声抹掉。
3. 撤销:闭包捕获的是旧对象
js
// 源码 424-429 / 656-659 行
function changeRecord(rec,patch){
for(var k in patch) rec[k]=patch[k];
saveLocal(); // 落盘的是 records 数组,不是 rec 本身
if(hasSDK){ setSync("busy","同步中...");
updateCloud(rec).then(refreshFromCloud).then(render).then(function(){ setSync("on","已同步"); });
} else render();
}
function completeTodo(r){
changeRecord(r,{状态:"已完成"});
showUndo("已完成「"+r.内容+"」", function(){ changeRecord(r,{状态:"未完成"}); });
// ↑ 闭包里的 r 是 refresh 之前那个对象
}
refreshFromCloud() 会把 records 整个换成新对象数组,于是撤销闭包里的 r 变成孤儿。改它不会反映到 records,也不会进 localStorage。
4. 修复版:合并 + 重推队列
js
var pending = []; // 本地已改、云端未确认的行
function refreshMerge(){
return db.query({databaseId:DATABASE_ID, pageSize:400}).then(function(res){
var rows=(res&&res.results)||[], byId={};
rows.forEach(function(r){ byId[r._id]=norm(r); });
// 本地有、云端没有 = 上次写入失败留下的,必须保住
pending.forEach(function(p){ if(!byId[p._id]) byId[p._id]=p; });
records = Object.keys(byId).map(function(k){ return byId[k]; });
saveLocal();
if(pending.length) return repush(); // 网络恢复后补推
});
}
// 撤销时按 _id 重新定位,而不是沿用旧对象
showUndo("已完成「"+r.内容+"」", function(){
var fresh = records.filter(function(x){ return x._id===r._id; })[0];
if(fresh) changeRecord(fresh,{状态:"未完成"});
});
六、8 条踩坑(全部实测)
坑 1:写入失败后全量拉回,本地数据蒸发。 每次写操作后面都跟一次 refreshFromCloud(),而它无条件用云端覆盖本地。断网期间新增的记录,恢复后一条不剩:
| 断网期间新增 | 应有 | 恢复后 | 蒸发率 |
|---|---|---|---|
| 1 条 | 3 | 2 | 33.3% |
| 3 条 | 5 | 2 | 60.0% |
| 10 条 | 12 | 2 | 83.3% |
改成按 _id 合并 + pending 重推后,同样 5 组场景蒸发率全部降到 0。
坑 2:撤销在断网时 100% 失效。 实测同一段源码跑两遍:联网撤销后 UI 回到「未完成」,断网撤销后 UI 仍是「已完成」,而闭包里那个对象确实已经被改成「未完成」------改了,但没人看。联网那次之所以生效,是因为改动先推上云端、再由 refreshFromCloud 拉回来,绕了 13 次写 + 6 次读。
坑 3:select 存的是 id,schema 一改全线崩。 见第三节,同文本 option 的 id 漂移率 100%。
坑 4:日期字段的 UTC 口径能整体偏移一天。 前端按本地口径构造 "2026-10-12T00:00:00",若云端回传的是 UTC ISO(2026-10-11T16:00:00.000Z),slice(0,10) 得到 2026-10-11------整套日历和逾期判定全部错一天。回传不带时区才是对齐的。
坑 5:一次写 = 一次全量读,payload 放大 400 倍。 实测真实源码:冷启动(云端为空自动播种 11 条)打了 14 次云调用;勾选完成 1 条待办 = 1 写 + 1 读;删除 1 条 = 1 写 + 1 读。
| 云端记录数 | 一次 query 回传 | 放大倍数 |
|---|---|---|
| 50 条 | 4.9 KB | 50x |
| 200 条 | 19.5 KB | 200x |
| 400 条 | 39.1 KB | 400x |
单条记录 JSON 100 B,一天 30 次操作就是 60 次云调用、约 1.14 MB 传输,而真正需要同步的可能只有几 KB。
坑 6:已完成折叠区的计数少算 85.1%。 allDoneTodos() 末尾有 .slice(0,30),实测 201 条已完成时只返回 30 条,折叠区标题写着「已完成 30 条」,实际是 201 条。
坑 7:空日期的待办不计入「今日待处理」。 逾期判定用的是字符串比较 r.日期 < t,空串既不满足 <= t 也不满足 < t。实测四种情况:昨天→计入且标逾期;今天→计入不标逾期;明天→不计入;未设日期→不计入。所以新建时不填日期的待办,会凭空从"今天"列表里消失。
坑 8:field_mapping 是事后反推的,别拿它当渲染依据。 canonical 快照里给每个字段标了 form_input / display_selector / render_signal 三个槽位,但语义是错位的:「内容」字段的 display_selector 指向 #todayCount(今日待办数),「时长」指向 #monthStat(月度统计)。render_signal 还用了 label:contains('要做什么') 这种 jQuery 专有写法,原生 querySelector 会直接抛 SyntaxError。这份映射适合人工核对,不适合直接驱动渲染。
七、实测方法说明
上面所有数字都不是估算,做法是把项目 <script> 原文抽出来,注入假的 window / document / localStorage 和一个假的多维表格 SDK,然后直接跑真实源码:
bash
# 假云端会按 option id 白名单校验写入,能真实复现"写不进去"
TZ=Asia/Shanghai node tools/_measure_frosted_schema.js
好处是 toProps()、changeRecord()、completeTodo() 这些跑的就是线上那份代码,不是手抄复刻的副本------撤销孤儿、覆盖丢失这类问题只能这么复现出来。
八、小结与修复清单
单文件零后端这条路是走得通的:44.4 KB、829 行、0 依赖、断网可用。但它的脆弱点不在渲染,而在数据契约------schema 一改,前端那份硬编码映射立刻失效;同步一断,本地改动立刻蒸发。
按顺序修的话:
- 先给
refreshFromCloud换成按_id合并,别无条件覆盖 - 加 pending 队列,写入失败的行留着等网络恢复补推
- 撤销闭包改成按
_id重新定位对象,别沿用旧引用 - 把
OPT那份映射改成启动时getSchema()拉一次,文本→id 现查,别硬编码 - 日期统一走
yyyy-mm-dd字符串,不依赖云端序列化口径 - 计数类聚合先聚合再截断,
slice只影响渲染不影响数字 - 空日期兜底成今天,别让它从列表里消失
完整工程已整理好(含一键启动脚本 + 部署文档),需要的同学评论区扣「源码」,我看到会一一回复;也欢迎关注我,后续会把 Local-First 这个系列继续更下去。