一个人做产品,能写出功能的人很多,能把它撑过第一个真实用户的人很少。
下面这张表是「独立开发真正会遇到的分工」。左列的东西借不来——借来了你也不知道它什么时候会咬你;右列的东西没必要自己造。
| 环节 | 必须自己会 | 可以借 |
|---|---|---|
| 需求 | 判断谁会真的付钱 | 竞品分析模板 |
| 数据 | 表怎么设计、怎么迁移 | ORM |
| 请求 | 超时、重试、幂等 | HTTP 客户端库 |
| 状态 | 谁是唯一真相 | 状态管理库 |
| 发布 | 怎么回滚到上一版 | CI 模板 |
| 钱 | 成本会不会失控 | 支付 SDK |
| 故障 | 出事能不能定位 | 日志/上报服务 |
后面九章只讲左列。每章都按同一个结构:它怎么坏 → 正确做法 → 一段能抄的代码 → 我自己踩过的坑 → 一道自验。
网络不是“通/不通”,而是“什么时候断、断在哪一步”。
一次请求有三种结局,只有一种是你能看见的:成功、明确失败(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 里,但没带幂等键。弱网下用户点三次“支付”,账单里三笔。用户不会认为是网络的错,只会认为你偷钱。格式一定会变。你唯一能控制的是“变的时候用户数据不丢”。
每次你想“我把字段改个名”,都会有一批用户手里握着旧格式:旧 App 的本地缓存、旧数据库文件、旧导出 JSON。它们不会自己消失,只会在你发布后的第三个星期炸出来。
三条规则,顺序不能换:
schema_version,打开时先读版本,再决定怎么迁。// 迁移写成一条链,每一步只管相邻两版 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,页面上一排“欢迎,”。修起来要写反向兼容,比重做还贵。同一个事实存两处,就一定会有两处不一致的那一天。
先把状态分三类,别混在一起:
| 类型 | 例子 | 真相在哪 |
|---|---|---|
| 服务器状态 | 订单、余额、别人也能改的数据 | 服务器 |
| 界面状态 | 当前 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 递增,问题消失。慢通常不是代码写得慢,是请求被串成了一条链。
看瀑布图最直观:每个方块是一次请求,横轴是时间。串行就是台阶,并行就是并排。
三个手段,按收益排序:
await a(); await b(); 是串行,await Promise.all([a(), b()]) 才是并行——一个 RTT 就能省几百毫秒。ETag/If-None-Match,命中时只回 304(几十字节)。loading="lazy",字体 font-display:swap,第三方脚本一律 defer。// 串行:两段各 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); // 只有这一条必须等
发布会出问题。区别只在于:你是三分钟切回去,还是三个小时。
关键不是“不出错”,是出错时你有一只手能立刻把开关拨回去。所以发布要满足两条:构建产物不可变、切换是一个原子动作。
具体做法(自己写静态站也一样适用):
releases/v12/),永不覆盖旧目录。current 链接、改 CDN 的版本前缀、更新一个配置项,都是一步。// 发布脚本的核心只有三行 build -> releases/v12/ // 1. 构建到新目录 switch current -> releases/v12 // 2. 原子切换(一行) // 出事: switch current -> releases/v11 // 3. 切回去,秒级
index.html。改坏了只能“再改回去”,而第二次上传也可能失败——你手上没有可回退的完整副本。独立开发最常见的一种死法,不是没人用,是有人用得太多。
你不一定被攻击,可能只是某个用户写了个循环,或者你把“每次请求都调一次付费接口”写进了轮询里。成本有三块,都要有上限:
| 成本 | 怎么失控 | 怎么兜底 |
|---|---|---|
| 请求/算力 | 轮询、脚本刷、死循环重试 | 每用户每分钟令牌桶 |
| 存储/带宽 | 用户传大图、日志全量存 | 大小上限 + 定期清理 |
| 第三方 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% 就发消息给自己)、降级(付费接口挂了就返回缓存结果,而不是整个页面报错)。
for 循环打 100 次接口。第 21 次开始应当拿到 429,而不是继续扣钱。你没有用户的屏幕。出事时你只有两个问题:发生了吗?发生在谁身上?
能回答这两个问题的,只有三层东西,缺一层就等于瞎:
window.onerror、unhandledrejection,别让异常静默死在用户手机上。requestId、用户 id、版本号,出问题能一条线索串到底。// 批量上报,失败不阻塞、不重试到死 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 秒内你的看板上应当出现这条,且带着版本号和机型。前端代码是公开的。凡是浏览器执行过的东西,用户都能看到。
所以只有一条铁律:任何密钥都不能出现在前端。包括“藏在 .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 不受影响);前端校验不是校验(用户能改请求,服务端必须再验一遍)。
sk-、Bearer、apiKey。搜得到就不合格。这九件事共同的点是:它们只在“真实使用”里出现,练习项目里不会。
所以别写“练习项目”,写你自己每天会用的东西,然后把它放到真实环境里跑两周:
每章的自验题就是检查表。九条全过,你就具备了“一个人把东西放到线上还能睡得着”的前提。