第一个决定不是选框架
新项目启动时,团队最常问的是“用 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:背景、选项、决定、后果、复核条件。
背景:回声客户端第一版有三个页面,状态分散在组件、路由、本地存储与服务端。团队需要先确定状态归属,再选状态库。
选项:
- 全部状态放全局 store,组件只读。
- 组件状态优先,只有跨页面共享的状态进 store。
- 按“丢失代价”分层,每层用不同机制。
决定:选 3。界面状态留在组件;页面状态进路由与内存;草稿与队列落本地存储;服务端状态由数据层缓存,组件不直接持有。
后果:需要维护四套机制,初期代码量比“一个全局 store”多;但每类状态的测试方式清晰,恢复策略也可以分别设计。全局 store 只保留跨页面的界面状态(比如当前用户、主题),不承担服务端数据。
复核条件:当跨页面共享状态超过 20 个,或者出现三处以上“同一份数据在两个页面各存一份”时,重新评估是否需要更统一的状态层。
这份记录的价值不在于选项 3 一定正确,而在于它把“为什么不用全局 store”写下来了。半年后有人问“为什么不直接把数据都放 store”,答案在记录里,不需要重新吵一遍。
与服务端会话的对照
服务端的会话通常指“用户登录后的一段服务端状态”,客户端也有会话,但含义更宽:
| 维度 | 服务端会话 | 客户端会话 |
|---|---|---|
| 存储位置 | 内存或集中式存储 | 内存 + 本地存储 |
| 生命周期 | 由超时与续期控制 | 由进程与用户操作控制 |
| 丢失影响 | 需要重新登录 | 可能丢失草稿与队列 |
| 一致性 | 服务端是权威 | 端上可能领先或落后 |
客户端会话不是服务端会话的副本,它多了“离线期间产生的本地变更”。这也是为什么客户端不能简单地“每次启动从服务端拉全量状态”:本地可能还有未同步的写入,直接覆盖会丢数据。
小结
第一个决定是状态归属,不是框架选型。判断依据是“这份状态丢了会怎样”:可丢的放内存,页面状态放路由,草稿与队列落本地,服务端状态由数据层缓存。四层分开之后,测试、恢复与替换都变得可控。
EchoClient 只实现了前两层,这是有意的边界。把不可丢的状态混进通用快照,会让恢复逻辑变得不可判定;正确做法是单独的待办日志,在第 7 章展开。
下一节会说明实验工程的三级视图:哪些结论可以在 Node 里验证,哪些必须留给真实浏览器与真机,以及为什么把边界写清楚比假装全能更重要。
阅读导航:上一节:1.1 客户端架构的四个独有约束 · 下一节:1.3 实验工程的三级视图 。
常见错误与边界
- 先选状态库再讨论状态归属。库可以换,归属错了要重写。
- 把所有状态塞进全局 store。服务端数据进 store 后,过期与失效逻辑会散落在组件里。
- 快照包含不可丢状态。快照会越来越大,恢复时也无法判断哪些可以安全丢弃。
- 请求失败后不清理 inflight。后续重试会一直拿到同一个 rejected promise。
- 缓存没有上限。长时间使用后内存持续增长,低端机最先出问题。
- 把页面栈上限当成“用户体验优化”。它首先是内存与恢复成本的控制。
- 假设会话恢复一定成功。恢复失败要有从首页重新开始的路径。
- 把本地状态当权威。离线期间本地可能领先,同步时要按冲突策略合并,不能直接覆盖。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。