《互联网产品的客户端架构·端形态与体验基础》1.3 实验工程的三级视图

说明 echo-client-lab 为什么把可运行性分成 L1 纯逻辑、L2 进程内端到端、L3 本地服务三级,给出可运行的 TypeScript 分级器、本机 Node 26 的真实测试输出、代码块抽取与逐字节校验流程,并明确真机性能与真实浏览器渲染不在承诺范围内,避免把示例当成实测。

为什么先谈边界

客户端书最容易犯的错,是给出一个“在真机上应该会很快”的结论,然后被读者当成事实。浏览器渲染、设备调度、网络切换、系统省电策略,这些东西在 Node 里都复现不出来。与其含糊地说“示例可以运行”,不如先把工程能承诺什么、不能承诺什么写清楚。

echo-client-lab 把可运行性分成三级:

级别判定验证方式本书承诺
L1纯逻辑,无外部进程与真实时序node --test代码可运行、断言可复现
L2进程内端到端,需要串起多个模块一个脚本跑完整流程流程可复现、状态可断言
L3需要本地 HTTP/WebSocket 服务或真实时序起本地服务 + 固定场景时序与重连逻辑可复现
不承诺真实浏览器渲染、真机性能、系统调度无只给模型与判断方法,不写实测数字

这个分级不是为了给章节贴标签,而是为了约束写作:每一节动手前先判定级别,级别决定这段代码要不要真实运行、运行结果能不能写进正文、哪些结论只能写成“预期”或“案例假设值”。

三级分别验证什么

L1:纯逻辑

L1 验证的是不依赖运行环境的判断逻辑:状态机、缓存策略、请求去重、冲突合并、指标计算。这类代码的输入输出完全由参数决定,同样的调用顺序得到同样的结果。

L1 的门禁是 node --test 全绿。它不能证明真机上不会卡,但能证明“给定这些输入,判断是对的”。本书大量使用 L1,因为架构决策的大部分错误发生在逻辑层,而不是渲染层。

L2:进程内端到端

L2 把多个 L1 模块串起来,验证一条完整流程:打开页面、加载数据、失败重试、写入本地、恢复快照。它仍然不启动外部进程,但会使用内存适配器模拟存储与网络。

L2 的门禁是一个脚本跑完整流程并断言关键状态。它比 L1 更容易暴露“模块各自正确、组合起来不对”的问题,比如缓存写入了但页面没更新,或者恢复时页面栈顺序错了。

L3:本地服务

L3 需要真实的 HTTP 或 WebSocket 服务,用来验证长连接、重连、离线补拉、请求超时这类与时序相关的行为。实验工程用 Node 内置的 http 模块起一个本地服务,不引入第三方框架。

L3 的门禁是固定场景脚本:启动服务、制造断连、观察重连与补拉、断言最终状态。它仍然不是“真机弱网”,但能把“重连逻辑有没有写对”从猜测变成验证。

不承诺的部分

真实浏览器渲染、真机启动耗时、帧率、内存占用、系统后台回收策略,这些都需要真实设备与浏览器环境。本书只给模型、口径与判断方法,不写“在某某手机上实测 1.2 秒”这类无法复现的数字。

如果某一节确实需要浏览器行为,会明确标注“需在浏览器环境验证”,并给出可复现的检查步骤,而不是伪造一个运行输出。

分级器

下面的分级器把“这段实验需要什么”翻译成级别与验证命令。它本身是 L1,因为判定只依赖输入。

// file: src/core/lab.ts
// tier: L1
// verify: node --test test/core/lab.test.ts

export type Tier = "L1" | "L2" | "L3";

export interface LabPlan {
  /** 是否需要真实网络或子进程 */
  needsExternalProcess: boolean;
  /** 是否只验证进程内的纯逻辑 */
  pureLogic: boolean;
  /** 是否需要模拟断网、重连等时序 */
  needTimedSequence: boolean;
  /** 是否依赖真实浏览器渲染 */
  needsBrowser: boolean;
}

export interface TierDecision {
  tier: Tier;
  reason: string;
  command: string;
}

/**
 * 给一个实验计划判定最低可运行级别,并给出默认验证命令。
 * 真实浏览器渲染不在本工程承诺范围内,遇到时返回 L3 并明确标注边界。
 */
export function classifyTier(plan: LabPlan): TierDecision {
  if (plan.needsBrowser) {
    return {
      tier: "L3",
      reason: "依赖真实浏览器渲染,本工程只验证逻辑层,需在浏览器环境另行执行",
      command: "node --test",
    };
  }
  if (plan.needsExternalProcess || plan.needTimedSequence) {
    return {
      tier: "L3",
      reason: "需要真实进程或时序,用本地 HTTP/WebSocket 服务验证",
      command: "node --test",
    };
  }
  if (plan.pureLogic) {
    return {
      tier: "L1",
      reason: "纯逻辑,无外部进程与真实时序",
      command: "node --test",
    };
  }
  return {
    tier: "L2",
    reason: "进程内端到端,需要一个脚本跑完整流程",
    command: "node --test",
  };
}

/** 卷一的验收门禁:三级都必须能由同一条命令在本地复现。 */
export function gateFor(tier: Tier): string[] {
  const base = ["node --test"];
  if (tier === "L1") return base;
  return [...base, "node --test --test-concurrency=1"];
}

判定顺序是:浏览器 > 外部进程/时序 > 纯逻辑 > 端到端。这个顺序保证“更接近真实环境”的需求优先,不会被“看起来像纯逻辑”掩盖。比如一个重连状态机,单看代码是纯函数,但它依赖时序,就必须按 L3 验证。

gateFor 对 L2/L3 追加 --test-concurrency=1,原因是涉及本地端口或共享状态的测试并行跑会互相干扰。这个参数不是性能优化,而是正确性要求。

本机环境与真实输出

实验工程假设 Node 26 及以上,利用原生 TypeScript 类型剥离直接运行 .ts,不需要构建步骤。本机验证过的版本:

node --version
v26.5.0

分级器的测试覆盖四种判定与门禁命令:

node --test test/core/lab.test.ts

本机一次运行的真实输出:

✔ 纯逻辑实验判为 L1 (0.49125ms)
✔ 需要真实时序的实验判为 L3 (0.119084ms)
✔ 真实浏览器渲染明确标为边界之外 (0.065042ms)
✔ 进程内端到端在没有外部依赖时判为 L2 (0.053416ms)
✔ L2/L3 的验收命令串行执行,避免共享端口冲突 (0.414209ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 137.912208

类型剥离只去掉类型,不做类型检查。类型错误不会被 node --test 拦住,需要额外的 tsc --noEmit。本书不把类型检查设为硬门禁,原因有两个:一是跨环境安装 TypeScript 编译器会引入网络依赖,破坏“零依赖可复现”;二是本书示例的类型复杂度有限,运行时断言已经能覆盖主要错误。读者在自己的工程里应该把类型检查加回 CI。

零依赖还有一层考虑:客户端生态的框架、构建器与测试库更新很快,书里的版本号一过时,示例就跑不起来。把可运行部分收敛到语言内置的能力上,能让内容在更长时间里保持可复现。框架相关的部分只讲判断方法,不绑定具体版本。

代价也要说清楚:没有类型检查、没有 UI 测试、没有真实浏览器,意味着本书不能替你验证“这个组件在低端机上会不会掉帧”。它验证的是决策逻辑与数据流;渲染与设备性能需要你在自己的工程里补。把这条边界写在前面,比在最后一章补一句“请以实际为准”更诚实。

代码块怎么变成可运行工程

书稿里的代码块不是独立片段,而是实验工程文件的副本。每个代码块开头有三行标记:

// file: src/core/lab.ts
// tier: L1
// verify: node --test test/core/lab.test.ts
  • file:这段代码在实验工程里的路径。
  • tier:这一节的可运行级别。
  • verify:验证这条结论要跑的命令。

写作流程是:先在实验工程里写文件并跑通测试,再把文件内容原样贴进正文。校验脚本反向工作:从正文提取每个代码块,按 file 路径与工程文件逐字节比对;不一致就报错。这样能防止“正文改了、代码没改”或“代码能跑、正文贴的是旧版本”。

同一路径在书中出现多次时,每次内容必须一致。校验脚本会记录第一次出现的内容,后续出现不一致直接失败。这条规则看起来严格,但它保证了读者按任意一节复现,拿到的都是同一份代码。

目录结构与章节规划

卷一结束时,实验工程的目录大致如下:

echo-client-lab/
  src/
    core/          # 约束分类、客户端模型、分级器
    state/         # 状态容器与状态机
    router/        # 路由解析与页面栈
    data/          # 请求编排、缓存、离线队列
    view/          # 组件契约与受控状态
    build/         # 产物预算与分包检查
    obs/           # 指标口径与埋点校验
  test/
    core/          # 与 src 对应的测试
    state/
    router/
    data/
    view/
    build/
    obs/

每一章对应一个目录,三个源文件对应三节,三个测试文件验证三节的核心断言。第 13 章会把所有模块串成一条端到端流程,作为卷一的收口。

分级规划大致是:第 1 至 6 章以 L1 为主,第 7 章的数据层与第 10 章的渲染一致性会用到 L2,第 11 章的错误恢复用到 L3 的本地服务。真实浏览器相关的部分只给模型,不进入门禁。

一次“示例能跑”但结论不成立的教训

早期版本里,我把“请求去重”写成了纯逻辑测试,断言同一 key 只调用一次 fetcher。测试通过,但把它放进发现页的真实滚动流程后,去重失效了。原因是每次滚动都创建了新的 EchoClient 实例,inflight 跟着实例一起重建。

这个 bug 在 L1 里抓不到,因为 L1 的测试只验证单个实例的行为;它也不是 L3 的问题,因为不需要真实网络。它属于 L2:多个模块组合后的生命周期。后来加了一条 L2 用例,模拟“页面重新挂载但共享同一个数据层”,才把这个错误固定下来。

这件事说明分级不是形式主义。级别判错了,测试会给你虚假的安全感:断言都过了,结论却不成立。判断标准不是“这段代码看起来简不简单”,而是“结论依赖哪些运行时事实”。

小结

三级视图的核心不是三个标签,而是三个承诺:L1 承诺逻辑可复现,L2 承诺流程可复现,L3 承诺时序可复现。真实浏览器与真机性能不在承诺范围内,相关结论只给模型与检查方法。

把代码块与工程文件逐字节绑定,是为了让“书里的代码能跑”成为可校验的事实,而不是作者的声明。读者按任意一节复现,拿到的都是同一份代码。

下一章会从端形态谱系开始,讨论 Web、原生、小程序与桌面在约束上的差异,以及为什么“同构”不是一个可以无条件套用的目标。

阅读导航:上一节:1.2 回声客户端与第一个决定 · 下一节:2.1 Web、原生、小程序与桌面 。

常见错误与边界

  • 把 L1 通过当成结论成立。逻辑正确不代表组合后的流程正确。
  • 把需要时序的测试写成纯函数测试。重连、超时、队列重放都依赖时间。
  • 用本地 Node 的耗时代表真机性能。两者没有可比性。
  • 声称“已在浏览器验证”却没有可复现步骤。这类声明无法审计。
  • 正文代码与工程文件各改各的。逐字节校验就是为这个问题准备的。
  • 把类型检查当成可选项却不说明。类型剥离不做类型检查,团队应在 CI 里补上。
  • 分级一次定终身。模块组合方式变了,级别可能要上调。
  • 为了凑 L3 引入真实网络依赖。能在 L1/L2 验证的结论,不要为了“更真实”付出不可复现的代价。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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