《互联网产品的客户端架构·端形态与体验基础》5.1 本地状态与共享状态

从「回声」客户端的筛选面板与草稿栏出发,把状态分成组件本地、跨页共享与服务端缓存三层,给出一个不依赖框架的最小订阅容器、选择器与不可变更新规则,用可运行的 TypeScript 测试验证订阅、退订与提升判据,并说明什么时候不该把状态提升到全局。

状态放错位置,后面都在补救

「回声」的发现页有一个筛选面板:内容类型、发布时间、是否关注。面板收起后,筛选条件仍要参与列表请求;用户从发现页进入详情页再返回,筛选条件最好还在。另一个位置是会话页底部的草稿栏,用户输入的半句话既要在输入框里,也要让顶部的“保存草稿”按钮知道当前有没有内容。

这两个需求看起来都像“需要全局状态”,实际归属并不相同。筛选条件只有列表页自己读取,详情页不关心;草稿栏的文本同时被输入框和保存按钮读取,而且切换会话后还要保留。前者留在页面本地就够了,后者才需要提升。

状态管理的第一问题不是“用哪个库”,而是“这份状态有几个读取方、活多久、丢了会怎样”。本节先把这三个问题收敛成一条可执行的判据,再写一个最小订阅容器,让判据和容器都能在 Node 里被测试。

三种归属与一条判据

端上的状态先按读取范围分三层,服务端状态单独一类:

归属典型状态读取方生命周期丢失代价
组件本地输入框焦点、面板展开单一组件一次渲染可接受
页面本地筛选条件、滚动位置单一页面一次导航可接受,可重置
共享 store当前用户、主题、草稿标记多个组件跨页面需要恢复
服务端缓存内容流、会话、订单多个页面由过期策略控制可重新拉取

判据按固定顺序执行,越靠前越优先:

  1. 数据是否以服务端为权威?是则交给缓存层,不进入组件间共享。
  2. 去重后的读取方是否达到两个?是则提升为共享状态。
  3. 生命周期是否超过单次视图,且持有者到使用者的层级距离达到两层?是则提升。
  4. 其余情况留在本地。

这个顺序很重要。服务端状态即使被十个页面读取,也不应该塞进共享 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,对数字、字符串和布尔值足够;数组或对象需要传入自定义相等函数。

自定义相等不要写成深度比较。深比较在状态树大、更新频繁时会把选择器的成本放大到与整棵树相当,反而抵消了选择性订阅的收益。更稳妥的做法是让选择器返回原始值,或者让更新逻辑保持结构共享,使未变化的分支引用不变。

订阅集合在通知前做了一次浅拷贝。这样监听器内部退订自己或其他监听器时,不会改变正在遍历的集合,也不会漏发当前这一轮通知。副作用仍然同步执行,顺序就是订阅顺序;需要批处理或异步调度时,应该在容器外面加一层,而不是让容器自己猜。

什么时候该提升

把上面的判据代回「回声」的两个场景:

场景readersownerDepthlifetimeserverOwned结论
发现页筛选["FeedPage"]1viewfalse本地
会话草稿["Composer", "SaveButton"]1sessionfalse共享
当前用户["Header", "Profile", "Feed"]2persistfalse共享
内容流["Feed", "Detail", "Search"]3sessiontrue服务端缓存
下单金额["Checkout", "PayBar"]2viewtrue服务端缓存

草稿是共享状态,但它仍然需要落本地存储。共享只回答“谁读”,持久化回答“丢了怎么办”,两者不能互相替代。很多团队把草稿放进全局 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,避免顺序依赖。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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