从一条内容的一生说起
「回声」里的一条内容,从作者按下发布开始,会经过四个完全不同的消费场景:
- 有人在小红点提示下点开信息流,一眼扫过十几条,只停留几百毫秒。
- 有人从分享链接进入详情页,把正文读完,翻评论翻到底。
- 有人点了赞、留了言,或者把内容转进自己的收藏夹。
- 作者本人守着页面,等第一条评论和第一个赞实时跳出来。
这四件事在服务端看是同一份数据的不同读取方式;在客户端看,却是四条独立的消费线,各自的失败模式、时序要求和状态归属都不一样。把它们当成一个页面上的四块 UI,是内容类客户端最常见的第一处架构错误。
先给结论:内容端应该按「面」而不是按「页」来组织。信息流是批量、可丢弃、位置敏感的消费;详情是单条、重正文、可深链的消费;互动是写路径,必须本地先行、服务端裁决;实时是增量推送,必须能重连、能补拉、能降级。四条面共享同一份内容模型,但不共享状态容器,也不共享失败策略。
本章只确定四条面的边界与数据需求,不选框架,也不谈性能数字。文中的耗时、比例除特别标注为本机输出外,都是案例假设值。
四条线不是四个页面
信息流:批量消费,允许丢弃
信息流一次拿一批内容,用户快速滑动。它有三个特征:
- 位置敏感:用户滑到第 30 条时,刷新不能把他弹回顶部。
- 可丢弃:滑出很远的内容可以从内存里淘汰,重新滚回时再拉。
- 必须去重:分页之间会有重叠,实时插入也会和分页结果撞车。
因此信息流需要的不是「最新数据」,而是「稳定的顺序」。服务端返回的排序在两次请求之间可能变化,客户端必须自己维护一个已见集合与插入位置,否则会出现「刷一下多出三条重复内容」。
详情:单条消费,深链可达
详情页的核心约束是可寻址。同一个 id 必须能从分享链接、推送、搜索结果、历史记录四条路径进入,而且进入时页面状态不能依赖「用户是从信息流点进来的」。
详情要的是一整条内容的完整正文、作者信息、互动计数、评论第一页。它不怕慢一点,怕的是白屏和错位:正文加载完成前占位必须稳定,返回时要能恢复到原来的滚动位置。
互动:写路径,本地先行
点赞、收藏、评论都是写操作。写路径的端上默认策略是乐观更新:用户点下去,UI 立刻变,然后发请求;失败时回滚并给出可恢复的提示。
但「乐观」不是对所有操作一视同仁。点赞失败回滚的代价很小,评论失败则不能让用户重新打一遍字。写路径必须按「回滚成本」分层,这一点在 5.3 会展开。
实时:增量推送,必须可降级
实时面只负责增量:新评论、新点赞、在线状态、正在输入。它不能是页面渲染的前提。连接断开时,信息流和详情必须仍然可用,只是实时角标停在最后一次已知状态。
实时连接的生命周期比页面长,比进程短。它会被切后台、被网络切换、被系统回收打断,所以重连、去重、补拉三件事必须在设计时就写进契约,而不是等线上出问题再补。
把面写成数据结构
四条面的差异,第一层体现为「每条面需要哪些数据」。与其在代码里散落判断,不如把需求写成纯函数:输入一条内容,输出四条面各自的数据清单与特殊需求。
// file: src/core/content-model.ts
// tier: L1
// verify: node --test test/core/content-model.test.ts
export type Surface = "feed" | "detail" | "interaction" | "realtime";
export type ContentState = "published" | "review" | "hidden";
export interface ContentBlock {
type: "text" | "image" | "video";
text?: string;
src?: string;
alt?: string;
}
export interface ContentItem {
id: string;
authorId: string;
createdAt: number;
state: ContentState;
blocks: ContentBlock[];
likeCount: number;
commentCount: number;
}
export interface SurfacePlan {
surface: Surface;
needs: string[];
}
const SURFACE_NEEDS: Record<Surface, string[]> = {
feed: ["作者信息", "首段文本", "封面媒体", "计数"],
detail: ["完整正文", "评论列表", "互动态"],
interaction: ["当前用户状态", "计数回写", "失败回滚"],
realtime: ["连接状态", "增量事件", "已读位置"],
};
export function planSurfaces(item: ContentItem): SurfacePlan[] {
const surfaces: Surface[] = ["feed", "detail", "interaction", "realtime"];
const extra: string[] = [];
if (item.blocks.some((b) => b.type === "video")) extra.push("播放器实例");
if (item.state !== "published") extra.push("审核态占位");
return surfaces.map((surface) => ({
surface,
needs: [...SURFACE_NEEDS[surface], ...(surface === "detail" ? extra : [])],
}));
}
export function isRenderable(item: ContentItem): boolean {
return item.state === "published";
}
export function firstText(item: ContentItem): string {
const block = item.blocks.find((b) => b.type === "text" && (b.text ?? "").trim() !== "");
return block?.text?.trim() ?? "";
}
export function summarize(item: ContentItem, limit = 60): string {
const text = firstText(item);
if (text.length <= limit) return text;
return `${text.slice(0, limit)}…`;
}
export function interactionTotal(item: ContentItem): number {
return item.likeCount + item.commentCount;
}
这段代码里有两个刻意的设计。
第一,planSurfaces 不返回「要渲染什么组件」,只返回「需要什么数据」。组件怎么实现是模板层的事,数据需求才是跨端共享的部分。原生客户端、小程序、Web 都可以复用同一份清单,只是渲染方式不同。
第二,特殊需求挂到具体的面上。视频只影响详情面,因为信息流里的视频封面不需要播放器;审核态也只影响详情面的占位,因为信息流里有单独的审核态标记。把特殊需求全局化,会让每一条内容都按最重的情况准备资源。
测试与真实输出
把四个面的需求写成纯函数的好处,是它可以被测试固定下来。下面这段测试跑在本机 Node 26.5.0 上:
$ node --test test/core/content-model.test.ts
✔ 四条内容面各自声明自己的数据需求 (1.062917ms)
✔ 视频只在详情面追加播放器实例 (0.162625ms)
✔ 审核中的内容在详情面追加占位需求 (0.088333ms)
✔ 只有 published 状态可以渲染正文 (0.116375ms)
✔ 首段文本跳过空白块并去除首尾空白 (0.109416ms)
✔ 摘要按字符截断而不是按字节 (0.088208ms)
✔ 互动总数把点赞与评论相加 (0.710209ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 161.501959
这些用例看起来在测很琐碎的东西,实际上每一条都对应一个线上会出现的错误:
- 「摘要按字符截断」防的是把中文按 UTF-16 长度截断后出现半个字符,或者把 emoji 截成乱码。
- 「首段文本跳过空白块」防的是编辑器保存了一个空段落,信息流里出现一条没有任何文字的卡片。
- 「审核中的内容在详情面追加占位需求」防的是审核态内容被当成正常内容渲染,等接口返回
hidden时页面已经闪了一下正文。
测试不是走流程,而是把「面与数据的对应关系」变成可回归的契约。
一次把四件事混在一个页面里的代价
「回声」的第一版详情页,把正文、评论、点赞、实时新评论全放在同一个组件里,用一个 useEffect 拉数据,用另一个 useEffect 建连接。上线两周后出现了三个互相纠缠的问题:
- 用户点开详情,评论接口很慢,正文已经渲染但页面一直在转圈,因为loading 状态是全局的。
- 实时连接先于内容 id 建立,收到的新评论不知道属于哪条内容,被写进了上一条内容的列表。
- 点赞失败回滚时,把整个
content对象替换成了服务端返回值,正在播放的视频被重置,滚动位置也丢了。
根因不是 React 用得不对,而是四条面共享了一个状态容器。共享容器的直接后果是:任何一条面的失败都会污染其他面的状态,任何一条面的更新都要重新计算整个页面。
改版时把页面拆成四个独立的边界:
| 面 | 状态归属 | 失败时 |
|---|---|---|
| 信息流 | 列表容器 | 保留已加载条目,显示重试条 |
| 详情 | 内容 id 对应的单条记录 | 显示骨架屏或缓存快照 |
| 互动 | 用户操作队列 | 回滚单条计数,不触碰正文 |
| 实时 | 连接会话 | 断线提示,停止角标更新 |
拆开之后,评论慢了不会卡住正文,实时消息带着内容 id 进来不会串台,点赞回滚只改计数不动内容对象。这不是性能优化,是正确性修复。
拆分的代价是多了三个边界,组件树看起来更碎。衡量是否值得的标准不是文件数量,而是「单个失败会不会扩散」:如果评论接口超时能让正文白屏,说明边界还没画对;如果实时断线只影响角标,说明边界成立。
另一个容易被忽略的收益是可替换性。四条面拆开之后,实时面可以从 WebSocket 换成 SSE 再换成轮询,只要事件形状不变,其余三条面完全不用改。共享容器的实现做不到这一点,因为换通道意味着改容器,而容器里同时装着渲染逻辑。
面与面的边界:什么不该跨
四条面之间只允许三种交互:
- 共享内容模型:同一条内容在四个面里的字段定义一致,避免「详情里的作者叫 author,信息流里叫 user」。
- 单向事件:互动成功后发一个「计数变化」事件,信息流与详情订阅它,但互动不直接改它们的内部状态。
- 显式的路由或深链:从信息流进详情只传 id,不传整个对象。传对象看起来省一次请求,实际上让详情依赖了信息流的缓存,深链进入时就不成立。
不允许的交互也很明确:
- 详情不直接操作信息流的列表数组。
- 实时连接不直接渲染 UI,它只把事件交给对应的面。
- 互动不发「刷新整个页面」的指令,只发「这条内容的计数变了」。
把边界写成这几条,比画一张漂亮的架构图有用,因为它可以在代码评审时被逐条检查。
四条面的优先级与降级顺序
四条面不是同等重要,故障时也不能一起降级。产品上通常有一个明确的顺序:
- 信息流与详情先保可用。用户打开应用的第一目标是看内容,列表与正文读不到,其他能力都没有意义。
- 互动保写入不丢。点赞失败可以回滚,评论失败必须保住草稿,让用户有机会重发。
- 实时最后降级。实时断开的直接后果是「新评论不跳了」,用户刷新一次就能补上,属于可接受损失。
这个顺序决定了代码里的失败处理:信息流与详情的错误必须展示可操作的提示;互动的错误必须进入重试队列;实时的错误只更新连接状态,不弹出阻断式弹窗。
反过来,如果按「技术上最难的先做」排序,团队往往先做实时,再补列表,最后发现列表的缓存与去重没做,实时推送的新内容插不进正确的滚动位置。优先级应该由用户价值决定,不由实现难度决定。
降级还需要一个明确的触发条件。常见的做法是给每条面设一个可观测信号:列表看首屏耗时与分页成功率,详情看正文渲染时间,互动看写请求失败率,实时看连接可用时长。信号越界时自动切到降级形态,而不是等用户投诉。
小结
内容端按面组织,而不是按页组织。信息流要稳定顺序与去重,详情要可寻址与稳定占位,互动要本地先行与可回滚,实时要能重连与降级。四条面共享内容模型,但不共享状态容器。
把每条面的数据需求写成纯函数,是让这套划分可测试的第一步。下一节会在这个模型上搭出 social-lab 的第一个可用形态:一个内存版客户端,能分页、能去重、能改计数,并在进程内跑通一次完整的读取与互动。
阅读导航:上一节:目录与学习路径 · 下一节:1.2 social-lab 的第一个决定:端上先画什么 。
常见错误与边界
- 把「信息流」「详情」当成路由而不是消费面。同一个路由下也可能同时存在列表与详情,反之亦然。
- 四条面共用一个全局 store。共享容器会让失败与更新互相污染,代价在线上才显现。
- 从列表进详情时传整个对象。深链、推送、搜索进入时没有这个对象,页面立刻分叉出两套逻辑。
- 实时连接直接改 UI 状态。连接的生命周期与页面的生命周期不同,直接改状态会在页面切换后写错对象。
- 把审核态只当成一个字段。审核态改变的是可渲染性、占位方式与可交互范围,不只是显示一个标签。
- 摘要按字节截断。中文与 emoji 都可能被截成非法字符,必须按字符或字素簇处理。
- 认为四条面必须同时可用。信息流与详情是基本盘,互动与实时都应该可以单独降级。
- 把四条面的划分做成四个独立仓库或四个团队。划分的目的是隔离失败与状态,不是增加协作成本。
- 把本节的「四条面」当成行业标准。不同产品的面划分不同,判据是「失败模式与状态归属是否一致」,不是数量。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。