为什么收口需要一个基线
前十二章各自解决了一个局部问题:页面栈怎么记历史,状态放在哪一层,缓存什么时候算新鲜,离线写怎么排队,错误怎么恢复。每一章都能单独跑测试,但“每一块都对”不等于“拼起来能跑”。模块之间的时序、默认值和失败路径,只有在一条完整流程里才会暴露。
基线不是演示页面,而是一条可重复执行的判断链:给定同一组输入,冷启动、缓存命中、弱网失败、进程重启都应该得到确定结果。它回答三个问题:模块有没有接上,失败时用户看到什么,下一次改动有没有把这条链踩断。
本节按 L2 处理:进程内端到端,真实模块、真实状态迁移,但网络、存储与时钟都由适配器注入。没有 DOM,没有真实定时器,也没有真实 HTTP。这样做的代价是测不到浏览器渲染与系统回收,收益是整条链能在几百毫秒内反复运行。
一条流程里的四个角色
基线的四个角色来自前面章节,不在这里重新实现:
| 角色 | 复用模块 | 在基线里的职责 |
|---|---|---|
| 页面栈 | router/page-stack.ts | 记录 feed 到 detail 的进入与返回 |
| 状态容器 | state/store.ts | 保存路由、阶段、列表、数据来源与待发写入 |
| 读数据层 | data/cache-store.ts | 缓存键、新鲜度判定、过期兜底 |
| 写数据层 | data/sync-queue.ts | 入队落盘、幂等键、重启后重放 |
四个角色通过构造参数组合,而不是在模块内部互相 import 单例。页面栈不知道缓存存在,缓存不知道页面栈存在,EchoBaseline 是唯一知道“先读缓存、再决定刷新、失败后标离线”的地方。这个组合根很薄,但它是卷一所有决定的汇合点。
冷启动的顺序固定为三步。第一步 hydrate() 同步读本地缓存,新鲜或可容忍的旧值立即上屏;第二步 openFeed() 走统一的读路径,缓存新鲜就直接返回,不新鲜才发请求;第三步把结果写回 store,页面只订阅 store,不直接碰缓存。顺序不能反:先发请求会让弱网首屏多等一个往返,先渲染再读缓存又会闪一次空列表。
基线实现
文件把四个角色接在一起。注意它没有新增任何缓存或队列算法,只是决定调用顺序与失败语义。
// file: src/summary/baseline.ts
// tier: L2
// verify: node --test test/summary/baseline.test.ts
import { TabbedPageStack } from "../router/page-stack.ts";
import { createStore } from "../state/store.ts";
import type { Store } from "../state/store.ts";
import { EndpointCache, MemoryStore, buildCacheKey, policyFor } from "../data/cache-store.ts";
import type { CacheSource, CacheStore } from "../data/cache-store.ts";
import { SyncQueue } from "../data/sync-queue.ts";
import type {
ConflictPolicy,
Mutation,
MutationInput,
QueueStore,
ReplayReport,
SendResult,
} from "../data/sync-queue.ts";
export interface FeedCard {
id: string;
title: string;
}
export type BaselinePhase = "idle" | "loading" | "ready" | "offline" | "error";
export interface BaselineState {
route: string;
phase: BaselinePhase;
items: FeedCard[];
source: CacheSource | "none";
pendingWrites: number;
lastError: string | null;
}
export interface BaselineOptions {
now: () => number;
loadFeed: () => Promise<FeedCard[]>;
send: (mutation: Mutation) => Promise<SendResult>;
cacheStore?: CacheStore;
queueStore?: QueueStore;
clientId?: string;
maxCacheEntries?: number;
}
const FEED_RESOURCE = "feed:home";
const FEED_ID = "default";
class MemoryQueueStore implements QueueStore {
private raw: string | undefined;
load(): string | undefined {
return this.raw;
}
save(raw: string): void {
this.raw = raw;
}
}
/**
* 卷一收口基线:页面栈负责“在哪”,store 负责“看到什么”,
* EndpointCache 负责“数据从哪来”,SyncQueue 负责“写有没有丢”。
* 所有 I/O 由构造参数注入,测试在进程内推演完整流程。
*/
export class EchoBaseline {
private readonly stack: TabbedPageStack;
private readonly store: Store<BaselineState>;
private readonly cache: EndpointCache;
private readonly queue: SyncQueue;
private readonly loadFeed: () => Promise<FeedCard[]>;
private readonly send: (mutation: Mutation) => Promise<SendResult>;
private readonly key: string;
constructor(options: BaselineOptions) {
this.loadFeed = options.loadFeed;
this.send = options.send;
this.stack = new TabbedPageStack({ home: { name: "feed" } });
this.cache = new EndpointCache({
now: options.now,
store: options.cacheStore ?? new MemoryStore(),
maxEntries: options.maxCacheEntries ?? 40,
});
this.queue = new SyncQueue({
clientId: options.clientId ?? "client-1",
now: options.now,
store: options.queueStore ?? new MemoryQueueStore(),
});
this.key = buildCacheKey({ resource: FEED_RESOURCE, id: FEED_ID });
this.store = createStore<BaselineState>({
route: "feed",
phase: "idle",
items: [],
source: "none",
pendingWrites: this.queue.size,
lastError: null,
});
}
get state(): BaselineState {
return this.store.getState();
}
get route(): string {
return this.stack.current?.route.name ?? "feed";
}
get pages(): string[] {
return this.stack.entries().map((entry) => entry.route.name);
}
/** 冷启动先同步读本地缓存;只有 fresh/stale 才能直接上屏。 */
hydrate(): boolean {
const found = this.cache.lookup<FeedCard[]>(this.key, policyFor(FEED_RESOURCE));
if (!found.entry || found.status === "expired") return false;
this.set({
phase: "ready",
items: found.entry.value,
source: found.status === "fresh" ? "cache-fresh" : "cache-stale",
});
return true;
}
/** 打开发现页:缓存优先;网络失败时用过期缓存兜底,没有缓存才报错。 */
async openFeed(forceRefresh = false): Promise<void> {
this.set({ phase: "loading", lastError: null });
try {
const result = await this.cache.get<FeedCard[]>(
this.key,
policyFor(FEED_RESOURCE),
this.loadFeed,
{ forceRefresh, allowExpiredOnError: true },
);
this.set({
phase: result.source === "cache-stale-offline" ? "offline" : "ready",
items: result.value,
source: result.source,
});
} catch (error) {
this.set({ phase: "error", source: "none", lastError: message(error) });
}
}
navigate(name: string, params: Record<string, string> = {}): void {
this.stack.push({ name, params });
this.set({ route: this.stack.current?.route.name ?? name });
}
back(): void {
this.stack.back();
this.set({ route: this.stack.current?.route.name ?? "feed" });
}
/** 离线写:先入队落盘,再按顺序重放;网络失败会保留剩余条目。 */
async submit(input: MutationInput, policy: ConflictPolicy = "merge-disjoint"): Promise<ReplayReport> {
this.queue.enqueue(input);
this.set({ pendingWrites: this.queue.size });
return this.flush(policy);
}
/** 单独重放队列,用于进程重启后继续上一次没有发完的写入。 */
async flush(policy: ConflictPolicy = "merge-disjoint"): Promise<ReplayReport> {
const report = await this.queue.replay({ send: this.send }, policy);
this.set({
pendingWrites: report.remaining,
lastError: report.stoppedBy === "network" ? "network" : null,
});
return report;
}
private set(patch: Partial<BaselineState>): void {
this.store.setState((state) => ({ ...state, ...patch }));
}
}
function message(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
几个决定值得单独说明。
第一,hydrate() 拒绝 expired 条目。EndpointCache.lookup() 会把超过 fresh 与 stale 之和的数据标成 expired,这类数据不能静默上屏,否则用户会看到几天前的价格或列表。真正需要“离线也要看”的场景,必须在 openFeed() 里显式传 allowExpiredOnError,让兜底成为一次有意的决定。
第二,openFeed() 把 cache-stale-offline 映射成 offline 阶段,而不是继续叫 ready。界面文案、重试按钮和埋点都依赖这个区分:ready 表示数据是新的或可容忍的,offline 表示用户看到的是旧数据且后台没有连上。两者混在一起,事故复盘时就分不清“用户看到了什么”。
第三,写路径先入队再发送。submit() 不是“发一个请求,失败就重试”,而是“把变更落盘,再让队列按顺序重放”。即使进程在发送途中被杀,下一次启动仍能从 QueueStore 读回条目;幂等键在入队时生成,重启不会变化。
第四,flush() 是独立方法。这让“重启后继续发”成为显式动作,而不是藏在构造函数里的副作用。构造一个基线对象不应该发网络请求,否则测试和真实的恢复流程都会变得不可控。
测试与真实输出
测试覆盖五条路径:冷启动空缓存、缓存新鲜命中、过期缓存加网络失败、页面栈往返、队列跨实例续传。完整测试文件在 test/summary/baseline.test.ts,与正文实现配套。
在实验工程根目录执行单文件命令:
node --test test/summary/baseline.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 冷启动缓存为空时走网络,成功后写入统一缓存键 (1.820458ms)
✔ 新鲜缓存直接上屏,第二次打开不再请求网络 (0.435583ms)
✔ 缓存超过 stale 窗口且网络失败时,退回过期缓存并标记离线 (0.581583ms)
✔ 页面栈与 store 同步:进入详情再返回,路由回到发现页 (0.244167ms)
✔ 写入在进程重启后仍能从持久队列继续发送 (0.368667ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 189.891166
第三条测试最容易写错。它先把数据写入缓存,再把注入时钟推进到 90_000_000 毫秒,超过 feed:home 的 fresh 60 秒加 stale 24 小时;此时新建一个客户端,hydrate() 必须返回 false,openFeed() 的加载器必须抛错,最终阶段必须是 offline 而不是 error。如果缓存策略或兜底参数被改动,这条断言会先失败。
第五条测试验证的是持久化边界,不是队列算法本身。第一个客户端发送失败后,QueueStore 里留下了带 attempts 的条目;第二个客户端用同一个 store 构造,pendingWrites 从 1 开始,flush() 成功后归零。它证明“进程重启”这件事在基线层面是可恢复的,但不能证明操作系统真的会在任意时刻杀进程,这个边界留给 L3。
一次进程回收后的恢复记录
假设用户在发现页滑到第三屏,点进一篇文章,写了一条评论但网络断了,然后切到后台。十分钟后系统回收进程。第二天用户重新打开应用。
基线能恢复的部分是明确的:缓存里的发现页列表还在,hydrate() 会立即上屏;未发送的评论在队列里,pendingWrites 会显示 1,flush() 会带着原幂等键重发;如果服务端已经处理过同一条评论,返回 duplicate,队列同样视为成功,不会产生第二条。
基线不能恢复的部分同样明确:页面栈本身没有持久化,用户回到的是 feed 根页面,而不是那篇文章;滚动位置也没有保存。这不是遗漏,而是卷一划出的边界。页面栈快照、滚动位置与草稿属于“恢复体验”,需要单独决定存什么、存多久、如何校验;13.3 会把“进程回收恢复”作为质量属性场景重新评估,而不是假装基线已经解决。
这份记录的价值在于把“恢复”拆成可验证的条目。缓存恢复有测试,队列续传有测试,页面栈恢复没有测试,因此它不能出现在验收结论里。评审时最危险的不是“没做”,而是“以为做了”。
基线能证明什么,不能证明什么
能证明的部分:四个模块在一条链上能按预期协作;缓存命中不会触发网络;网络失败时旧数据能按策略兜底;页面栈与 store 的路由保持同步;写入队列跨实例不丢条目。每一条都有断言,改动后能回归。
不能证明的部分:真实网络的首包时间、浏览器渲染与交互、系统级进程回收、真实存储配额、并发写入的服务端冲突处理。这些需要 L3 环境或线上灰度,不能由进程内基线替代。
这条边界要写进验收记录。基线通过只说明“逻辑链成立”,不说明“产品可以发布”。把 L2 当 L3 用,是实验工程里最常见的自欺。
小结
卷一的收口不是再写一个新模块,而是把已有模块接成一条可重复的流程:页面栈回答在哪,store 回答看到什么,缓存回答数据从哪来,队列回答写有没有丢。组合根只负责顺序与失败语义,算法仍留在各自章节。
基线的价值在于把“拼起来能跑”变成可回归的断言。它用注入适配器换来确定性,用 L2 边界换来快速反馈;同时明确留下页面栈恢复、真实渲染与系统回收三个缺口,交给后续章节和更高层级处理。
阅读导航:上一节:12.3 埋点规范与数据质量 · 下一节:13.2 模块划分与依赖图 。
常见错误与边界
- 把基线当成演示页面。基线是可断言的流程,不是截图。
- 在组合根里重新实现缓存或队列算法。算法属于各自模块,组合根只决定顺序。
- 构造基线对象时顺手发请求。构造应该是纯的,恢复与刷新要显式调用。
- 把
expired缓存静默上屏。过期兜底必须是显式参数,不能成为默认行为。 - 把
offline混进ready。用户看到旧数据与看到新数据必须有不同状态。 - 用 L2 通过证明可以发布。渲染、系统回收与真实网络不在基线覆盖范围内。
- 只测成功路径。弱网失败、缓存过期、重启续传才是端到端最容易断的地方。
- 忽略幂等键的持久化。重启后重新生成幂等键,会让服务端把同一次写入当成两次。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。