从服务端卷结束的地方开始
上一套《互联网产品的服务端架构》写到最后一章时,我以为关于「回声」的话已经说得差不多了。服务怎么分、流量怎么扛、状态归谁、失败时谁兜底,这些问题都有了对得起读者的答案。把稿子合上之后,我却一直想起另一组问题。
用户在地铁里打开发现页,图片加载到一半列车进隧道,这时页面应该显示什么。用户写了一半的私信,接了个电话回来,应用还在,草稿没了,这算谁的责任。用户手机里装着半年前的旧版本,服务端已经改了三轮接口,旧版本打开时看到的是白屏还是一句能看懂的提示。
这些问题在服务端卷里几乎不会出现。服务端只关心请求有没有到、处理得对不对、返回得够不够快。它不关心请求是在 Wi-Fi 下发出的还是在隧道里发出的,也不关心客户端进程有没有被系统杀掉。不是服务端傲慢,而是它的运行环境允许它不关心。
客户端没有这个福气。
两套书的分工
服务端卷回答的是:一个互联网产品在服务器这一侧,架构应该怎么长。客户端卷回答的是:同一款产品,在用户手里那台设备上,架构应该怎么长。
两个问题看起来是同一个系统的两面,实际使用的默认假设几乎相反。
| 维度 | 服务端卷的默认假设 | 客户端卷的默认假设 |
|---|---|---|
| 运行环境 | 同构、可控、可复制 | 碎片化、不可控、差异巨大 |
| 网络 | 机房内稳定,失败可重试 | 公网不确定,可能完全离线 |
| 生命周期 | 进程长期驻留 | 随时被切后台、被回收、被划掉 |
| 发布 | 可回滚,所有实例同时生效 | 新旧版本长期共存,无法强制回滚 |
| 故障可见性 | 日志与指标集中采集 | 依赖端上上报,可能丢失或延迟 |
| 容量 | 可水平扩展 | 受设备内存、算力与电量限制 |
这张表不是要证明客户端更难。它想说明的是:把服务端的默认值直接搬到端上,是很多端上事故的共同起点。
服务端默认网络可靠,所以失败就是失败;客户端必须处理“请求发出去了,但不知道成功还是失败”。服务端默认进程长活,所以状态可以放在内存;客户端必须把不能丢的状态落到本地,并在恢复时校验。服务端默认版本一致,所以字段可以删;客户端必须假设旧版本还会活很久。
两套书的分工,就是把这些相反的默认值分别讲清楚。服务端卷讲“服务怎么摆”,客户端卷讲“端怎么长”。两边都读完,一个产品才算从服务器到用户手里被完整看了一遍。
一次白屏事故的复盘
假设有一个内容产品,服务端接口一直很稳定,端上却在一个版本之后收到大量白屏反馈。这是一个用于说明问题的案例,不对应任何真实产品。
复盘时发现三件事叠在一起。
第一,服务端给列表接口新增了一个必填字段,旧客户端不认识它,反序列化时抛了异常。服务端认为新字段是向后兼容的,因为旧字段一个没删;客户端认为服务端返回了脏数据,因为它只认自己见过的形状。
第二,端上的本地缓存 key 在新版本里换了格式,旧缓存读不出来,新缓存还没写入。用户在弱网下打开页面,既没有网络数据,也没有可用缓存,只能看到空白。
第三,页面没有错误边界。反序列化异常一路冒到顶层,整个页面被卸载,用户看到的是纯白,而不是“加载失败,请重试”。
三个问题单独看都不致命,叠在一起就成了一次大面积事故。服务端觉得自己没做错,客户端觉得自己按契约解析,测试环境也一直正常,因为测试环境里的客户端永远是最新版本,网络永远是稳定的,缓存永远是干净的。
这个案例想说明的不是“要加错误边界”这么简单。它说明的是:服务端的正确性,不能直接推出客户端的正确性。 客户端有自己的失败模式,而这些失败模式往往在服务端视角里看不见。
端上判断为什么值得单独成书
有人会问:客户端不就是服务端接口的消费者吗,把接口调对、页面画出来,还需要单独一套架构书吗。
这个疑问可以理解,但它把客户端看成了服务端的薄壳。真实的客户端不是薄壳,它是产品与用户之间唯一的一层。服务端再正确,端上白屏,用户看到的就是产品坏了;服务端再快,端上首屏三秒,用户感受到的就是慢。体验不是在服务器机房里兑现的,是在用户手里那台设备上兑现的。
端上判断值得单独成书,至少有三个理由。
端上不是服务端的缩小版
服务端架构的很多结论,到了端上会失效。服务端可以假设所有实例运行同一份代码,客户端不能。服务端可以用重试解决大部分瞬时失败,客户端要区分“重试有用”和“重试会让事情更糟”。服务端可以把状态放在集中式存储里,客户端的状态分散在组件、路由、本地存储、内存缓存和服务端五六个位置。
这些差异不是“实现细节多一点”,而是会改变架构形状的输入。把它们当成细节,就会在项目后期付出代价:状态越写越乱,缓存越加越多,旧版本兼容补丁一层叠一层,最后没人说得清一份数据到底以谁为准。
框架会换,判断不会
客户端生态的更新速度比服务端快得多。今天流行的框架、状态库、构建器,两三年后可能已经换了一轮。如果一本书只教“这个框架怎么用”,它的半衰期会非常短。
但有些判断不会随着框架消失:这份状态丢了会怎样,应该放在哪一层;这个页面栈在冷启动、深链进入、进程回收后分别应该恢复到什么状态;离线优先意味着哪些默认做法必须被禁止;首屏、可交互与包体冲突时,优先级由什么决定。
这些判断是本书真正想写的东西。框架只是判断的实现手段。判断对了,框架可以换;判断错了,换什么框架都救不回来。
端上问题需要跨角色共同回答
客户端架构不只是客户端工程师的事。产品经理决定要不要做离线可用,设计师决定首屏先给用户看什么,服务端工程师决定接口返回什么形状,技术负责人决定兼容几个版本。这些决定都会改变端上的架构。
如果产品与设计不理解端上约束,他们会提出在真机上无法兑现的体验方案;如果服务端工程师不理解客户端的版本分裂,他们会随手删掉一个“看起来没人用”的字段;如果技术负责人只把客户端当成“画页面的”,端上就不会有人为架构负责。
所以这套书尽量不写成纯代码手册。它会把每个判断的场景、选项、代价和边界摆出来,让不同角色都能参与讨论,而不是把结论留给某一个岗位。
四个约束与四组默认值
卷一第 1 章把客户端与服务器最大的差别收敛成四个约束。这里先各说一段,因为它们决定了后面所有章节的语气。
设备能力碎片化
同一个页面,可能跑在六年前的千元机、刚发布的旗舰机、桌面浏览器和一台内存很小的电视盒子上。CPU 核心数、内存上限、GPU 能力、字体、输入法、刷新率都不同。
服务端可以假设“所有机器大致相同”,客户端不能。碎片化带来的不是“多测几台手机”,而是同一份代码在不同设备上的性能曲线可能差一个数量级。架构上必须留出降级路径:低端设备少加载、少动画、少并发,而不是让所有设备跑同一条最重的路径。
网络不确定
服务端之间的调用通常在一个机房或同城可用区,延迟稳定。客户端的网络横跨移动网络、Wi-Fi、弱 Wi-Fi 和完全离线,同一个请求的耗时可能从几十毫秒到几十秒,失败也可能是“请求发出去了但响应没回来”。
这直接改变数据层:请求需要超时预算、重试与去重,读路径需要缓存兜底,写路径需要离线队列与冲突解决。服务端可以假设“失败就是失败”,客户端必须处理“不知道成功还是失败”的第三种状态。
生命周期被系统打断
移动应用随时可能被切到后台、被系统回收、被用户划掉。桌面应用会被休眠,浏览器标签页会被冻结。一个正在编辑的表单、一个正在上传的文件、一条正在发送的消息,都可能在任意时刻被中断。
服务端进程可以长期驻留内存,客户端的内存是不可靠的。架构上必须把状态分成两类:可以丢的,比如当前滚动位置;不能丢的,比如未发送的消息、未提交的草稿。不能丢的状态要落到本地存储,并在恢复时校验完整性。
分发与版本不可回滚
服务端发布新版本,出问题可以回滚,所有用户同时回到旧版本。客户端不行:用户手机上的旧版本可能半年不更新,应用商店审核有延迟,Web 端还有浏览器缓存和 CDN 的旧资源。发布不是“切换”,而是“新旧版本长期共存”。
这带来兼容窗口的问题:新服务端要能服务旧客户端,新客户端要能处理旧服务端返回的数据格式。任何一个字段的删除、一个接口语义的变化,都要按“至少兼容两个版本”来设计。热更新能缓解一部分问题,但它有平台限制与合规风险,不能当成默认能力。
四个约束合起来,就是一句话:客户端架构必须在不可控的环境里,给用户一个可控的体验。
端形态为什么放在最前面
卷一没有从状态管理或组件体系讲起,而是先讲端形态。原因很简单:Web、原生、小程序与桌面不是“同一个应用换层皮”,它们在能力、分发、更新与性能上的差异,会改变后面所有章节的默认值。
Web 的发布最轻,但能拿到的系统能力有限;原生的能力最完整,但更新要经过商店与审核;小程序的启动路径短,但包体与运行环境受平台限制;桌面端的内存更宽裕,但生命周期与输入方式又是另一套逻辑。选择哪一种端形态,等于选择了一组约束组合。
如果不先把这组约束摆清楚,后面的讨论很容易变成“最佳实践大杂烩”:有人拿 Web 的经验套原生,有人拿原生的经验套小程序,最后每个结论都有人反对,因为大家讨论的根本不是同一个运行环境。
所以第 2 章给的是能力矩阵与选型判据,不是产品综述;第 3 章把体验拆成可以采样、可以预算的指标,而不是“感觉快不快”。这两章定下来,第 9 章的构建产物与第 10 章的渲染路径才有判断依据。
本书的四个立场
立场一:判断先于选型
新项目启动时,团队最容易问“用哪个框架”“用哪个状态库”。这些问题当然要回答,但它们不是第一个决定。第一个决定是每一类状态放在哪里、谁负责它的生命周期。
本书会在很多章节里先问“这份状态丢了会怎样”,再问“该用什么工具”。工具可以换,判断不能省。
立场二:约束要写成输入
四个约束听起来像常识,但常识最容易在项目里丢失。解决办法不是写一份更长的检查清单,而是把产品事实变成函数的输入:产品事实进去,风险与必备能力出来,冲突可以被测试拦住。
这就是实验工程存在的意义。书中出现的分类器、缓存、页面栈、同步队列,首先是为了让判断可以被验证,而不是为了提供一套生产框架。
立场三:边界要写进交付物
客户端书最容易犯的错,是给出一个“在真机上应该会很快”的结论,然后被读者当成事实。浏览器渲染、设备调度、网络切换、系统省电策略,这些东西在 Node 里复现不出来。
所以本书会把可运行性分成三级,明确哪些结论可以在本地验证,哪些只能给模型与检查方法。边界写清楚,比含糊地说“示例可以运行”更诚实。
立场四:数字必须有出处
书中出现的用户量、比例、耗时与成本,除非明确标注为本机运行输出,都是案例假设值,用来演示判断过程,不是任何真实产品的实测。本机输出会附命令与原始文本,读者可以按同样的命令复现;耗时数字随机器变化,不作为性能结论。
这条规则看起来琐碎,但它是技术书最起码的诚实。一个没有出处的数字,会被读者当成事实带进真实项目。
这套书怎么写
卷一 45 篇,由前置 3 篇、正文 39 篇与附录 3 篇组成。正文 13 章,每章 3 节,每节遵循同一个写法。
先给场景:一个具体的页面、一次具体的故障、一组具体的产品事实。没有场景的架构讨论,很容易变成名词解释。
再列选项:至少两种做法,各自适合什么条件,代价是什么。只给一个答案的章节,读者学不到判断,只能学到结论。
然后给决定:在什么前提下选哪一个,为什么。决定要写成可以复核的记录,而不是“业界通常这么做”。
接着给边界:这个决定什么时候会失效,出现什么信号时应该重新评估。架构决定很少一劳永逸,写明退出条件比写明理由更重要。
最后落到实验:一段可以在本地跑通的代码,或者一张可以检查的表。文字负责解释,产物负责让结论有牙齿。
这套写法有一个代价:它比“直接给最佳实践”啰嗦。但客户端架构的坑,大多不是不知道某个工具,而是不知道这个工具在什么条件下不适用。把条件写出来,比把工具夸一遍更有用。
写给谁
客户端工程师:如果你已经能写页面,但常常说不清“状态该放哪”“缓存该不该加”“旧版本怎么兼容”,这套书会帮你把直觉变成可以讨论、可以测试的判断。卷一按顺序读,每章的实验工程都可以本地跑。
技术负责人与架构师:如果你需要为端上架构负责,但团队里没有人系统讲过这件事,卷一第 1、4、13 章与附录 C 会给出判断框架。你可以把书里的问题直接带进评审。
产品经理与设计师:你不需要读代码。第 2、3、6 章讨论端形态、体验指标与页面栈,能帮你在提方案时知道哪些承诺在端上兑现不了,哪些体验问题其实是架构问题。
服务端工程师与全栈工程师:如果你习惯从服务器看系统,这套书会补上另一半视角。你会看到同一个产品在端上如何被拆成页面、状态、缓存与同步队列,也会理解为什么客户端不能简单地“每次启动拉全量”。
读这套书需要什么基础
读卷一不需要精通某个框架,但需要能看懂 TypeScript。书里的类型标注不复杂,主要是接口、联合类型与泛型的基本用法。如果完全没有接触过 TypeScript,建议先补一门语言入门课,再回到这里。
需要理解 HTTP 请求与响应的基本形状,知道什么是状态码、请求头与超时。书里不会从头解释这些概念,但会在用到时说明它在端上的特殊之处。
需要有一点前端或移动端的实际经验。哪怕只是做过一个页面、遇到过一次白屏、处理过一次缓存失效,读起来都会更有感觉。纯理论读者也能读,但很多判断的分量,要在做过之后才体会得到。
不需要会写原生代码,也不需要有小程序或桌面端的开发经验。第 2 章会把不同端形态的差异讲清楚;具体平台的 API 不在本书范围内。
如果你时间有限,可以先读第 1 章与第 13 章,再挑一个与自己工作最相关的问题章节。第 1 章给约束,第 13 章给收口,中间各章可以按需补。
如果你正在做技术选型,建议先读第 2、3、9、10 章,把端形态、质量预算、构建产物与渲染路径放在一起看。单独看任何一章,都容易得出片面的结论。
如果你在带团队,建议把第 4、5、7、11、12 章当作评审材料。它们讨论的是分层、状态归属、数据层、错误恢复与观测,这些决定一旦定错,后面很难靠加班补回来。
本书不承诺什么
- 不教 TypeScript 语法、CSS 布局或某个框架的 API。这些内容有更好的入门材料。
- 不提供可以直接复制上线的客户端框架。实验工程是判断的夹具,不是生产脚手架。
- 不声称做过真实浏览器渲染或真机性能实测。相关结论只给模型、口径与检查方法。
- 不评价具体框架、状态库或构建工具的优劣。书中只讨论判断方法,不绑定版本。
- 不把案例假设值当成行业标准。书里的阈值、比例与耗时,都需要读者按自己的产品事实替换。
- 不覆盖所有端形态的全部细节。Web、原生、小程序与桌面各有自己的生态,本书只讨论与架构判断相关的部分。
在这些边界之内,本书会尽量把端上的判断讲透:什么约束在起作用,有哪些选项,每个选项的代价是什么,什么时候应该改变决定。
关于代码与实验工程
卷一的代码放在实验工程 echo-client-lab 里,使用 TypeScript 与 Node 26 的原生类型剥离,不引入第三方依赖。它不是为了提供一个可以直接复制上线的框架,而是为了让书里的判断可以被执行、被断言、被回归。
实验工程按章分目录,按节分源文件与测试文件。每节的结论至少有一个本地可运行的检查点;涉及真实浏览器与真机性能的部分,不在工程承诺范围内,只给模型与检查方法。
可运行性分三级:L1 验证纯逻辑,L2 验证进程内端到端流程,L3 验证本地服务与时序。分级不是为了给章节贴标签,而是为了约束写作:级别决定了这段代码要不要真实运行、运行结果能不能写进正文、哪些结论只能写成“预期”或“案例假设值”。
代码块开头的三行标记分别说明文件路径、可运行级别与验证命令。正文与工程中的同一段代码保持逐字节一致,校验脚本会反向比对。这样能防止“正文改了、代码没改”或“代码能跑、正文贴的是旧版本”。
读者不需要先把整个工程跑一遍再读书。每节的验证命令都可以单独执行,按任意一节复现,拿到的都是同一份代码。
关于时间、版本与案例
客户端生态变化快,书中会尽量把与版本强绑定的内容收窄。涉及框架的部分只讨论判断方法,不评价具体框架的优劣;涉及语言与运行时的部分,会写明本机验证过的版本。
书中出现的用户量、比例、耗时与成本,除非明确标注为本机运行输出,都是案例假设值。它们的用途是演示判断过程,不是任何真实产品的实测,也不应该被当成行业标准。本机输出会附命令与原始文本,读者可以按同样的命令复现;耗时数字随机器变化,不作为性能结论。
案例假设值需要读者替换成自己的产品事实。比如“用户每小时被打断几次”这种输入,书里给一个数字只是为了让讨论开始,真实项目必须自己采样。把书里的数字直接搬进评审,比没有数字更危险。
最后
如果读完卷一只能记住一句话,我希望是这一句:客户端架构的第一课,是承认端上的默认假设与服务端相反。
承认网络会断,才会认真设计缓存与队列;承认进程会死,才会把不能丢的状态落到本地;承认旧版本不会消失,才会在删字段之前多想一步;承认设备差异巨大,才会给低端机留一条能走的路。
这四句话听起来朴素,但它们对应的是一整套设计动作:缓存策略、持久化边界、版本兼容窗口、降级路径。卷一后面的每一章,几乎都在展开其中某一条。
这四句话也不是四条孤立的规则。它们会互相牵制:为了离线可用而加缓存,缓存就会带来过期与一致性问题;为了兼容旧版本而保留字段,字段就会带来数据形状的分裂;为了照顾低端机而降级,降级路径本身又需要测试与观测。架构判断的难点,往往不在单条规则,而在几条规则冲突时怎么排优先级。
写这套书的过程中,我反复提醒自己:不要把“端上很重要”写成口号。重要不重要,读者读完一章自然会有判断。作者能做的是把约束、选项与代价摆清楚,让读者在自己的产品里做决定,而不是替读者做决定。
如果某一章读完,你觉得“这个结论在我们产品里不成立”,那也很好。至少你知道它为什么不成立,知道需要替换哪个前提。架构判断本来就不是一套放之四海而皆准的答案。
卷一的目录与学习路径在《互联网产品的客户端架构·端形态与体验基础》目录 里。从第 1 章的四个约束开始,先把地基打牢,再进入后面三卷的具体场景。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。