《互联网产品的客户端架构·内容、社交与实时互动》1.2 social-lab 的第一个决定:端上先画什么

内容端的第一处架构决定不是选框架,而是决定哪份状态归谁所有。本节把服务端状态、会话状态与瞬时 UI 状态分开,用一段可运行的 TypeScript 实现带游标、去重与淘汰上限的信息流容器,并复盘一次把游标存在组件里、导致返回页面后重复加载的真实事故。

第一个决定不是框架

搭 social-lab 时,很容易从「用哪个状态库」开始。但在选库之前,有一个更早的问题必须回答:这条数据归谁所有,活多久,谁有权改它。

「回声」的内容端至少有三种状态混在一起:

  • 服务端状态:内容列表、单条详情、评论、计数。它的事实来源在服务端,端上只是副本。
  • 会话状态:当前用户、登录态、连接的会话、已读位置。它跨页面存活,但随进程结束而消失。
  • 瞬时 UI 状态:下拉刷新的动画、输入框的焦点、正在输入的草稿。它的生命周期以毫秒到分钟计。

把三者塞进同一个容器,会出现一个典型症状:刷新页面时,服务端状态被清空,连带着把「用户滑到第几屏」也清了;或者反过来,退出登录时瞬时状态还在,界面上残留上一个用户的表情草稿。

这一节只做第一个决定:信息流容器的状态归属。答案是把「条目数组、游标、已见集合」绑成同一个所有者,并给它一个明确的上限与淘汰规则。代码落在 src/core/social-client.ts,不依赖任何框架,因此可以在 Node 里直接验证。

容器里到底要存什么

一个信息流容器至少需要四样东西:

数据作用缺失时的症状
条目数组渲染与滚动定位页面空白或顺序错乱
下一页游标继续加载每次都从第一页开始,重复请求
已见集合去重分页重叠或实时插入时出现重复卡片
上限策略控制内存长时间滑动后内存持续增长

四样东西有一个共同的约束:它们必须一起更新。如果条目加进去了但已见集合没加,下一次分页就会重复;如果游标更新了但条目没加,那一页的数据就永久丢了。

所以它们应该属于同一个所有者,并且只通过一个方法写入。这就是 appendPage 存在的原因:它接收一整页,返回这次写入的结果,而不是让调用方分别改三个字段。

一个可运行的内存实现

下面是 social-lab 的容器实现。它刻意不依赖浏览器 API,也不依赖定时器,因此属于 L2:有进程内的完整流程,但没有真实端口。

// file: src/core/social-client.ts
// tier: L2
// verify: node --test test/core/social-client.test.ts
import type { ContentItem } from "./content-model.ts";

export type ConnectionState = "idle" | "connecting" | "open" | "closed";

export interface FeedPage {
  items: ContentItem[];
  nextCursor: string | null;
}

export interface AppendResult {
  added: number;
  duplicates: number;
  dropped: number;
}

export class SocialClient {
  #feeds = new Map<string, ContentItem[]>();
  #cursors = new Map<string, string | null>();
  #seen = new Map<string, Set<string>>();
  #connection: ConnectionState = "idle";
  #limit: number;

  constructor(limit = 200) {
    this.#limit = limit;
  }

  get connection(): ConnectionState {
    return this.#connection;
  }

  setConnection(state: ConnectionState): ConnectionState {
    this.#connection = state;
    return state;
  }

  items(feedKey: string): ContentItem[] {
    return [...(this.#feeds.get(feedKey) ?? [])];
  }

  cursor(feedKey: string): string | null {
    return this.#cursors.get(feedKey) ?? null;
  }

  appendPage(feedKey: string, page: FeedPage): AppendResult {
    const current = this.#feeds.get(feedKey) ?? [];
    const seen = this.#seen.get(feedKey) ?? new Set<string>();
    const fresh: ContentItem[] = [];
    let duplicates = 0;
    for (const item of page.items) {
      if (seen.has(item.id)) {
        duplicates += 1;
        continue;
      }
      seen.add(item.id);
      fresh.push(item);
    }
    const merged = [...current, ...fresh];
    const dropped = Math.max(0, merged.length - this.#limit);
    const kept = dropped > 0 ? merged.slice(dropped) : merged;
    if (dropped > 0) {
      // 被淘汰的条目允许在后续分页中重新出现,因此从 seen 中移除。
      for (const item of merged.slice(0, dropped)) seen.delete(item.id);
    }
    this.#feeds.set(feedKey, kept);
    this.#seen.set(feedKey, seen);
    this.#cursors.set(feedKey, page.nextCursor);
    return { added: fresh.length, duplicates, dropped };
  }

  replacePage(feedKey: string, page: FeedPage): number {
    const current = this.#feeds.get(feedKey) ?? [];
    const inserted = page.items.filter((item) => !current.some((old) => old.id === item.id));
    this.#feeds.set(feedKey, [...inserted, ...current].slice(0, this.#limit));
    this.#seen.set(feedKey, new Set([...inserted, ...current].map((i) => i.id)));
    this.#cursors.set(feedKey, page.nextCursor);
    return inserted.length;
  }

  updateInteraction(feedKey: string, id: string, delta: { like?: number; comment?: number }): boolean {
    const list = this.#feeds.get(feedKey);
    if (!list) return false;
    const item = list.find((i) => i.id === id);
    if (!item) return false;
    item.likeCount = Math.max(0, item.likeCount + (delta.like ?? 0));
    item.commentCount = Math.max(0, item.commentCount + (delta.comment ?? 0));
    return true;
  }

  snapshot(feedKey: string): { count: number; cursor: string | null; connection: ConnectionState } {
    return { count: this.items(feedKey).length, cursor: this.cursor(feedKey), connection: this.#connection };
  }
}

这里有几个值得停下来看的决定。

一个容器管理多个信息流

feedKey 是「推荐」「关注」「话题」这样的逻辑流,而不是路由。同一个页面切换 tab 时,两个流各自保留游标与去重集合。如果用一个全局数组,切换 tab 后你会拿到混合了两条流的数据。

淘汰时必须把 id 移出去重集合

这是整段代码里最容易写错的一行。超过上限时,我们从数组头部丢掉旧条目,但如果已见集合还留着它们的 id,用户滚回顶部重新拉取时,这些条目会被当成重复项丢掉,出现「越滑越少」的诡异现象。

所以 seen.delete(item.id) 与数组淘汰必须成对出现。测试里专门有一条用例覆盖它:把上限设成 3,写入 a/b/c,再写入 d 淘汰 a,最后重新写入 a,断言 added 为 1。

写入返回结果,而不是静默成功

appendPage 返回 { added, duplicates, dropped },让调用方能区分「加载到新内容」「全是重复」「触发了淘汰」。UI 可以据此决定是否显示「没有更多了」、是否提示「已清理较早内容」,日志也能记录真实的分页行为。

刷新与加载更多是两个方法

replacePage 负责刷新:新条目插到顶部,游标换成最新的,已有条目保持原位。appendPage 负责加载更多:新条目追加到尾部。两者分开,是因为刷新的语义是「把新的放到前面」,而分页的语义是「把旧的接在后面」,混成一个方法会让插入位置变成布尔参数,最终没人记得该传 true 还是 false。

测试与真实输出

$ node --test test/core/social-client.test.ts
✔ 第一页写入后游标与条目都可见 (0.982542ms)
✔ 重复条目只计一次,不改变顺序 (0.103083ms)
✔ 超过上限时从头部淘汰并允许重新出现 (0.107333ms)
✔ 刷新插入新条目到顶部并保持已读位置 (0.536208ms)
✔ 互动计数按增量更新且不为负 (0.119916ms)
✔ 更新不存在的条目返回 false (0.067833ms)
✔ 不同 feed 的去重集合互不干扰 (0.058417ms)
✔ 连接状态可切换并出现在快照中 (0.084ms)
✔ 空页只更新游标 (0.080708ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 261.665792

九条用例覆盖的是容器最容易退化的行为:重复、淘汰、刷新位置、计数下溢、跨流隔离。它们不测渲染,也不测网络,因为这一层只负责「数据怎么在端上稳定存在」。

一次把游标放在组件里的失败

第一版「回声」把游标存在了信息流组件里,理由是「只有这个组件需要它」。这个决定在单页应用里看起来没问题,直到出现三种路径:

  1. 用户从信息流点进详情,再返回。组件被重建,游标回到初始值,信息流从头加载,用户被弹回顶部。
  2. 用户切到「关注」tab 再切回来。两个 tab 共用一个组件实例,游标被后者覆盖,回到推荐流时从错误的位置继续。
  3. 用户在弱网下连续下拉刷新两次。第一次请求的游标先返回,第二次后返回,旧游标覆盖了新游标,下一页从更早的位置开始,出现一大段重复。

根因是游标的生命周期被绑定到了组件的生命周期,而信息流的生命周期比组件长。修复方式不是加 useRef 或 keep-alive,而是把容器提升到页面之外,让游标与条目、已见集合一起存活。

修复后仍然有一个遗留问题:进程被系统回收后,容器消失,用户回来时从头开始。这个问题的正确解法不是把容器持久化到磁盘,而是接受「列表可以从头加载」,只把「用户看到第几条」这种真正需要恢复的位置写进路由或本地存储。两者的区别在于:容器是缓存,位置是意图。

判断某个字段该不该持久化,有一个简单的问题:丢了它,用户是否需要重新做一次有成本的操作?滑到第 30 条的滚动位置属于低成本,丢了只是再滑一次;输入了一半的评论属于高成本,丢了用户会骂人。前者放内存,后者必须落本地。

同样的判断也适用于游标。游标丢了会导致重复请求,但不会让用户丢失内容,所以它可以随容器一起消失。真正需要跨进程恢复的是「未读位置」和「草稿」,这两者有自己的存储路径,不应该塞进信息流容器。

上限为什么是 200

容器构造时接受一个 limit,默认 200。这个数字不是拍脑袋的性能参数,而是一个产品判断:

  • 一屏大约显示 3 到 6 条内容,200 条相当于 30 到 60 屏。
  • 用户在一次会话里滑到 200 条以下的概率很低,滑更远的用户通常是在找某条特定内容,用搜索比继续滑更快。
  • 每条内容连同图片引用大约占几 KB 到几十 KB,200 条的上限把容器内存控制在几十 MB 以内,不会和图片缓存抢空间。

上限的存在还有一个架构作用:它强迫团队回答「淘汰之后怎么办」。没有上限时,容器看起来永远正确,直到长时间使用后内存告警。有了上限,淘汰、去重集合清理、滚回顶部重新加载这三条路径必须在开发阶段就被测试覆盖。

不同产品的合理上限不同。图文信息流可以更高,视频流必须更低,因为视频条目的内存占用远大于文本。重要的不是 200 这个数字,而是它被写进构造参数、可以被测试、可以按产品调整。

容器不是缓存层

需要强调一个边界:本节的容器只负责「当前会话内的列表状态」,它不是缓存层。它不处理过期、不处理离线、不处理跨进程恢复。这些能力在第七、八章的数据层与实时章节里单独设计。

把这两件事混在一起,常见的结果是容器越写越大:先加一个 ttl,再加一个 persist,最后变成一个半成品缓存,既没有缓存的命中率统计,也没有容器的简单性。

小结

内容端的第一个决定是状态归属:条目数组、游标、已见集合必须属于同一个所有者,通过同一个写入方法更新。淘汰条目时要同步清理去重集合,刷新与加载更多要用不同方法,写入结果要可观测。

这套容器可以直接在 Node 里跑测试,不依赖框架。下一节会补齐 lab 的另一半:如何判断一段验证属于 L1、L2 还是 L3,以及每一级各自能证明什么、不能证明什么。

阅读导航:上一节:1.1 「回声」的内容面:信息流、详情、互动、实时四条线 · 下一节:1.3 lab 的三条验证线:L1 纯逻辑、L2 进程内、L3 本地实时通道 。

常见错误与边界

  • 把游标存在组件里。组件的生命周期短于信息流,返回、切 tab、重建都会丢游标。
  • 淘汰条目却保留 id。用户滚回顶部时会发现内容「越滑越少」。
  • 用一个数组存所有 tab 的数据。切换 tab 后顺序与游标互相覆盖。
  • 把刷新和加载更多写成同一个方法,用布尔参数区分。插入位置会变成隐式约定。
  • 在容器里直接发请求。容器应只负责状态,请求编排交给数据层,否则测试必须 mock 网络。
  • 把容器当成缓存。过期、离线、跨进程恢复都不在容器职责内。
  • 用 #seen 无限增长换取「绝对不重复」。内存会随会话时长线性增长,必须有上限与清理。
  • 认为「测试通过」等于「体验正确」。本节只验证状态行为,滚动位置与渲染稳定性要在浏览器里另测。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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