← 首页

独立开发要会的九件事

9 章 · 8 张动图
目录 0 · 卡住你的不是写代码 1 · 请求一定会失败 2 · 数据会长大:迁移 3 · 状态放哪:唯一真相 4 · 首屏为什么慢 5 · 发布与回滚 6 · 钱:配额与限流 7 · 出事之后:观测 8 · 密钥与边界

00卡住你的不是写代码

一个人做产品,能写出功能的人很多,能把它撑过第一个真实用户的人很少。

下面这张表是「独立开发真正会遇到的分工」。左列的东西借不来——借来了你也不知道它什么时候会咬你;右列的东西没必要自己造。

环节必须自己会可以借
需求判断谁会真的付钱竞品分析模板
数据表怎么设计、怎么迁移ORM
请求超时、重试、幂等HTTP 客户端库
状态谁是唯一真相状态管理库
发布怎么回滚到上一版CI 模板
钱成本会不会失控支付 SDK
故障出事能不能定位日志/上报服务

后面九章只讲左列。每章都按同一个结构:它怎么坏 → 正确做法 → 一段能抄的代码 → 我自己踩过的坑 → 一道自验。

先记住一句:“在我机器上能跑”和“在用户手机上能跑”之间,差的不是环境,是这九件事。

01请求一定会失败

网络不是“通/不通”,而是“什么时候断、断在哪一步”。

一次请求有三种结局,只有一种是你能看见的:成功、明确失败(4xx/5xx)、不知道结果(超时、连接被掐)。第三种最麻烦——服务端可能已经执行了,你只是没收到回执。

所以重试不是“再发一次”,而是“再发一次,但保证不会做两遍”。做法是给每个请求带一个幂等键:客户端生成、重试时不变,服务端见到同一个键就返回第一次的结果,而不是再做一次。

// 带超时 + 幂等键 + 指数退避的最小实现
async function call(url, body, key){
  for (let i = 0; i < 3; i++){
    const ctl = new AbortController();
    const timer = setTimeout(() => ctl.abort(), 8000);
    try {
      return await fetch(url, {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          'idempotency-key': key
        },
        body: JSON.stringify(body),
        signal: ctl.signal
      });
    } catch (e) {
      if (i === 2) throw e;
      // 200 / 800 / 3200ms,再抖 0~30%,避免所有客户端一起回来
      await sleep(200 * 4 ** i * (1 + Math.random() * 0.3));
    } finally { clearTimeout(timer); }
  }
}

三点补充,都是血:① 超时要设,不设的请求会挂住你的并发数;② 退避要有抖动,否则服务一恢复,几万客户端在同一毫秒涌进来,二次打挂;③ 不是所有请求都能重试——POST /pay 必须靠幂等键,GET 天然安全。

踩过的坑:重试写在 fetch 的 catch 里,但没带幂等键。弱网下用户点三次“支付”,账单里三笔。用户不会认为是网络的错,只会认为你偷钱。
自验:打开开发者工具切到 “Slow 3G + Offline”,点一次提交。你应当看到 3 次尝试、间隔越来越长,且服务端只多了一条记录。

02数据会长大:迁移

格式一定会变。你唯一能控制的是“变的时候用户数据不丢”。

每次你想“我把字段改个名”,都会有一批用户手里握着旧格式:旧 App 的本地缓存、旧数据库文件、旧导出 JSON。它们不会自己消失,只会在你发布后的第三个星期炸出来。

三条规则,顺序不能换:

// 迁移写成一条链,每一步只管相邻两版
const steps = [
  { to: 2, up: d => { d.rows.forEach(r => r.createdAt = r.createdAt ?? '2026-01-01'); } },
  { to: 3, up: d => { d.rows.forEach(r => r.tags = r.tags ?? []); } }
];

function migrate(d){
  backup(d);                                  // 先复制一份
  while (d.schema_version < LATEST){
    const s = steps.find(x => x.to === d.schema_version + 1);
    if (!s) throw new Error('缺少迁移步骤 ' + d.schema_version);
    s.up(d); d.schema_version = s.to;         // 一步一提交
  }
  save(d);
}

为什么“一步一提交”?因为迁移可能在中途崩。写成大事务,崩了等于没做;写成一步一版,崩了下次从断点接着走,不会重复加字段。

踩过的坑:把 user_name 改成 userName,只在代码里改。老数据读出来全是 undefined,页面上一排“欢迎,”。修起来要写反向兼容,比重做还贵。
自验:留一份旧版数据文件,跑一次迁移,然后用旧版代码再打开它——应该还能读(只加不改的检验)。

03状态放哪:唯一真相

同一个事实存两处,就一定会有两处不一致的那一天。

先把状态分三类,别混在一起:

类型例子真相在哪
服务器状态订单、余额、别人也能改的数据服务器
界面状态当前 tab、下拉展开、输入框草稿本地,不用同步
缓存副本列表数据、用户资料服务器,本地只是快照

独立开发最容易犯的错,是把第三条当成第一条:本地改了、界面变了、以为成了,其实服务器没收到。

正确姿势是乐观更新 + 失败回滚 + 版本号判新旧:

// 先改本地(界面立刻响应),失败再改回来
async function rename(id, name){
  const before = state.get(id);
  state.set(id, { ...before, name });        // 乐观更新
  try {
    const r = await api.patch('/items/' + id, {
      name, version: before.version          // 带上我看到的版本
    });
    state.set(id, r.data);                   // 用服务器返回的版本号覆盖
  } catch (e) {
    state.set(id, before);                   // 回滚
    if (e.status === 409) toast('别处改过了,已刷新');
  }
}

冲突判定用版本号,不要用时间戳:手机时钟可以是错的、也可以是用户手动改的;而版本号是服务器发的,天然单调。

踩过的坑:用 updatedAt 比大小来合并。测试机上两台设备时钟差 40 秒,永远“最后写入”的那条反而被丢弃。改成 version 递增,问题消失。
自验:飞行模式下改一条记录,同时用另一台设备改同一条,恢复网络。你应当看到一条 409 提示,而不是数据被静默覆盖。

04首屏为什么慢

慢通常不是代码写得慢,是请求被串成了一条链。

看瀑布图最直观:每个方块是一次请求,横轴是时间。串行就是台阶,并行就是并排。

三个手段,按收益排序:

// 串行:两段各 300ms,共 600ms
const u = await getUser();
const list = await getList(u.id);

// 并行:先拿能拿的,再补依赖的
const [u, hot] = await Promise.all([getUser(), getHotList()]);
const mine = await getList(u.id);   // 只有这一条必须等
踩过的坑:首屏先请求“配置”再请求“内容”,看着都很快(各 200ms),但排队后首屏 1.2 秒。把配置做成构建期常量,立刻降到 400ms。
自验:在开发者工具里勾 “Disable cache” 刷新一次,记下 DOMContentLoaded;再取消勾选刷新一次。两次的差值就是缓存替你省下的时间。

05发布与回滚

发布会出问题。区别只在于:你是三分钟切回去,还是三个小时。

关键不是“不出错”,是出错时你有一只手能立刻把开关拨回去。所以发布要满足两条:构建产物不可变、切换是一个原子动作。

具体做法(自己写静态站也一样适用):

// 发布脚本的核心只有三行
build -> releases/v12/          // 1. 构建到新目录
switch current -> releases/v12  // 2. 原子切换(一行)
// 出事:
switch current -> releases/v11  // 3. 切回去,秒级
踩过的坑:图省事直接改线上的 index.html。改坏了只能“再改回去”,而第二次上传也可能失败——你手上没有可回退的完整副本。
自验:发布完立刻演练一次回滚:切回上一版、确认页面正常、再切回来。全程计时,超过 1 分钟就说明流程有问题。

06钱:配额与限流

独立开发最常见的一种死法,不是没人用,是有人用得太多。

你不一定被攻击,可能只是某个用户写了个循环,或者你把“每次请求都调一次付费接口”写进了轮询里。成本有三块,都要有上限:

成本怎么失控怎么兜底
请求/算力轮询、脚本刷、死循环重试每用户每分钟令牌桶
存储/带宽用户传大图、日志全量存大小上限 + 定期清理
第三方 API不做缓存、不设上限服务端配额 + 预算告警

令牌桶是最简单的限流器:桶有容量、按固定速率补一个、每个请求消耗一个。桶空就返回 429,并告诉对方什么时候再试。

// 每用户一个桶(服务端内存版,够用到几千用户)
const buckets = new Map();
function take(uid, cap = 20, perMin = 20){
  const now = Date.now();
  let b = buckets.get(uid) || { t: now, n: cap };
  b.n = Math.min(cap, b.n + (now - b.t) / 60000 * perMin);   // 按时间补充
  b.t = now;
  if (b.n < 1){ buckets.set(uid, b); return false; }
  b.n -= 1; buckets.set(uid, b); return true;
}

// 调用处
if (!take(uid)) return new Response('太频繁,请稍后',
  { status: 429, headers: { 'retry-after': '3' } });

再补两件事:预算告警(花钱到 50% 就发消息给自己)、降级(付费接口挂了就返回缓存结果,而不是整个页面报错)。

踩过的坑:接口只在前端加了防抖,服务端裸奔。有人直接打 API,一夜之间账单四位数。限流必须在服务端,前端那层只是体验。
自验:写个 for 循环打 100 次接口。第 21 次开始应当拿到 429,而不是继续扣钱。

07出事之后:观测

你没有用户的屏幕。出事时你只有两个问题:发生了吗?发生在谁身上?

能回答这两个问题的,只有三层东西,缺一层就等于瞎:

// 批量上报,失败不阻塞、不重试到死
const buf = [];
function report(kind, data){
  buf.push({ kind, data, v: VERSION, t: Date.now(), ua: navigator.userAgent });
  if (buf.length >= 10) flush();
}
function flush(){
  if (!buf.length) return;
  const body = JSON.stringify(buf.splice(0));
  navigator.sendBeacon('/api/log', body);   // 页面关了也能发出去
}
addEventListener('error', e => report('err', { m: e.message, f: e.filename, l: e.lineno }));
addEventListener('unhandledrejection', e => report('rej', { m: String(e.reason) }));

两条纪律:采样(同样的错误一分钟只报一次,否则你自己把上报接口打挂)、别记隐私(手机号、密码、token 一律脱敏,日志库泄露比代码泄露更致命)。

踩过的坑:只在 console.log 里打日志。用户说“白屏”,你手上什么都没有,只能猜。加上全局捕获后,第一次就定位到是某个 Android 内核不支持某个语法。
自验:在页面里故意 throw new Error('test'),30 秒内你的看板上应当出现这条,且带着版本号和机型。

08密钥与边界

前端代码是公开的。凡是浏览器执行过的东西,用户都能看到。

所以只有一条铁律:任何密钥都不能出现在前端。包括“藏在 .env 里”“打包压缩过”“变量名看不懂”——打包产物里就是明文,搜一下就有。

正确结构是加一层你自己的服务端:

// 错误:前端直连上游,key 在 bundle 里
fetch('https://api.upstream.com/v1/chat', {
  headers: { authorization: 'Bearer sk-xxxx' }   // 谁都能复制走
});

// 正确:前端只调自己的函数,key 存在服务端环境变量
// functions/api/chat.js
export async function onRequest({ request, env }){
  const { text } = await request.json();
  const r = await fetch('https://api.upstream.com/v1/chat', {
    method: 'POST',
    headers: { authorization: 'Bearer ' + env.UPSTREAM_KEY },  // 只在服务端
    body: JSON.stringify({ text })
  });
  return new Response(await r.text(), { headers: { 'access-control-allow-origin': '*' } });
}

顺带纠正两个常见误解:CORS 不是安全边界(它只约束浏览器,脚本和 curl 不受影响);前端校验不是校验(用户能改请求,服务端必须再验一遍)。

踩过的坑:key 写在前端,上线三天被盗刷。上游按用量计费,账单不会因为“是被盗的”而少一分。
自验:在打包产物里搜 sk-、Bearer、apiKey。搜得到就不合格。

09怎么练这九件事

这九件事共同的点是:它们只在“真实使用”里出现,练习项目里不会。

所以别写“练习项目”,写你自己每天会用的东西,然后把它放到真实环境里跑两周:

每章的自验题就是检查表。九条全过,你就具备了“一个人把东西放到线上还能睡得着”的前提。

一句话总结:独立开发的门槛不在“会不会写”,在“坏掉的时候,你知不知道为什么,以及多久能修好”。