为 SubLens 加上 Demo 模式:只拦一个函数,换掉整个后端
发布于
SubLens 是我自己用的订阅追踪器,数据存在自己的 Google Sheet 上(这套把 Sheet 当后端的架构,我在另一篇写过怎么搭)。这个设计对我很好,对第一次点进来的人很糟:网页一开就跳出「请输入 API Token」,而他根本还不知道这东西能干嘛。
这篇记录怎么加一个 Demo 模式让陌生人先看到东西,以及三个当下没想到、跑起来才发现的问题。
唯一的设计决定:拦在哪一层
代码拆成九个模块,但所有跟后端讲话的地方都收敛在 api.js 的同一个函数。添加、编辑、暂停、封存、删除、读汇率,全部走这里。
所以假后端只要塞在这个函数的最前面,其他八个文件一行都不用改:
export async function apiFetch(action, data = null) {
spinnerDepth++;
document.getElementById('apiSpinner').classList.remove('hidden');
try {
// Demo 模式:改由本機 localStorage 模擬,不發出網路請求
if (isDemoMode()) {
const result = await demoFetch(action, data);
if (result && result.error) throw new Error(result.error);
return result;
}
// ...以下是原本的 fetch,完全沒動
前提是 demoFetch() 的回传形状要跟 Apps Script 一模一样:读取回数组,写入回 { success: true },出错回 { error: "..." }。
调用端本来就有这种判断:
const result = await apiFetch('updateSubscription', data);
if (result === null) return;
形状对了,这行就继续有效,不用为 demo 多开一条分支。假后端本体是一个 switch,读写 localStorage:
case 'updateSubscription': {
const idx = subs.findIndex(s => String(s.id) === String(data.id));
if (idx === -1) return { error: '找不到 id 為 ' + data.id + ' 的資料列' };
subs[idx] = { ...subs[idx], ...data };
saveStore(subs);
return { success: true };
}
连错误消息都照抄后端原文。不是为了好玩——消息不一样的话,之后看到报错会分不出是哪一边喷的。
种子数据不能写死日期
第一版我把范例订阅的 startDate 直接写成 "2026-03-15" 这种固定字符串。当下看起来完全正常。
问题是这个 app 的三个主要数字——下次扣费日、累积支出、趋势图——全部是拿今天跟 startDate 算出来的。固定日期的种子数据放三个月就会烂掉:累积支出愈滚愈大,图表看起来像鬼。而且它不会报错,只会愈来愈不合理。
改成相对今天生成:
function daysAgo(n) {
const d = new Date();
d.setDate(d.getDate() - n);
return d.toISOString().split('T')[0];
}
{ name: 'Netflix', startDate: daysAgo(420), status: 'active' },
{ name: 'YouTube Premium', startDate: daysAgo(90), status: 'paused', pausedDate: daysAgo(20) },
{ name: 'Adobe CC', startDate: daysAgo(700), status: 'archived', endDate: daysAgo(60) },
七笔数据刻意铺满所有状态组合:三种货币、月费跟年费、active / paused / archived 各有代表。demo 的用途就是让人看到功能存在,少一种状态就少展示一个功能。
坑一:入口放在用户看不到的地方
第一版的入口是页面顶部一条横幅,写着「未设置 Google Sheets?试用 Demo」。
跑起来才发现新用户根本看不到它。实际流程是:页面加载 → 第一个请求回 unauthorized → token 弹窗盖住整页。横幅在弹窗底下。
所以入口要放进弹窗本身:
<div class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700 text-center">
<p class="text-xs text-gray-400 mb-2">沒有 Token?</p>
<button id="btnTokenDemo" class="w-full border ...">🧪 試用 Demo 模式</button>
</div>
横幅留着,但它服务的是另一种人:已经在用、想看看 demo 长怎样的。真正的新用户入口是那个弹窗。
事后看很明显。但写的时候我脑里的画面是「干净的首页」,不是「被弹窗盖住的首页」。
坑二:改了状态,却没有人去重新拉数据
存完 Token 之后,画面是空的。
原本的 handler 长这样:
localStorage.setItem('sublens_token', token);
showToast('Token 已儲存', 'success');
hideModal('tokenModalOverlay');
写进去了、弹窗关了,然后就没了。loadData() 在启动时已经跑完,没有人会再叫它第二次。清除 Token 一模一样的问题。
修法是三个动作统一 reload:
localStorage.setItem('sublens_token', token);
localStorage.removeItem('sublens_demo'); // 從 Demo 切回真實帳戶
showToast('Token 已儲存,重新載入中…', 'success');
hideModal('tokenModalOverlay');
location.reload();
location.reload() 很粗暴,但这里它是对的答案。改 token、进 demo、退 demo,三件事都是换数据源,等于整个 app 的状态全部作废。手动重跑 loadData() 加 loadAndDisplayRates() 再重画两张图,要记得清的东西比 reload 多,而用户一年大概会按到这几颗按钮三次。
removeItem('sublens_demo') 那行是后来补的。少了它,一个在 demo 模式的人输入真 Token 之后,会存好 token、reload、然后继续看到假数据,完全不知道发生什么事。
坑三:自动判断放太晚
没填 API 网址的人(例如 fork 这个 repo 的人)应该直接进 demo,不用自己点。判断本身很简单:
export function isApiConfigured() {
const url = CONFIG.API_URL || '';
return url.startsWith('https://script.google.com/macros/s/') &&
!url.includes('YOUR_DEPLOYMENT_ID');
}
我一开始把它放在 loadData() 开头。逻辑上没错,时序上错了——init 是这样跑的:
await loadAndDisplayRates(); // 先跑,對著空網址發請求,噴紅色錯誤
await loadData(); // 才發現沒設定,進 demo,reload
用户会先吃到一个错误 toast,画面才闪一下重整。判断要移到 init() 第一行,在任何请求发出去之前:
async function init() {
if (!isDemoMode() && !isApiConfigured()) {
enterDemoMode(); // reloads the page
return;
}
...
}
这类 bug 的共通形状是:把检查放在「用到那个东西的函数」里,而不是「最早能知道答案的地方」。
最后的样子
flowchart TD
A["開啟網頁"] --> B{"已在 Demo 模式?"}
B -- 是 --> D["localStorage 假後端"]
B -- 否 --> C{"API 網址有設定?"}
C -- 否 --> E["自動進 Demo"]
E --> D
C -- 是 --> F["打真的 Apps Script"]
F --> G{"回 unauthorized?"}
G -- 否 --> H["顯示真實資料"]
G -- 是 --> I["Token 彈窗:輸入 Token 或試用 Demo"]
刻意不做的事
没做 demo 数据导出,也没做 demo 搬到真实帐户。 想像中很顺(「试用完把数据带走」),实际上没有人想留着七笔假订阅。做了就要处理 id 冲突、汇率换算、部分失败要不要回滚,为一个没人走的路径多养三个错误状态。
demo 数据没有版本或迁移机制。 之后字段改了,旧的 demo 数据可能对不上。到时候的处理方式是叫用户按「重设数据」——反正是假的,重来的成本是零。
回头看,这件事实作上最省力的部分是拦截点:因为所有请求本来就收敛在一个函数,假后端才能只写一个新文件接上去。如果当初每个模块各自 fetch,同样的功能要改八个地方,而且一定会漏掉一两个。
模块化的好处通常被讲成「好读」「好测试」,这次它换到的是一个更具体的东西:加一整套平行的数据源,api.js 的 diff 只有一个 if。