把 Google Sheet 当成理财 App 的后端
发布于
我一直用一张 Google Sheet 记帐,痛点很单纯:输入麻烦、图表丑。我想要一个能一眼看懂趋势的界面,但完全不想为了一个自己用的小工具去养一台服务器——要付费、要更新、要监控,还要备份一份跟 Sheet 一模一样的数据。
于是后端就是那张 Sheet 本身。前端是一个纯静态网站丢在 GitHub Pages,中间夹一个 Google Apps Script Web App 当 API。零服务器、零数据库、零月费,数据还是躺在我自己的 Google 帐号底下。这篇记录实作过程中三个真正花掉时间的地方。
整个数据流长这样
flowchart LR
A[React 靜態網站<br/>GitHub Pages] -->|POST text/plain| B[Apps Script<br/>Web App]
B -->|讀寫分頁| C[(Google Sheet)]
A -.離線時.-> D[localStorage 快取]
前端没有任何秘密,任何人打开网址都只会看到一个「连接你的 Google Sheet」画面。网址和密钥存在浏览器,数据进出都经过那个 Apps Script 端点。
绕过 CORS 预检:用 text/plain 送 JSON
第一个坑。正常你会想用 Content-Type: application/json 送 POST,但那会触发浏览器的 CORS 预检(preflight),而 Apps Script 的 Web App 对 OPTIONS 请求的处理很难搞,常常直接失败。
解法是把 body 当成纯文字送:
await fetch(cfg.url, {
method: "POST",
headers: { "Content-Type": "text/plain;charset=utf-8" },
body: JSON.stringify({ token: cfg.token, action: "save", data }),
});
text/plain 是 CORS 的「简单请求」之一,不会触发预检。服务器那边照样 JSON.parse(e.postData.contents) 就拿到对象。内容明明是 JSON,只是骗浏览器说它是纯文字——这招在 Apps Script 几乎是标配。
两部设备同时改:乐观锁
同步一开始是最笨的做法:每次数据变动,就把整份 JSON 覆盖写回 Sheet。单机没问题,但只要我在手机和电脑同时开着,后存的一方会把先存的改动整份盖掉,而且完全无声无息。
修法是加一个版本戳。Apps Script 用 ScriptProperties 存一个 updatedAt,前端每次加载都记住它;存盘时把「我这份是基于哪个版本」一起送上去:
const stored = getUpdatedAt();
if (!body.force && body.baseUpdatedAt && stored && body.baseUpdatedAt !== stored) {
return json({ ok: false, error: "conflict" }); // 雲端比較新,先不要蓋
}
writeAll(body.data);
setUpdatedAt(new Date().toISOString());
外层再包一个 LockService 的脚本锁,避免两个请求同时进到写入区。前端收到 conflict 就不硬存,改弹一条横幅让我自己决定:加载云端版本,还是以这边覆盖。
这是乐观锁(optimistic locking)最精简的样子——不预先锁,等冲突发生才处理。对一个用户顶多开两三个分页的场景,这样刚刚好,不用扯到什么版本矢量。
净资产走势:每天一行快照
资产在数据模型里只是「当前值」,没有时间维度,所以画不出走势。理财 App 最想看的那张「净资产有没有在往上」的图,反而是缺的。
解法是在存盘时,顺手把当天的总资产、净资产写进一个独立分页,一天只留一行:
const today = toDateStr(new Date());
const existing = rows(sheet).findIndex((r) => toDateStr(r[0]) === today);
if (existing === -1) sheet.appendRow([today, total, net]); // 今天還沒有 → 新增
else sheet.getRange(existing + 2, 1, 1, 3).setValues([[today, total, net]]); // 有 → 覆蓋
计算交给前端、只把算好的数字送上来,这样「净资产 = 总资产 − 借贷」这条公式只活在一个地方。累积几天之后,仪表板那条迷你走势图就从「拿收支累计来凑」换成真实的资产曲线。
让旧后端也不会坏
上面两个功能都要改 Apps Script,而重新部署是手动的——我很可能哪天先更新了前端、却忘了贴新的后端。所以前端对新字段一律采取「没有就当空的」:
const snapshots = out.snapshots || []; // 舊後端沒回這欄 → 空陣列
return { data: out.data, snapshots, updatedAt: out.updatedAt ?? null };
版本戳是 null 时,冲突判断的 stored && body.baseUpdatedAt 直接短路,等于自动退回「无保护」的旧行为。新前端配旧后端,功能少一点但不会爆。这种向后兼容不用写什么兼容层,把「缺值」当成合法输入处理就好。
整个东西加起来大概两百行,没有数据库、没有服务器帐单。对一个自己每天打开两分钟的工具来说,这个成本结构才是重点。
后来我把同一套「Sheet 当后端」的做法搬去做订阅追踪器 SubLens,也顺便踩到另一组完全不同的问题——为 SubLens 加上 Demo 模式。