为什么先谈边界
客户端书最容易犯的错,是给出一个“在真机上应该会很快”的结论,然后被读者当成事实。浏览器渲染、设备调度、网络切换、系统省电策略,这些东西在 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 验证的结论,不要为了“更真实”付出不可复现的代价。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。