《互联网产品的客户端架构·端形态与体验基础》13.1 回声客户端基线

把页面栈、共享状态、缓存数据层与离线写队列串成一条可运行的端到端基线:用注入适配器在进程内复现冷启动、缓存命中、弱网兜底与进程重启后的队列续传,给出 TypeScript 实现、五个测试的真实输出,并说明 L2 基线能证明与不能证明的边界。

为什么收口需要一个基线

前十二章各自解决了一个局部问题:页面栈怎么记历史,状态放在哪一层,缓存什么时候算新鲜,离线写怎么排队,错误怎么恢复。每一章都能单独跑测试,但“每一块都对”不等于“拼起来能跑”。模块之间的时序、默认值和失败路径,只有在一条完整流程里才会暴露。

基线不是演示页面,而是一条可重复执行的判断链:给定同一组输入,冷启动、缓存命中、弱网失败、进程重启都应该得到确定结果。它回答三个问题:模块有没有接上,失败时用户看到什么,下一次改动有没有把这条链踩断。

本节按 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 通过证明可以发布。渲染、系统回收与真实网络不在基线覆盖范围内。
  • 只测成功路径。弱网失败、缓存过期、重启续传才是端到端最容易断的地方。
  • 忽略幂等键的持久化。重启后重新生成幂等键,会让服务端把同一次写入当成两次。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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