《互联网产品的客户端架构·端形态与体验基础》1.1 客户端架构的四个独有约束

从「回声」的三类界面出发,把客户端架构与服务器架构的差别收敛成设备碎片化、网络不确定、生命周期被打断、分发不可回滚四个独有约束,给出可运行的 TypeScript 分类器与测试,并复盘一次把在线默认值带进离线场景的失败,说明端上判断为什么必须先于框架选型。

从三个界面说起

「回声」是本书的贯穿案例,一个同时有内容流、私信与站内下单的产品。上一套书从服务端看它,这套书从端上看它。同一天里,用户可能做三件事:

  • 在地铁里刷发现页,网络时断时续,图片加载到一半列车进隧道。
  • 在会话页等一条回复,应用被切到后台,十分钟后系统回收了进程。
  • 在下单页填地址,接了个电话回来,页面还在,但输入框里的内容没了。

这三件事在服务器架构里几乎看不见。服务端只收到请求或收不到请求,它不关心进程有没有被系统杀掉,也不关心用户是在地铁里还是在 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 次/小时是案例假设值,团队要按自己的产品事实调整并写进决策记录。
  • 分类一次就锁死。产品、网络环境、设备分布都会变,约束需要随版本复核。
  • 用分类结果直接选框架。分类输出的是风险与能力,不是技术选型答案。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查