《互联网产品的客户端架构·端形态与体验基础》1.2 回声客户端与第一个决定

把「回声」客户端收敛成页面栈、请求去重与有上限缓存三个最小部件,先回答「状态放在哪」而不是「用哪个状态库」,给出可运行的 TypeScript 模型、快照恢复与真实测试输出,并附一份状态归属决策记录,说明第一个决定如何约束后面所有架构选择。

第一个决定不是选框架

新项目启动时,团队最常问的是“用 React 还是 Vue”“状态用 Redux 还是 Zustand”。这些问题当然要回答,但它们不是第一个决定。第一个决定是:每一类状态放在哪里,谁负责它的生命周期。

框架和状态库只是这个决定的实现手段。状态归属错了,换什么库都救不回来;状态归属对了,库可以随时替换。端上常见的状态可以分成四层:

状态例子默认归属丢失后果
界面状态弹窗开关、输入框焦点组件内存可接受,重新操作
页面状态当前页、滚动位置、筛选条件路由 + 内存可接受,可恢复
草稿与队列未发送消息、未提交表单本地存储不可接受,必须持久化
服务端状态内容流、会话列表、订单服务端 + 端上缓存可重新拉取,但要处理过期

这张表的顺序不是按“重要性”排的,而是按“丢失代价”排的。越往下,越不能只放在内存里。很多端上事故的根因,是把第三层状态当成了第一层:用户写了半天的内容,一次进程回收就没了。

本节用「回声」客户端的三个页面把这个决定具体化,并写出一段可运行的模型。代码不依赖 DOM、定时器或网络库,因此可以在 Node 里直接验证。

三个页面与它们的状态

「回声」客户端的第一版只有三个页面:

  • 发现页:内容流列表,支持分页与下拉刷新。
  • 会话页:与某个用户的私信,支持发送文本与查看历史。
  • 下单页:商品确认、地址填写、提交订单。

按上一节的四层划分,三个页面的状态归属并不相同。

发现页的列表是服务端状态,端上只做缓存。它需要处理分页游标、刷新与过期,但列表丢了可以重新拉,代价是用户要再等一次加载。

会话页的消息是混合状态。已经收到或发出的消息属于服务端状态,本地缓存是为了离线可读;正在输入但未发送的文本属于草稿,必须落本地;发送队列属于“不能丢”的状态,进程被杀后要能继续发。

下单页的地址与订单确认信息属于草稿,金额与库存属于服务端状态且必须校验。用户填了一半的地址如果丢了,转化率会直接下降,所以它和未发送消息一样,必须持久化。

这就是第一个决定的具体形状:不是“要不要用全局状态”,而是“这份状态丢了会怎样”。答案决定了它放在组件、路由、本地存储还是服务端。

一个最小模型

下面的 EchoClient 把三件事收进一个类:页面栈、请求去重、有上限的缓存。它故意做得很小,目的是让状态归属可以被测试,而不是提供一个生产级框架。

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

export interface Page {
  name: string;
  params: Record<string, string>;
}

export interface Snapshot {
  pages: Page[];
  cache: Array<[string, unknown]>;
  sessionId: string;
}

export interface ClientOptions {
  sessionId: string;
  cacheLimit: number;
}

export type Fetcher<T> = (key: string) => Promise<T>;

/**
 * 回声客户端的最小模型:页面栈 + 请求去重 + 有上限的缓存。
 * 不依赖 DOM、定时器或网络实现,所有副作用都从参数注入,便于在 Node 里验证。
 */
export class EchoClient {
  private pages: Page[];
  private cache = new Map<string, unknown>();
  private inflight = new Map<string, Promise<unknown>>();
  private readonly sessionId: string;
  private readonly cacheLimit: number;

  constructor(options: ClientOptions, initial?: Snapshot) {
    this.sessionId = options.sessionId;
    this.cacheLimit = options.cacheLimit;
    this.pages = initial ? [...initial.pages] : [];
    if (initial) {
      for (const [k, v] of initial.cache) this.cache.set(k, v);
    }
  }

  get current(): Page | undefined {
    return this.pages[this.pages.length - 1];
  }

  get depth(): number {
    return this.pages.length;
  }

  navigate(page: Page): void {
    this.pages.push(page);
    if (this.pages.length > 12) this.pages.shift();
  }

  back(): Page | undefined {
    if (this.pages.length <= 1) return undefined;
    this.pages.pop();
    return this.current;
  }

  /**
   * 同一 key 的并发请求只发一次;结果写入缓存,超过上限时按插入顺序淘汰。
   */
  async load<T>(key: string, fetcher: Fetcher<T>): Promise<T> {
    if (this.cache.has(key)) return this.cache.get(key) as T;
    const running = this.inflight.get(key);
    if (running) return running as Promise<T>;

    const task = fetcher(key).then((value) => {
      this.inflight.delete(key);
      this.writeCache(key, value);
      return value;
    }, (err) => {
      this.inflight.delete(key);
      throw err;
    });
    this.inflight.set(key, task);
    return task;
  }

  snapshot(): Snapshot {
    return {
      pages: this.pages.map((p) => ({ name: p.name, params: { ...p.params } })),
      cache: [...this.cache.entries()],
      sessionId: this.sessionId,
    };
  }

  private writeCache(key: string, value: unknown): void {
    if (this.cacheLimit <= 0) return;
    this.cache.set(key, value);
    while (this.cache.size > this.cacheLimit) {
      const oldest = this.cache.keys().next().value;
      if (oldest === undefined) break;
      this.cache.delete(oldest);
    }
  }
}

这段代码里有三个容易被忽略的决定。

页面栈有上限。 navigate 在超过 12 层时丢弃最早的一页。端上内存有限,页面栈无限增长会让快照越来越大,恢复也越来越慢。丢弃最早一页的代价是用户不能再一路返回,但比进程被杀更轻。

并发请求按 key 去重。 发现页快速滚动时,同一个详情请求可能被触发多次;inflight 保证同一时刻只有一个真实请求。注意失败时要从 inflight 删除,否则一次失败会让后续重试永远拿到同一个 rejected promise。

缓存按插入顺序淘汰。 这是最简单的 LRU 近似。真实产品通常需要按访问时间淘汰,但第一版先保证行为可预测:同样的调用顺序,快照内容完全一致,测试可以直接比对。

这段模型没有处理“缓存过期”。过期策略属于服务端状态的语义,应该由数据层根据资源类型决定,而不是塞进这个通用容器。7.2 节会展开。

快照与恢复

生命周期被打断之后,能不能恢复,取决于快照里有什么。snapshot() 只包含页面栈、缓存和会话 ID,不包含未发送消息与草稿——那两类状态必须单独持久化,并且要校验完整性。

这是一个有意的边界。把“可丢的状态”和“不可丢的状态”混在一个快照里,会导致两个问题:快照越来越大,以及恢复时无法判断哪些内容可以安全丢弃。正确做法是分成两份:

  • 会话快照:页面栈与缓存,允许丢失,恢复失败就从首页重新开始。
  • 待办日志:草稿、发送队列、未完成的上传,必须逐条确认写入成功,恢复时按顺序重放。

EchoClient 只负责前者。后者会在第 7 章的数据层里实现,核心是“先写日志再执行”,而不是“执行完再补日志”。

测试与真实输出

测试覆盖页面栈、去重、失败重试、缓存淘汰与快照恢复五个场景。下面只列出运行命令与真实输出,完整测试文件在实验工程里与正文代码一起维护。

node --test test/core/echo-client.test.ts

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

✔ 页面栈遵循后进先出,根页面不能再返回 (0.530875ms)
✔ 同一 key 的并发请求只触发一次 fetcher (0.155542ms)
✔ 失败不写缓存,下一次调用会重新发起 (0.220042ms)
✔ 缓存超过上限时按插入顺序淘汰 (0.477709ms)
✔ 快照可以恢复到新的实例 (0.11525ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 137.912208

输出里的耗时是整次运行的墙钟时间,不是单个用例的累计,所以不能拿来比较优化效果。判定标准只有两个:退出码为 0,以及 fail 0。

第一个决定的决策记录

把上面的讨论写成一份决策记录,格式沿用服务端卷的 ADR:背景、选项、决定、后果、复核条件。

背景:回声客户端第一版有三个页面,状态分散在组件、路由、本地存储与服务端。团队需要先确定状态归属,再选状态库。

选项:

  1. 全部状态放全局 store,组件只读。
  2. 组件状态优先,只有跨页面共享的状态进 store。
  3. 按“丢失代价”分层,每层用不同机制。

决定:选 3。界面状态留在组件;页面状态进路由与内存;草稿与队列落本地存储;服务端状态由数据层缓存,组件不直接持有。

后果:需要维护四套机制,初期代码量比“一个全局 store”多;但每类状态的测试方式清晰,恢复策略也可以分别设计。全局 store 只保留跨页面的界面状态(比如当前用户、主题),不承担服务端数据。

复核条件:当跨页面共享状态超过 20 个,或者出现三处以上“同一份数据在两个页面各存一份”时,重新评估是否需要更统一的状态层。

这份记录的价值不在于选项 3 一定正确,而在于它把“为什么不用全局 store”写下来了。半年后有人问“为什么不直接把数据都放 store”,答案在记录里,不需要重新吵一遍。

与服务端会话的对照

服务端的会话通常指“用户登录后的一段服务端状态”,客户端也有会话,但含义更宽:

维度服务端会话客户端会话
存储位置内存或集中式存储内存 + 本地存储
生命周期由超时与续期控制由进程与用户操作控制
丢失影响需要重新登录可能丢失草稿与队列
一致性服务端是权威端上可能领先或落后

客户端会话不是服务端会话的副本,它多了“离线期间产生的本地变更”。这也是为什么客户端不能简单地“每次启动从服务端拉全量状态”:本地可能还有未同步的写入,直接覆盖会丢数据。

小结

第一个决定是状态归属,不是框架选型。判断依据是“这份状态丢了会怎样”:可丢的放内存,页面状态放路由,草稿与队列落本地,服务端状态由数据层缓存。四层分开之后,测试、恢复与替换都变得可控。

EchoClient 只实现了前两层,这是有意的边界。把不可丢的状态混进通用快照,会让恢复逻辑变得不可判定;正确做法是单独的待办日志,在第 7 章展开。

下一节会说明实验工程的三级视图:哪些结论可以在 Node 里验证,哪些必须留给真实浏览器与真机,以及为什么把边界写清楚比假装全能更重要。

阅读导航:上一节:1.1 客户端架构的四个独有约束 · 下一节:1.3 实验工程的三级视图 。

常见错误与边界

  • 先选状态库再讨论状态归属。库可以换,归属错了要重写。
  • 把所有状态塞进全局 store。服务端数据进 store 后,过期与失效逻辑会散落在组件里。
  • 快照包含不可丢状态。快照会越来越大,恢复时也无法判断哪些可以安全丢弃。
  • 请求失败后不清理 inflight。后续重试会一直拿到同一个 rejected promise。
  • 缓存没有上限。长时间使用后内存持续增长,低端机最先出问题。
  • 把页面栈上限当成“用户体验优化”。它首先是内存与恢复成本的控制。
  • 假设会话恢复一定成功。恢复失败要有从首页重新开始的路径。
  • 把本地状态当权威。离线期间本地可能领先,同步时要按冲突策略合并,不能直接覆盖。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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