从三个界面说起
「回声」是本书的贯穿案例,一个同时有内容流、私信与站内下单的产品。上一套书从服务端看它,这套书从端上看它。同一天里,用户可能做三件事:
- 在地铁里刷发现页,网络时断时续,图片加载到一半列车进隧道。
- 在会话页等一条回复,应用被切到后台,十分钟后系统回收了进程。
- 在下单页填地址,接了个电话回来,页面还在,但输入框里的内容没了。
这三件事在服务器架构里几乎看不见。服务端只收到请求或收不到请求,它不关心进程有没有被系统杀掉,也不关心用户是在地铁里还是在 Wi-Fi 下。客户端架构的第一课,就是承认这些差异不是“实现细节”,而是决定架构形状的输入。
先给结论:客户端有四个服务器没有的约束——设备能力碎片化、网络不确定、生命周期被系统打断、分发与版本不可回滚。它们分别改变渲染、数据、状态与发布四条路径。本章不急着选框架,而是先把这四个约束写成可分类的输入,并用一段可运行的 TypeScript 把它们固定下来。
文中出现的用户量、比例、耗时,除非特别标注为本机运行输出,都是案例假设值,用来演示判断过程,不是任何真实产品的实测。
服务器没有的四个约束
设备能力碎片化
同一个 Web 页面,可能跑在六年前的千元安卓机、刚发布的旗舰机、桌面浏览器和一台内存只有 1 GB 的电视盒子上。CPU 核心数、内存上限、GPU 能力、字体与输入法、屏幕刷新率都不同。服务器可以假设“所有机器大致相同”,客户端不能。
碎片化带来的不是“兼容几个浏览器”这么简单,而是同一份代码在不同设备上的性能曲线可能差一个数量级。一个在旗舰机上 16 毫秒完成的重排,在低端机上可能 120 毫秒,用户看到的就是卡顿。架构上必须留出降级路径:低端设备少加载、少动画、少并发,而不是让所有设备跑同一条最重路径。
网络不确定
服务端之间的调用通常在一个机房或同城可用区,延迟稳定在亚毫秒到几毫秒。客户端的网络横跨 2G、3G、4G、5G、Wi-Fi、弱 Wi-Fi 和完全离线,同一个请求的耗时可能从 30 毫秒到 30 秒,失败也可能是“请求发出去了但响应没回来”这种不确定态。
这直接改变数据层的设计:请求需要超时预算、重试与去重,读路径需要缓存兜底,写路径需要离线队列与冲突解决。服务端可以假设“失败就是失败”,客户端必须处理“不知道成功还是失败”。
生命周期被系统打断
移动端应用随时可能被切到后台、被系统回收、被用户划掉。桌面端会被休眠,浏览器标签页会被冻结。一个正在编辑的表单、一个正在上传的文件、一条正在发送的消息,都可能在任意时刻被中断。
服务端进程可以长期驻留内存,客户端的“内存”是不可靠的。架构上必须把状态分成两类:可以丢的(比如当前滚动位置)和不能丢的(比如未发送的消息、未提交的草稿)。不能丢的状态要落到本地存储,并在恢复时校验完整性。
分发与版本不可回滚
服务端发布新版本,出问题可以回滚,所有用户同时回到旧版本。客户端不行:用户手机上的旧版本可能半年不更新,应用商店的审核有延迟,Web 端还有浏览器缓存和 CDN 的旧资源。发布不是“切换”,而是“新旧版本长期共存”。
这带来兼容窗口的问题:新服务端要能服务旧客户端,新客户端要能处理旧服务端返回的数据格式。任何一个字段的删除、一个接口语义的变化,都要按“至少兼容两个版本”来设计。热更新能缓解一部分问题,但它本身有平台限制与合规风险,不能当成默认能力。
把约束写成输入
四个约束听起来像常识,但常识最容易在项目里丢失。解决办法不是写一份更长的检查清单,而是把它变成函数输入:产品事实进去,风险与必备能力出来,冲突能被测试拦住。
下面的分类器把四个约束收敛成五个风险标签。判断顺序固定为离线 > 中断 > 网络 > 输入 > 分发,因为越靠前的约束越会推翻后面约束的默认做法。比如“离线优先”一旦成立,“只做乐观写入、失败再提示”的默认策略就不成立了。
// file: src/core/constraints.ts
// tier: L1
// verify: node --test test/core/constraints.test.ts
export type NetworkProfile = "fast" | "slow" | "offline";
export type LifecycleState = "foreground" | "background" | "cold-start";
export type InputMode = "touch" | "mouse" | "keyboard";
export interface ClientFacts {
/** 产品事实:用户主要使用的网络环境 */
network: NetworkProfile;
/** 产品事实:应用被系统中断的频率(次/小时) */
interruptionsPerHour: number;
/** 产品事实:主输入方式 */
inputs: InputMode[];
/** 产品事实:是否必须在无网时完成核心任务 */
offlineRequired: boolean;
}
export type Risk = "startup" | "offline" | "interruption" | "input" | "distribution";
export interface ConstraintClass {
risks: Risk[];
required: string[];
forbiddenDefaults: string[];
}
const FACTS_LIMIT = {
interruptionsPerHour: 6,
} as const;
/**
* 把产品事实翻译成端上风险与必备能力。
* 判断顺序固定:离线 > 中断 > 网络 > 输入 > 分发,越靠前越会推翻后面的假设。
*/
export function classifyConstraints(facts: ClientFacts): ConstraintClass {
const risks: Risk[] = [];
const required = new Set<string>();
const forbidden = new Set<string>();
if (facts.offlineRequired) {
risks.push("offline");
required.add("local-store");
required.add("sync-queue");
forbidden.add("optimistic-only-write");
}
if (facts.interruptionsPerHour > FACTS_LIMIT.interruptionsPerHour) {
risks.push("interruption");
required.add("state-restore");
required.add("draft-persistence");
forbidden.add("in-memory-only-draft");
}
if (facts.network === "slow" || facts.network === "offline") {
risks.push("startup");
required.add("cache-first-render");
required.add("request-budget");
forbidden.add("blocking-first-paint");
}
if (facts.inputs.includes("touch")) {
risks.push("input");
required.add("hit-target");
forbidden.add("hover-only-affordance");
}
risks.push("distribution");
required.add("version-compat");
forbidden.add("no-force-upgrade-window");
return {
risks,
required: [...required].sort(),
forbiddenDefaults: [...forbidden].sort(),
};
}
/**
* 判断某个决策是否与已识别约束冲突。
* 返回空数组表示没有发现冲突;每条冲突都带原因,便于写进评审记录。
*/
export function conflictsWithConstraints(
decision: string,
cls: ConstraintClass,
): string[] {
const reasons: string[] = [];
if (cls.forbiddenDefaults.includes(decision)) {
reasons.push(`决策 ${decision} 与已识别风险 ${cls.risks.join(",")} 冲突`);
}
return reasons;
}
这段代码故意不依赖任何框架、DOM 或网络库。它回答的是“这个产品事实组合下,哪些默认做法必须被禁止”,而不是“现在该用哪个状态库”。框架会换,判断顺序不该跟着换。
有几个设计细节值得说明。
第一,interruptionsPerHour > 6 这个阈值是案例假设值,不是行业标准。它的作用是把“经常被打断”变成一个可以讨论的数字:产品经理说“用户会频繁切出去”,团队就要问“大概几次每小时”,然后决定要不要做草稿持久化。阈值本身可以调整,但必须显式写出来,不能藏在某个人的直觉里。
第二,forbiddenDefaults 比 required 更重要。必备能力是“应该做”,禁止默认值是“不能这样做”。后者更容易在评审时被忽略,也更容易在事故里出现。比如把 optimistic-only-write 写进禁用列表,就意味着离线场景下不能只做乐观更新,必须有队列和重试。
第三,风险列表的顺序是稳定的,测试可以直接比对。这让“约束有没有变”成为一个可回归的问题:产品事实变了,分类结果应该跟着变;分类结果没变,说明输入没变或者代码坏了。
测试与真实输出
分类器的测试覆盖五个场景:纯在线、离线优先、高中断、判断顺序、无冲突。完整测试文件如下:
// file: test/core/constraints.test.ts
// tier: L1
// verify: node --test test/core/constraints.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyConstraints, conflictsWithConstraints } from "../../src/core/constraints.ts";
import type { ClientFacts } from "../../src/core/constraints.ts";
const base: ClientFacts = {
network: "fast",
interruptionsPerHour: 2,
inputs: ["touch", "keyboard"],
offlineRequired: false,
};
test("纯在线、低中断的产品只需要分发与输入约束", () => {
const cls = classifyConstraints(base);
assert.deepEqual(cls.risks, ["input", "distribution"]);
assert.ok(cls.required.includes("version-compat"));
assert.ok(!cls.required.includes("sync-queue"));
});
test("离线优先会推翻“只做乐观写入”的默认做法", () => {
const cls = classifyConstraints({ ...base, offlineRequired: true, network: "offline" });
assert.ok(cls.risks.includes("offline"));
assert.ok(cls.risks.includes("startup"));
const [reason] = conflictsWithConstraints("optimistic-only-write", cls);
assert.match(reason, /offline/);
assert.match(reason, /startup/);
});
test("高中断频率要求状态恢复与草稿持久化", () => {
const cls = classifyConstraints({ ...base, interruptionsPerHour: 12 });
assert.ok(cls.risks.includes("interruption"));
assert.ok(cls.required.includes("state-restore"));
assert.ok(cls.required.includes("draft-persistence"));
});
test("判断顺序稳定,风险列表可直接比对", () => {
const cls = classifyConstraints({
network: "slow",
interruptionsPerHour: 9,
inputs: ["touch"],
offlineRequired: true,
});
assert.deepEqual(cls.risks, ["offline", "interruption", "startup", "input", "distribution"]);
});
test("没有命中禁用默认值时返回空冲突列表", () => {
const cls = classifyConstraints(base);
assert.deepEqual(conflictsWithConstraints("cache-first-render", cls), []);
});
在实验工程根目录执行:
node --test test/core/constraints.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 纯在线、低中断的产品只需要分发与输入约束 (1.238375ms)
✔ 离线优先会推翻“只做乐观写入”的默认做法 (0.181875ms)
✔ 高中断频率要求状态恢复与草稿持久化 (0.087625ms)
✔ 判断顺序稳定,风险列表可直接比对 (0.073625ms)
✔ 没有命中禁用默认值时返回空冲突列表 (0.070292ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 201.846666
这里用 node --test 直接跑 .ts 文件,依赖的是 Node 26 的类型剥离能力,不需要先编译。它只剥离类型,不做类型检查,所以类型错误仍要靠编辑器或单独的 tsc --noEmit 发现。本书把运行测试当作硬门禁,把类型检查当作建议项,原因在 1.3 节说明。
一次把在线默认值带进离线的失败
某次版本里,发现页的图片加载策略是“先请求原图,失败后显示占位图”。这个策略在办公室 Wi-Fi 下表现很好,上线后却收到大量“图片全是灰块”的反馈。复盘时发现三个问题叠加:
- 弱网下原图请求超时是 15 秒,用户不会等 15 秒,直接滑走。
- 失败后没有降级到缩略图,占位图一直保留到用户重新进入页面。
- 图片请求没有去重,同一个 URL 在列表滚动时被反复请求,进一步拖慢网络。
根因不是图片组件写得差,而是分类阶段把网络当成了 fast,因此没有把“缓存优先渲染”和“请求预算”列入必备能力。决策记录里写的是“图片加载失败有占位图”,看起来已经处理了失败,实际上处理的是“最终失败”,没有处理“慢到用户放弃”。
如果当时跑了分类器,network: "slow" 会让 startup 风险出现,cache-first-render 与 request-budget 会被列为必备能力,评审时就会追问缩略图、超时预算和并发上限。分类器不能自动修 bug,但它能把“这个场景需要讨论”提前到编码之前。
与服务端约束的对照
把四个约束放在一张表里,对照上一套书的服务端视角:
| 维度 | 服务端 | 客户端 |
|---|---|---|
| 运行环境 | 同构、可控 | 碎片化、不可控 |
| 网络 | 机房内稳定 | 公网不确定,可能离线 |
| 生命周期 | 长期驻留 | 随时被系统打断 |
| 发布 | 可回滚、同时生效 | 新旧版本长期共存 |
| 故障可见性 | 日志、指标集中 | 依赖端上上报,可能丢失 |
| 容量 | 可水平扩展 | 受设备内存与算力限制 |
这张表不是要证明客户端“更难”,而是说明两边的默认假设相反。服务端默认“网络可靠、进程长活、版本一致”,客户端必须默认“网络不可靠、进程会死、版本分裂”。把服务端的默认值直接搬到端上,是很多端上事故的共同起点。
小结
客户端架构的四个独有约束是设备碎片化、网络不确定、生命周期被打断、分发与版本不可回滚。它们分别改变渲染、数据、状态与发布四条路径,而且不能靠“多加几个判断”解决,必须在架构形状上留出位置。
把约束写成函数输入,比写成检查清单更可靠:产品事实进去,风险与必备能力出来,冲突可以被测试拦住。分类器不替你做决定,它只保证“该讨论的风险已经出现在桌面上”。
下一节会把这些约束落到「回声」客户端的具体形状上:三个页面、第一个状态归属决定,以及为什么“状态放哪”比“用哪个状态库”更早需要回答。
阅读导航:上一节:目录与学习路径 · 下一节:1.2 回声客户端与第一个决定 。
常见错误与边界
- 把碎片化理解成“多测几台手机”。碎片化改变的是性能曲线与降级路径,不是测试设备数量。
- 用服务端的“失败即失败”处理客户端请求。客户端还有“不知道成功还是失败”的第三种状态。
- 假设应用会一直在前台。任何不能丢的状态都要落到本地,并在恢复时校验。
- 把热更新当成回滚手段。热更新有平台限制与合规风险,不能替代版本兼容设计。
- 只列必备能力,不列禁用默认值。禁用项才是评审时最容易被放过的部分。
- 把阈值当行业标准。
6 次/小时是案例假设值,团队要按自己的产品事实调整并写进决策记录。 - 分类一次就锁死。产品、网络环境、设备分布都会变,约束需要随版本复核。
- 用分类结果直接选框架。分类输出的是风险与能力,不是技术选型答案。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。