为 SubLens 加上 Demo 模式:只拦一个函数,换掉整个后端

Joe NG
3745 字
10 分钟
–

为 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。