状态放错位置,后面都在补救
「回声」的发现页有一个筛选面板:内容类型、发布时间、是否关注。面板收起后,筛选条件仍要参与列表请求;用户从发现页进入详情页再返回,筛选条件最好还在。另一个位置是会话页底部的草稿栏,用户输入的半句话既要在输入框里,也要让顶部的“保存草稿”按钮知道当前有没有内容。
这两个需求看起来都像“需要全局状态”,实际归属并不相同。筛选条件只有列表页自己读取,详情页不关心;草稿栏的文本同时被输入框和保存按钮读取,而且切换会话后还要保留。前者留在页面本地就够了,后者才需要提升。
状态管理的第一问题不是“用哪个库”,而是“这份状态有几个读取方、活多久、丢了会怎样”。本节先把这三个问题收敛成一条可执行的判据,再写一个最小订阅容器,让判据和容器都能在 Node 里被测试。
三种归属与一条判据
端上的状态先按读取范围分三层,服务端状态单独一类:
| 归属 | 典型状态 | 读取方 | 生命周期 | 丢失代价 |
|---|---|---|---|---|
| 组件本地 | 输入框焦点、面板展开 | 单一组件 | 一次渲染 | 可接受 |
| 页面本地 | 筛选条件、滚动位置 | 单一页面 | 一次导航 | 可接受,可重置 |
| 共享 store | 当前用户、主题、草稿标记 | 多个组件 | 跨页面 | 需要恢复 |
| 服务端缓存 | 内容流、会话、订单 | 多个页面 | 由过期策略控制 | 可重新拉取 |
判据按固定顺序执行,越靠前越优先:
- 数据是否以服务端为权威?是则交给缓存层,不进入组件间共享。
- 去重后的读取方是否达到两个?是则提升为共享状态。
- 生命周期是否超过单次视图,且持有者到使用者的层级距离达到两层?是则提升。
- 其余情况留在本地。
这个顺序很重要。服务端状态即使被十个页面读取,也不应该塞进共享 store,否则过期、失效与重新拉取会散落在各个组件里。反过来,一个纯界面状态即使只有一个读取方,只要它要跨两层传递且活过一次导航,提升仍然比一路上举回调更便宜。
最小订阅容器
下面的 createStore 只做四件事:读当前值、用更新函数替换状态、订阅整体变化、按选择器订阅局部变化。它不依赖框架、DOM 或调度器,因此可以直接在 Node 里验证通知顺序。
// file: src/state/store.ts
// tier: L1
// verify: node --test test/state/store.test.ts
export type Listener<T> = (value: T) => void;
export type Selector<T, S> = (state: T) => S;
export type Equality<S> = (a: S, b: S) => boolean;
export type Updater<T> = (state: T) => T;
export interface Store<T> {
getState(): T;
setState(next: T | Updater<T>): void;
subscribe(listener: Listener<T>): () => void;
select<S>(
selector: Selector<T, S>,
listener: Listener<S>,
isEqual?: Equality<S>,
): () => void;
}
/**
* 最小订阅容器:状态不可变,setState 只在引用变化时通知。
* 不依赖框架、DOM 或调度器,监听同步执行,便于在 Node 里验证顺序。
*/
export function createStore<T>(initial: T): Store<T> {
let state = initial;
const listeners = new Set<Listener<T>>();
const getState = (): T => state;
const setState = (next: T | Updater<T>): void => {
const value = typeof next === "function" ? (next as Updater<T>)(state) : next;
if (Object.is(value, state)) return;
state = value;
for (const listener of [...listeners]) listener(state);
};
const subscribe = (listener: Listener<T>): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
const select = <S>(
selector: Selector<T, S>,
listener: Listener<S>,
isEqual: Equality<S> = Object.is,
): (() => void) => {
let current = selector(state);
const onState = (next: T): void => {
const value = selector(next);
if (isEqual(value, current)) return;
current = value;
listener(value);
};
listeners.add(onState);
return () => {
listeners.delete(onState);
};
};
return { getState, setState, subscribe, select };
}
export type StateScope = "local" | "shared" | "server-cache";
export type StateLifetime = "view" | "session" | "persist";
export interface ScopeInput {
/** 读取这份状态的界面位置,按去重后的数量判断 */
readers: string[];
/** 当前持有者到共同祖先的层级距离 */
ownerDepth: number;
lifetime: StateLifetime;
/** 数据是否以服务端为权威 */
serverOwned: boolean;
}
export interface ScopeDecision {
scope: StateScope;
lift: boolean;
reason: string;
}
/**
* 判断一份状态该留在本地、提升到共享 store,还是交给服务端缓存层。
* 服务端状态优先于任何提升规则;读取方数量优先于传递层级。
*/
export function decideStateScope(input: ScopeInput): ScopeDecision {
if (input.serverOwned) {
return {
scope: "server-cache",
lift: false,
reason: "服务端是权威,端上只做缓存与失效",
};
}
const readers = new Set(input.readers).size;
if (readers >= 2) {
return {
scope: "shared",
lift: true,
reason: `${readers} 个读取方需要同一份数据`,
};
}
if (input.lifetime !== "view" && input.ownerDepth >= 2) {
return {
scope: "shared",
lift: true,
reason: "跨两层传递且生命周期超过单次视图",
};
}
return {
scope: "local",
lift: false,
reason: "单一读取方且生命周期限于单次视图",
};
}
不可变更新与选择器
setState 接受值或更新函数。传更新函数时,它拿到的是调用瞬间的 state,而不是闭包里捕获的旧值,因此连续两次自增不会互相覆盖。这个细节看起来小,却是并发更新场景里最常见的错误来源。
返回同一引用时直接返回、不通知,是不可变更新的配套约束。它把“状态没变”的判断交给引用相等,代价是更新时必须创建新对象。就地修改数组再 setState 同一个引用,界面不会刷新;这不是容器失效,而是使用方破坏了约定。
select 解决另一个问题:整棵状态树变化时,只关心某一个字段的组件不该重渲染。它保存上一次选中的值,每次状态变化重新计算,只有选中值不等时才调用监听器。默认比较是 Object.is,对数字、字符串和布尔值足够;数组或对象需要传入自定义相等函数。
自定义相等不要写成深度比较。深比较在状态树大、更新频繁时会把选择器的成本放大到与整棵树相当,反而抵消了选择性订阅的收益。更稳妥的做法是让选择器返回原始值,或者让更新逻辑保持结构共享,使未变化的分支引用不变。
订阅集合在通知前做了一次浅拷贝。这样监听器内部退订自己或其他监听器时,不会改变正在遍历的集合,也不会漏发当前这一轮通知。副作用仍然同步执行,顺序就是订阅顺序;需要批处理或异步调度时,应该在容器外面加一层,而不是让容器自己猜。
什么时候该提升
把上面的判据代回「回声」的两个场景:
| 场景 | readers | ownerDepth | lifetime | serverOwned | 结论 |
|---|---|---|---|---|---|
| 发现页筛选 | ["FeedPage"] | 1 | view | false | 本地 |
| 会话草稿 | ["Composer", "SaveButton"] | 1 | session | false | 共享 |
| 当前用户 | ["Header", "Profile", "Feed"] | 2 | persist | false | 共享 |
| 内容流 | ["Feed", "Detail", "Search"] | 3 | session | true | 服务端缓存 |
| 下单金额 | ["Checkout", "PayBar"] | 2 | view | true | 服务端缓存 |
草稿是共享状态,但它仍然需要落本地存储。共享只回答“谁读”,持久化回答“丢了怎么办”,两者不能互相替代。很多团队把草稿放进全局 store 就以为万事大吉,进程被系统回收后一样丢。
提升也有成本。共享 store 通常是模块级单例,测试之间会互相污染;生命周期从一次视图延长到整个会话,内存占用随之上升;原本可以随页面卸载一起释放的订阅,需要显式清理。判据只能告诉你“什么时候值得付这个成本”,不能替你付。
一个常见的反例是把筛选条件提升到全局。用户从发现页切到消息页再回来,筛选条件还在,看起来是优点;但如果用户期望“重新进入发现页就是默认列表”,这个“优点”就变成了残留状态。筛选条件的生命周期应该跟路由走,而不是跟应用走。第 6 章会把这条规则展开成“URL 即状态”。
测试与真实输出
测试覆盖同步通知、退订、不可变更新、引用短路、选择器、自定义相等,以及提升判据的四种分支。运行命令与真实输出如下。
node --test test/state/store.test.ts
本机一次运行的真实输出:
✔ 订阅者在每次引用变化时同步收到新状态 (1.071667ms)
✔ 退订之后不再收到通知 (0.101625ms)
✔ 更新函数必须返回新对象,原状态不被就地修改 (0.092917ms)
✔ 返回同一引用时不通知,避免无意义重渲染 (0.153542ms)
✔ 选择器只在选中值变化时通知 (0.159375ms)
✔ 选择器可以使用自定义相等函数比较数组 (0.127583ms)
✔ 单一读取方的视图状态留在本地 (0.090125ms)
✔ 两个以上读取方时提升为共享状态 (0.125916ms)
✔ 跨层传递且生命周期超过视图时提升 (0.104417ms)
✔ 服务端状态优先于提升规则,交给缓存层 (0.109334ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 148.462458
输出里的毫秒数是单次运行的墙钟耗时,会随机器负载波动,不能用来比较实现优劣。判定标准只有退出码为 0 与 fail 0。
小结
本地状态与共享状态的边界,由读取方数量、生命周期和权威来源共同决定。单一读取方、活不过一次视图的状态留在本地;多个读取方或跨层传递且活得更久的状态才提升;服务端状态无论被多少地方读取,都交给缓存层。
最小订阅容器的价值不在功能多,而在行为可预测:同步通知、引用短路、选择器按值比较、退订幂等。把这些规则用测试固定下来之后,换成任何状态库都只是替换实现,不会重新讨论语义。
下一节处理另一半问题:进入缓存层的服务端状态,如何按资源类型设定过期、如何在旧值上后台刷新,以及失效时怎样阻止过期响应回写。
阅读导航:上一节:4.3 用测试固化分层 · 下一节:5.2 服务端状态的缓存与失效 。
常见错误与边界
- 先选状态库再讨论状态归属。库可以替换,归属错了要重写调用点。
- 把所有状态塞进一个全局 store。服务端数据进入后,过期与失效逻辑会散落到组件。
- 用就地修改配合引用相等。数组 push 后传回同一个引用,订阅者不会收到通知。
- 选择器里做深度比较。状态树一大,选择器成本会接近整棵树,失去局部订阅的意义。
- 忘记退订。组件卸载后监听器仍持有闭包,既泄漏内存,也可能在卸载后触发更新。
- 把“共享”当成“持久化”。共享只决定谁读,落盘与恢复要单独设计。
- 把筛选条件提升到全局。跨页面残留看似方便,实际违背用户对重新进入的预期。
- 用测试之间的模块级单例共享状态。每个用例应创建独立 store,避免顺序依赖。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。