Local-First 数据契约:一张表装下 4 类数据

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 这个系列继续更下去。

相关推荐
迅猛龙办公室1 小时前
Python函数实现反转一个只有3位数的整数
开发语言·前端·python
我星期八休息1 小时前
Redis 的原理和使用(2)
前端·数据库·redis·python·mysql·缓存·bootstrap
Java后端的Ai之路1 小时前
19-ReactJS最佳实践
前端·react.js·前端框架
溪语流沙1 小时前
【Web全栈进阶】文件上传与图片展示:早报站的第一张图片
前端·vue.js·python·fastapi
alwaysrun1 小时前
Web之wasm-bindgen详解
前端·wasm·bindgen
鬼手点金1 小时前
如何选择模型
java·前端·人工智能·git·深度学习
恋猫de小郭1 小时前
Dart 3.15 新特性:this 终于支持类型提升了
android·前端·flutter
IT_陈寒2 小时前
Vue开发实战:从入门到精通
前端·人工智能·后端
znnnk2 小时前
2026 前端工程化新纪元:Rust 重塑工具链,AI Agent 接管流水线
前端·人工智能·rust