《互联网产品的客户端架构·内容、社交与实时互动》序

卷一讲清楚端上约束之后,真正难的部分才开始:内容要滚动、互动要即时、连接会断。这篇序说明为什么内容、社交与实时互动值得单独成卷,四条消费面共享哪四条核心判断,本卷十三章如何排布、坚持哪些立场、写给哪些读者,以及明确不承诺什么、与卷一如何衔接。

从卷一结束的地方开始

卷一《端形态与体验基础》收尾时,我们把「回声」的客户端拆成了可验证的骨架:状态归谁、依赖朝哪走、失败在哪兜、构建怎么产、渲染走哪条路径。那是一张相对静止的图,讲的是「一个页面应该怎么组织」。

真实的用户不会停在一个页面上。他会向下滚,会点进详情,会点赞,会在评论区跟人吵起来,会等着对方回复,会在网络切换的瞬间把手机塞回口袋。这些动作把静态的页面图变成一条持续流动的线,而流动带来了卷一没有正面处理的问题。

内容会重复。同一篇文章在分页边界出现两次,用户看到两张一模一样的卡片。

互动会失败。点赞的请求超时了,但用户已经看到红心变亮,接下来该不该让它跳回去。

连接会断。用户正在等一条回复,地铁进隧道,二十秒后出来,那条消息还在不在,顺序对不对,会不会重复。

这些问题没有一个能靠「把页面拆得更细」解决。它们属于另一层:数据在端上如何持续存在,事件如何到达,状态如何收敛。这就是卷二要处理的内容。

内容端为什么会成为第二个专题

客户端产品有很多种。工具类应用的核心是输入与计算,交易类应用的核心是流程与一致性,内容类应用的核心是「持续消费」。三者的架构重心不同,内容类是最适合作为第二个专题的,原因有三点。

第一,内容类应用的数据形态最复杂。一条内容不是一个字段,而是作者、正文、媒体、互动计数、审核状态、可见性规则的组合。它在信息流里只需要一小部分,在详情页需要全部,在实时通道里只传增量。同一份数据在不同面里有不同的投影,这迫使团队认真回答「哪份数据归谁」。

第二,内容类应用的失败最频繁。列表滚动会触发大量请求,图片加载会失败,用户会在弱网下操作,实时连接会被系统反复打断。失败不是异常路径,而是常态路径。架构必须把失败当成一等公民,而不是在最后加一个 try/catch。

第三,内容类应用的体验最依赖时序。用户滑到第 30 条时刷新,新内容应该插在哪里;点赞之后立刻刷新,服务端返回的计数是旧的还是新的;实时消息与分页结果同时到达,谁覆盖谁。这些问题的答案不在接口文档里,而在端上的状态机里。

社交与实时是内容消费的自然延伸。有了内容就会有互动,有了互动就会有实时通知,有了实时通知就会有连接管理。把它们放在同一卷,是因为它们共享同一套判断:什么可以乐观、什么必须确认、断了以后怎么补、补的时候怎么去重。

一次重复卡片的复盘

「回声」上线初期,推荐流每隔十几屏就会出现一次重复卡片。用户反馈很直接:你们是不是把同一篇文章推了两次。

第一轮排查怀疑是服务端排序不稳定。服务端同学查了日志,同一时间窗口内的排序是确定的,没有重复下发。第二轮怀疑是客户端分页参数错了,检查后发现游标传得也对。

真正的原因在第三轮才找到:客户端用「最后一条内容的发布时间」作为游标,而同一毫秒内可能有多条内容。服务端按 (created_at, id) 排序,客户端只传了 created_at,于是下一页从「这个时间点之后」开始,把同一毫秒里排在后面的几条又返回了一次。

这不是一个复杂 bug,但它暴露了三件事:

  1. 游标必须是能唯一确定位置的复合值,不能只取排序字段的一部分。
  2. 客户端必须有去重能力,不能假设服务端永远不重复。
  3. 重复检测要能在本地复现,不能等用户截图。

修复分两层:服务端把游标改成 (created_at, id) 的编码,客户端在容器里保留已见集合。两层都做,不是因为不信任对方,而是因为重复的代价由用户承担,任何一层都不应该假设另一层永远正确。

这件事后来成了本卷的写作原则:每一个结论都要能落到一段可运行的代码和一条能复现的用例上。只讲「应该去重」没有意义,要讲清楚去重集合跟谁一起更新、淘汰时怎么清理、并发时怎么保证顺序。

内容、社交、实时共享同一套判断

本卷 13 章看起来主题分散:信息流、富文本、上传、点赞、连接、重连、在线状态、关系、通知、审核、搜索。但它们反复使用同一组判断。

判断一:事实源在谁那里。 内容列表的事实源是服务端,端上只是副本;草稿的事实源是端上,服务端只负责接收;连接状态的事实源是端上,服务端只知道「这个连接曾经存在」。搞错事实源,就会出现「用服务端的旧计数覆盖用户的乐观更新」这类问题。

判断二:失败能不能回滚。 点赞失败可以回滚,因为用户的操作成本低;评论失败不能简单回滚,因为用户已经付出了打字成本;上传失败更不能回滚,必须能续传。回滚成本决定了写路径的默认策略。

判断三:断开之后怎么补。 实时连接断开是常态。补拉需要知道「断点在哪里」,断点需要服务端提供可比较的序列号或时间戳,端上需要检测空洞。没有断点信息的实时系统,只能靠全量刷新兜底。

判断四:降级之后还剩什么。 每一条能力都要回答「不可用时产品还能做什么」。实时不可用时,信息流还能刷;图片不可用时,文字还能读;推送不可用时,角标还能在打开应用时更新。没有降级路径的能力,就是单点故障。

这四条判断会贯穿整卷。读者如果只记住一件事,记住这四条就够了。

十三章的排布

本卷十三章可以分成四段。

第一段是内容面的建立(第 1 章)。 先把信息流、详情、互动、实时四条面分开,给出 social-lab 的容器与三级验证线。这一段不涉及具体功能,只确定后面所有章节共用的坐标系。

第二段是内容消费(第 2 到 4 章)。 信息流解决「怎么稳定地向下滚」,渲染解决「一条内容怎么安全地显示」,上传解决「内容怎么从端上送到服务端」。三章覆盖一条内容从产生到被消费的主要路径。

第三段是互动与连接(第 5 到 8 章)。 互动解决「用户的操作怎么立刻反馈又不出错」,实时通道选型解决「用哪种方式把变化送过来」,可靠性解决「断了以后怎么补」,在线状态解决「用户之间的在场感怎么近似」。这一段是全卷技术密度最高的部分。

第四段是社交语义与产品边界(第 9 到 12 章)。 关系、通知、审核、搜索,这四章看起来更偏产品,但每一章最终都落到端上的状态与降级:关系缓存什么时候失效,未读数以哪一端为准,审核态怎么不闪正文,搜索联想怎么处理乱序返回。

第 13 章收口,把前面所有模块拼成一条端到端链路,并做一次故障注入:断网、慢网、断线重连。收口章不引入新概念,只验证前面的判断在组合之后是否仍然成立。

这个排布有一个刻意的顺序:先建立「面」,再建立「流」,最后建立「连接」。反过来先讲 WebSocket 再讲信息流,读者会误以为实时是架构的中心;实际上实时只是增量,内容消费才是基本盘。

一次实时重连的复盘

重复卡片是读路径的问题,实时重连则是写路径与连接层同时出问题。这里提前讲一次,因为它把本卷后半部分的判断都串了起来。

「回声」的私信页第一版在组件挂载时建立 WebSocket,卸载时关闭。逻辑看起来对称,实际有三个漏洞:

  • 组件因为路由变化被卸载又立刻挂载时,旧连接还没关完,新连接已经建立,服务端同时看到两条连接。
  • 切到后台超过一段时间,系统切断了 socket,但客户端没有收到关闭事件,UI 仍然显示「在线」。
  • 重连成功后,客户端只拉取「最新一条消息」,中间的十几条消息永久丢失。

三个漏洞对应三章的内容:连接生命周期(第 6 章)、重连与退避(第 7 章)、补拉与空洞检测(第 7 章)。修复方案不是加一个定时器重连,而是把连接当成一个有状态的会话:

  1. 连接有明确的 idle / connecting / open / closed 状态,UI 只读这个状态,不直接读 socket。
  2. 每次重连携带上次收到的最大序列号,服务端从该序列号之后补发。
  3. 客户端按序列号检测空洞,发现缺口时主动补拉,而不是等实时推送。
  4. 消息按「会话 id + 序列号」去重,重连导致的重复推送被丢弃。

这套方案后来也用在通知与在线状态上。它说明一件事:实时不是「连上就行」,而是一套带断点、带去重、带降级的协议。协议设计在服务端文档里,但验证必须在端上完成。

与卷一、卷三的衔接

卷一提供的是静态结构:分层、状态归属、错误边界、构建与渲染路径。卷二把这些结构放进流动的数据里,检验它们在持续变化时是否仍然成立。

举一个具体的衔接点。卷一讲了错误边界,要求「一个组件的失败不扩散到整页」。卷二第 5 章会追问:如果失败的是写操作,错误边界应该回滚 UI 还是保留 UI 并重试?答案取决于回滚成本,而回滚成本是卷一没有定义的概念。两卷合起来,才是完整的失败处理。

卷三会进入交易、增长与平台化。交易对一致性的要求远高于内容,增长对埋点与实验的要求远高于互动,平台化对多端一致性的要求远高于单端体验。卷二建立的「事实源、回滚成本、断点补拉、降级顺序」四个判断,会在卷三被重新使用,只是约束更严。

因此本卷不追求把每个主题讲到穷尽。信息流只讲到「稳定顺序」,不展开推荐算法;实时只讲到「可靠增量」,不展开协议性能调优。留白部分会在卷三或后续专题里出现。

关于案例中的数字

书中会出现一些数字:容器上限 200 条、重连退避从 1 秒开始、未读数合并窗口 500 毫秒。它们都是案例假设值,用于让讨论具体,不是行业标准,也不是经过大规模验证的最优参数。

判断一个数字是否适合你的产品,需要三个输入:你的内容体积、你的网络分布、你的用户耐心。同样是「首屏加载预算」,图文产品与视频产品可能差一个数量级。书里给出数字的地方,都会同时给出推导过程,读者可以替换输入重新计算。

唯一可以当作事实使用的是测试输出里的耗时。它们来自本机 Node 26.5.0 的真实运行,但只反映逻辑层的执行时间,不包含网络、渲染与设备差异。

三条阅读路径

不同的读者可以用不同方式进入。

完整路径:从第 1 章顺序读到第 13 章。适合正在设计一个内容或社区产品、需要完整判断链的读者。

问题路径:如果你正被某个具体问题困住,可以直接跳到对应章节。信息流重复与位置问题看第 2 章,富文本与 XSS 看第 3 章,上传失败看第 4 章,点赞回滚看第 5 章,连接断开看第 6、7 章,未读数不一致看第 10 章。每章开头都会用一段话复述所需前提。

评审路径:如果你要为团队做架构评审,读第 1、5、7、13 章,再看附录 C 的清单。这四章覆盖了状态归属、写路径、连接可靠性与组合验证,是评审时最容易漏掉的部分。

三条路径共享同一套代码。无论从哪一章进入,实验工程都能单独跑对应模块的测试,不需要先完成前面的章节。

本卷的四个立场

立场一:先定边界,再谈实现。 信息流、详情、互动、实时是四条消费面,不是四个页面。面的边界由失败模式与状态归属决定,不由路由决定。

立场二:能本地验证的,不要留给真机。 本卷所有实验都能在 Node 里跑:纯函数用 L1,进程内流程用 L2,真实本地端口用 L3。真机性能与平台差异不在承诺范围内,但逻辑与时序必须可复现。

立场三:乐观更新是默认,但要按回滚成本分层。 不是所有操作都适合乐观。点赞适合,发评论要保草稿,上传要能续传,支付类操作根本不应该乐观。

立场四:实时是增量,不是前提。 实时通道只负责把变化送过来,页面不能依赖它才能渲染。连接断开时,产品应该退回「手动刷新」的可用状态,而不是白屏。

这套书怎么写

每一节从一个具体问题开始,给出一个可运行的 TypeScript 片段,附上本机真实输出,再复盘一次相关的失败。代码不依赖第三方库,用 Node 26 的原生类型剥离直接运行,读者可以在自己的机器上复现。

实验工程叫 social-client-lab,与卷一的 echo-client-lab 平行。每个模块只做一件事:游标、窗口、刷新策略、块模型、sanitize、占位、上传编排、续传、格式策略、连接、重连、补拉、presence、关系缓存、未读合并、审核态、搜索联想。

章节之间的代码会互相引用,但每一节都尽量做到「单独拿出来也能跑」。这不是为了炫技,而是为了让读者在只关心某一节时,不必先搭完整个工程。

原生客户端与小程序不在本卷的实验范围内。书中会讲它们的架构建模:同样的面划分、同样的状态归属、同样的降级顺序,但不做真机验证,也不承诺平台特有的性能数字。

写给谁

本卷写给三类读者。

第一类是做 Web 前端、正在从「写页面」转向「设计端上架构」的工程师。你可能已经用过某个框架,但说不清为什么信息流要自己做去重、为什么实时连接不能直接改组件状态。本卷会把这些问题拆开。

第二类是做原生或跨端开发、需要理解 Web 端架构取舍的工程师。平台不同,但状态归属、失败分层、增量同步的判断是共通的。

第三类是做服务端、需要和客户端同学一起设计接口与实时协议的后端工程师。读完之后,你会知道客户端为什么坚持要复合游标、为什么要求消息带序列号、为什么对「至少一次」如此敏感。

读本卷需要什么基础

需要能读懂 TypeScript 的类型与类,知道 Promise 与事件循环的基本行为,最好写过至少一个带列表与详情页的应用。不需要 React、Vue 或任何特定框架的经验,书中刻意不依赖框架 API。

如果读过卷一,会更容易进入状态:卷一讲的分层、状态归属、错误边界,在本卷会被反复引用。没读过也不影响,本卷在需要时会用一句话复述前提。

本卷不承诺什么

不承诺真机性能数字。书中的耗时都是本机 Node 环境下的测试运行时间,不代表任何手机上的表现。

不承诺框架最佳实践。书中讨论的是架构判断,具体用哪个库、哪个版本,读者应按自己的团队约束选择。

不承诺平台细节。iOS 的后台策略、Android 的进程保活、小程序的包体限制,本卷只讲它们对架构形状的影响,不讲具体配置。

不承诺「照着做就不会出事故」。架构判断降低的是系统性风险,不能替代监控、灰度与应急。

也不承诺所有结论都适用于你的团队规模。三人团队与三百人团队面对的问题不同:前者需要更少的抽象,后者需要更明确的边界。书中会说明结论成立的前提,读者需要自己判断前提是否满足。

更具体地说,书中不会给出「直接可用」的生产代码。所有片段都为了说明判断而裁剪过,缺少鉴权、限流、日志脱敏等生产必需项。把片段直接复制进项目,是另一种把书读坏的方式。

也不承诺「实时一定比轮询好」。选型取决于产品对延迟、成本、复杂度的权衡,第 6 章会给出判定表,而不是一句结论。

关于术语与译名

本卷尽量使用中文术语,但有几个词保留英文,因为中文译名反而会造成误解。

  • feed:书中译为「信息流」,但代码里的 feedKey 保留英文。它指一条逻辑流,不是页面。
  • cursor:译为「游标」,指「从哪个位置继续」的不透明标记,不是数据库游标。
  • presence:保留英文。中文「在线状态」容易被理解成布尔值,而 presence 本质是一个带时间衰减的近似。
  • optimistic update:译为「乐观更新」,指先改本地再等服务端确认。
  • sanitize:保留英文。中文「净化」在安全语境里有歧义,sanitize 特指按白名单清洗不可信内容。

术语第一次出现时会给出定义,之后直接使用。如果读者发现某个词在不同章节含义不同,那多半是笔误,欢迎指出。

实验工程的目录

social-client-lab 的目录按章组织,与正文一一对应:

src/
  core/       第 1 章:内容模型、容器、分级判定
  feed/       第 2 章:游标、窗口、刷新策略
  render/     第 3 章:块模型、sanitize、占位
  upload/     第 4 章:上传编排、续传、格式策略
  ...         后续章节按同样规则扩展
test/
  core/  feed/  render/  upload/  ...

每个目录只依赖 core 与 Node 标准库,不依赖其他章节的模块。这样任何一章都可以单独跑测试,读者不必按顺序搭完整个工程。

测试命令统一用 node --test,不加任何第三方测试框架。L2 与 L3 的用例必须加 --test-concurrency=1,原因在第 1.3 节详细说明。

关于代码与实验工程

所有标了 // file: 的代码块都与 .local/checks/social-client-lab 下的文件逐字节一致。测试输出是真实运行结果,包含真实耗时;耗时只用于说明量级,不应作为性能基准。

代码使用 Node v26.5.0 的原生 TypeScript 类型剥离,导入路径带 .ts 后缀,不引入任何第三方依赖。测试用 node:test 与 node:assert/strict。

实验分三级:L1 纯逻辑,L2 进程内流程,L3 本地真实端口。正文会标注每段代码属于哪一级,以及它不能证明什么。

关于时间、版本与案例

书中的产品叫「回声」,是一个同时有内容流、私信与站内下单的产品。案例中的用户量、比例、耗时,除标注为本机输出外,都是用于演示判断的假设值。

写作时间基准是 2026 年 10 月。所引用的平台行为、协议能力以当时的公开文档为准;读者阅读时如果发现平台已经变化,应以新文档为准,并回到架构判断本身:判断依赖的是约束,不是某个版本的实现细节。

最后

卷一结束时我说过,客户端架构的第一课是承认端上约束不是实现细节。卷二想补上后半句:承认约束之后,还要承认端上的数据与事件是持续流动的,架构必须为流动设计,而不是为某一个页面设计。

如果你正在做一个内容、社区或即时通信产品,希望这一卷能让你在写下第一个 useEffect 之前,先想清楚这条数据归谁、失败之后退到哪里、断开之后怎么补。

序言到这里。前言会说明本卷的结构、读法与实验工程的用法。

最后补充一句写作上的自我约束:本卷不追求覆盖所有框架与平台,也不试图给出「标准答案」。每一章给出的是一套判断方法,读者需要把自己的产品事实代进去,才能得到自己的答案。如果某一节的结论在你的场景里不成立,那很可能是输入不同,而不是结论错了——先检查输入,再检查结论。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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