多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

SSR与Hydration:摊开讲透的底层逻辑与性能平衡艺术

SSR与Hydration:摊开讲透的底层逻辑与性能平衡艺术 很多做前端的同学一聊到服务端渲染第一反应都是“为了SEO”、“首屏快一点”然后就直接往项目里怼了一个 SSR 框架。等到真正上线才发现事情远没有想象的那么简单页面确实更快出来了但按钮点了半天没反应或者 FCP 很漂亮、TTI 却拉胯得离谱。这些问题的源头几乎都指向同一个概念——Hydration水合/注水。这篇文章我想从一个老开发的角度把 SSR 和 Hydration 的底层逻辑摊开来讲清楚为什么我们需要它它真正的成本在哪里以及“性能平衡”这四个字到底意味着什么。不是单纯地给你一份配置文档而是把那些文档里不会写的道理和踩坑经验一起聊透。如果你正在准备给团队项目引入 SSR或者已经被 SSR 的性能问题折磨过一阵子这篇文章会很对你的胃口。1. 先拨开迷雾SSR到底解决了什么问题没解决什么问题1.1 从“白屏”说起单页应用让人又爱又恨的地方这几年前后端分离是绝对的主流页面交互体验确实上来了。但要说痛点单页应用CSR一直有两个绕不开的硬伤。第一个是首屏白屏时间。浏览器拿到的 HTML 是一个空壳里面只有一个div idroot剩下的全靠 JavaScript 脚本去动态渲染。用户打开页面的那一两秒里要经历“下载 JS → 解析执行 → 请求接口 → 再渲染 DOM”这么一条链路屏幕上一片空白只能转圈。移动端网络差一点白屏三五秒很常见用户早跑了。第二个是 SEO。搜索引擎爬虫虽然现在也能执行一部分 JavaScript但执行的成本和深度跟现代浏览器没法比。很多爬虫拿到的就是那个空壳 HTML页面在搜索引擎眼里约等于不存在。花了大力气做的产品页、文章详情页搜索排名上不去流量就没了。这两个痛点催生了服务端渲染的需求。服务端直接把组件渲染成完整的 HTML 字符串返回给浏览器爬虫拿到的是一份有真实内容的文档用户打开页面也能立刻看到结构不用等 JS 慢慢跑。1.2 SSR 不是银弹它改变的是“感知性能”不是“页面真的变快了”这里有个很关键的认知需要先掰正SSR 并不是让你的网站“运行得更快”而是让它“看起来更快”。从服务端拿回来的 HTML 是一个静态的、瞬间展示的骨架这部分确实快。但页面上的按钮、输入框、列表滚动、路由跳转这些交互能力是靠 JavaScript 后续附加上去的。也就是说SSR 把“渲染工作”前置到服务器做了一遍但浏览器端该做的事情一件都没少尤其 Hydration 这一步反而增加了额外开销。我见过不少团队引入 SSR 之后首屏确实从 4 秒降到了 1 秒但用户点击第一个按钮到产生响应还是等了两三秒钟。因为脚本依然要在后台下载、解析、执行与已经展示的 HTML 完成“接合”——这就像一个装修队把家具搬进来了但每个螺丝、每个抽屉还是得现场拧一遍屋子看着像住人了实际上还没法正常用。所以要谈 SSR 的性能就要把话题焦点从“首屏展示”转移到“首屏展示交互可用”这个整体链路而这正是 Hydration 的用武之地和争议所在。2. Hydration不是简单“绑事件”它是让静态页面“活过来”的复杂手术2.1 静态 HTML 和“活”页面的鸿沟为什么不能直接监听就好了很多人刚接触 Hydration 时都会有一个朴素的想法服务端不是已经把 HTML 返回了吗那浏览器拿到之后为什么不能直接给这些 DOM 节点加上事件监听器就完事了这个想法听起来很美但实际做起来完全行不通。问题在于现代前端框架无论是 React、Vue 还是其他生态的核心逻辑是“数据驱动视图”。页面上的一切状态——比如用户登录信息、购物车数量、表单输入值、弹窗开合——都存在于组件的状态对象里视图是状态的一种投影。如果只给静态 HTML 绑上事件但没有把对应的组件状态、内部数据结构和虚拟 DOM 树恢复到浏览器内存里那当你点一个按钮时事件处理函数要读取状态、要触发重渲染、要更新视图它根本不知道状态在哪里虚拟 DOM 长什么样。整棵组件树在浏览器端是“失忆”的。所以在 Hydration 过程中框架必须在浏览器端重新创建一遍完整的组件树生成对应的虚拟 DOM并且把服务端渲染时用到的数据状态一并恢复出来。然后它以服务端生成的真实 DOM 为基准把虚拟 DOM 与真实 DOM 做一次“对上号”的匹配最后才在匹配的节点上挂载事件监听器、建立好完整的数据流。2.2 Hydration 的完整流程一棵树从“静态照片”变成“实时系统”的四个阶段为了让你真正理解 Hydration 到底在做什么我把这个过程拆成四个阶段。第一阶段是“状态恢复”。服务端在渲染 HTML 的同时会把当时渲染所用到的数据序列化成一段内联脚本通常是塞进window.__INITIAL_STATE__这样的全局变量里。浏览器端的代码启动后第一步就是读取这个全局变量把服务端的“记忆”接过来让组件初始状态和服务端渲染时保持一致。第二阶段是“组件树重建”。框架会根据路由和组件定义在内存里重新创建一棵完整的组件树并生成对应的虚拟 DOM 结构。这一步本质上就是“再渲染一遍”所以它的 CPU 开销和正常渲染完全一样——这也是 Hydration 耗时的主要来源之一。第三阶段是“DOM 匹配与差异化比对”。虚拟 DOM 生成了但它需要和页面上已有的真实 DOM 对上号。框架会逐层遍历、比对两者的结构找出哪里吻合、哪里缺失。绝大多数情况下两者是吻合的毕竟 HTML 就是同一套代码渲染出来的但如果有不一致——比如某些组件在客户端渲染时因为环境差异结果不同——框架就要做校正。第四阶段是“事件绑定”。到了这一步框架才会把各级组件的事件处理器挂载到对应的 DOM 节点上然后标记这棵树的“水合”完成。从这一刻起页面才真正从一个可看的静态页面变成了一个可用、可交互的完整应用。这整个流程有点像给一栋已经建好的房子做水电改造——墙是现成的但你要把电线和水管重新穿进去而穿的过程不能把墙砸烂。2.3 为什么虚拟 DOM 的 Diff 在这种场景下依然不可或缺前面提到Hydration 过程中需要做“差异化比对”这就是虚拟 DOM 的 Diff 算法在服务端渲染场景下的延伸应用。你可能会有疑问既然服务端渲染的 HTML 和客户端重新渲染的结果理论上是一模一样的那为什么还要做 Diff直接信任服务端的结果把事件绑上去不就行了答案是理论是理论现实是现实。你没法保证两者永远一致。举个例子。组件里有个Date.now()的调用或者Math.random()生成的临时 ID服务端跑一次的结果和客户端跑一次的结果必然不一样DOM 结构就可能出现偏差。再比如某些浏览器插件会在 DOM 里注入节点比如一些翻译插件这会破坏原有的结构让事件的挂载寻找不到目标节点。所以一份稳健的 Hydration 实现必须包含比对环节。框架在真实 DOM 与虚拟 DOM 不一致时会采用“以真实 DOM 为准”或“以客户端虚拟 DOM 为准”的修复策略这就由框架的具体实现来决定了。但不管怎样这个环节的存在是必要的因为它构成了安全网否则你连“这个页面能不能正常交互”都没法保证。3. 性能的代价那些你会在生产环境里真实付出的成本3.1 双重执行服务端渲染一次客户端还要再渲染一次我现在要说的这一点可能是很多人真正把 SSR 部署到生产环境之后才反应过来的你的代码实际上被完整执行了两遍。服务端执行一遍目的是生成字符串类型的 HTML 输出给用户。客户端还要再执行一遍目的是生成虚拟 DOM、做水合、让页面可交互。这两遍执行加起来的 CPU 开销比纯客户端渲染多了不止一倍。而且这还不算网络往返、HTTP 请求头大小、CDN 缓存这些外围成本。尤其当组件树非常庞大、状态数据非常复杂的时候客户端的重复渲染往往会成为性能瓶颈。大家可以试想一个大型后台管理系统页面组件上百个全局状态全部序列化下来有几 MB在这种情况下做全量水合那必然是卡顿的。好在我观察到一个趋势现在的新一代框架已经非常关注这个问题在“如何减少水合工作量”上花了很多功夫。实际上SSR 的优化空间主要并不在于把 HTML 渲染得多快而是在于如何降低客户端重复执行的开销。3.2 FCP 与 TTI 之间的落差为什么你的页面“能看”却“不能点”我在实际项目里经常用两个指标来评估 SSR 的效果一个是 FCPFirst Contentful Paint首次内容绘制一个是 TTITime to Interactive可交互时间。纯客户端渲染的项目里这两个指标通常差不了太多因为页面内容本来就是 JS 执行完后才渲染出来的。但 SSR 项目里它们的差距会被大幅拉开。HTML 一到浏览器FCP 瞬间就好了但此时 JS 还没加载完Hydration 还没开始页面虽然显示了一堆内容和样式但你是点不动任何东西的。比如一个电商首页首屏商品图片和标题通过 SSR 很快加载出来了但用户想点击“加入购物车”按钮抱歉按钮僵尸般毫无反应要等脚本下载完、水合完成这个按钮才真正可以点击。TTI 被拉长这是 SSR 最隐蔽的性能陷阱因为只看 FCP 指标时你会觉得“挺好啊”但用户体感却是“这个页面卡住了点哪都没用”。甚至可以说这种“看起来加载完但实际不能点”的体验比白屏更让人抓狂——至少白屏还让人知道系统在加载。3.3 序列化的负担HTML 里藏着的“小纸条”究竟有多重这是一个经常被忽略的细节但影响却不小。前面我提到Hydration 的第一步是“状态恢复”通过全局变量读取服务端渲染时用到的数据。这份数据要嵌进 HTML 里以script标签的形式传到浏览器端。问题就来了数据量越大HTML 体积越大解析成本越高而这份“全局状态”大多数时候是不能被缓存的。我见过一个实际案例某个内容平台的详情页几乎把整个页面的数据模型都序列化到了window.__INITIAL_STATE__里。服务器返回的 HTML 足足有 800 多 KB其中一大部分是那段 JSON。这不光是带宽压力浏览器在 HTML 解析阶段就要把这一大段 JSON 解析出来本身就是一个不小的解析耗时。很多团队主服务器渲染SSR时完全没意识到他们传给客户端的“状态快照”太重了。等瘦身之后把冗余字段去掉、只保留首屏真正用到的数据HTML 体积直接降到了 120KBTTI 指标一下子提升了近一半。数据序列化的取舍是真的会直接反映在性能数据上的。4. 平衡的艺术核心流式渲染、按需水合与数据瘦身4.1 流式渲染不用等全部数据先让首屏“能看的”出来既然 SSR 的一个问题在于“所有东西都要等服务端把所有数据拿齐才开始渲染”那顺着这个思路自然会想到能不能先渲染一部分先发给浏览器呢答案是能而且在今天的框架生态里已经很成熟了就是流式渲染Streaming SSR。常规 SSR 做法是等所有异步数据全部拿到 → 组件树全部渲染完成 → 生成完整 HTML → 返回给浏览器。整个过程是“要么全部要么全无”。流式渲染则是框架一拿到首屏必需的数据就开始渲染同时把不需要等待的部分拆成后续的“流”stream先以完整的 HTML 片段把首屏内容发给浏览器。还没准备好的部分会以占位标记形式返回等后续数据就绪再补发这段 HTML。这有什么好处最直接的影响是用户打开页面的速度感受被大幅提前了。哪怕是 Header、首屏 Banner、主要商品列表这样的核心内容只要这些数据接口响应够快用户几乎立刻就能看到页面框架而不必等整个页面所有接口都返回。当然流式渲染也带来一些实现复杂度比如不同步的 HTML 片段可能影响 SEO 爬虫抓取的完整性某些框架对流式输出的支持方式也可能限制你能用的服务端特性。不过总体来说流式渲染已经是现代 SSR 框架的标配能力如果你的应用有多个相互独立的数据接口我强烈建议优先考虑接上流式渲染。4.2 部分水合与“岛架构”不是所有组件都需要立刻活过来流式渲染解决了“展示”的问题但还没有解决“交互”的问题。于是水合层面的优化就登场了——核心思路很简单能不能不对整棵组件树做全量水合而只对那些真正需要交互的组件做水合这个思想现在有个很流行的名字叫“岛架构”Islands Architecture也被称为“部分水合”Partial Hydration。页面里大部分内容其实是静态的——文章正文、产品描述、价格信息它们只需要被展示不需要任何交互逻辑。真正需要交互的只有几个“岛”搜索框、加购按钮、评论区的折叠控件、导航菜单。那么为什么我们要为一个 90% 都是静态内容的页面付出 100% 的水合成本呢正确做法是把页面分成一个个独立的“组件岛”每个岛单独执行自己的水合逻辑岛与岛之间互不干扰。静态区域就保持静态不加载任何相关 JS不建立任何状态树也就没有水合开销。“岛架构”听起来很漂亮实现起来却有一些主观取舍。比如一个电商详情页评论区可能需要“点赞”功能那这个评论区就是一个岛如果另一个页面根本没有评论区那这个岛就整个不要了。每个页面需要哪些岛、岛要多大、岛与岛之间是否需要通信这些都要在架构设计阶段想清楚。当前主流框架的生态我也一直在观望比如一些以“零水合、静态优先”为卖点的框架其实就是把这条思路做到了极致。如果你不能直接迁移到这些新框架选用主流框架【支持部分水合】的库或框架级方案也能在大部分场景获得不错的收益。4.3 能不做水合就不做水合静态页面就该是静态的顺着这个思路有一种更激进但相当实用的策略如果某个页面根本不需要任何交互那就完全不做水合。很多内容页、官网介绍页、文档页其实用户访问它们就是来看信息的从头到尾没有任何点击动作。这种页面用 SSR 生成静态 HTML 后直接不加载客户端框架代码那就没有水合过程没有状态恢复没有事件绑定整个页面就是一份纯粹的 HTML。这个策略的实现方式也很直接在做路由设计时把页面分档——需要在客户端运行的应用页面走全量水合部分需要交互的走部分水合完全静态的页面连水合代码都不要引入。这里我要提醒一句判断“是否需要交互”不能只盯着当前功能要考虑到未来迭代。一个现在看起来完全静态的落地页过几个月可能就要加一个咨询表单、加一个数据埋点弹窗。所以“静态页面”的策略要配上良好的页面声明机制让后来接手的人能够容易地把某个页面从静态切换为可交互模式。4.4 数据序列化的瘦身克制克制再克制之前在讲成本时提到过数据序列化的负担。现在要聊怎么解决——第一个原则就是能不序列化就不序列化能少序列化就少序列化。具体怎么操作呢我总结了三个方向。第一明确“首屏才需要”和“后续才需要”的边界。很多全局状态是用户操作之后才会用到的比如“用户点击某个按钮后弹出的弹层数据”这些数据完全没必要在初次渲染时塞进 HTML 里。把它留在客户端后续异步请求里就好。第二优先使用服务端缓存。如果每个页面访问都重新序列化一遍相同的一大坨数据那显然是浪费。在服务端做数据级缓存让多个用户共享同一份序列化结果能大幅降低响应的耗时和体积。第三不要全盘照搬服务端数据模型。很多服务端返回的数据里有很多无用字段比如id、createdAt、updatedAt、各种外键关联但这些字段在页面首屏渲染时根本用不到。在服务端组装数据的时候就应该做一次字段裁剪只把页面需要的数据暴露到序列化状态中。我见过有些开发者图省事直接把接口响应原封不动塞进初始状态这种习惯必须改。5. 我在 SSR 落地时的实测数据与反思5.1 一次真实项目中的组合拳流式渲染 数据瘦身 按需水合讲完了理论我想分享一个我在某内容社区详情页项目中的真实优化过程。这个项目早期走的是标准 SSR 全量水合线上数据大致是FCP 1.1sTTI 3.8sHTML 体积 860KB。模块加载和总时长都让用户明显感知到“慢”。第一次优化我上了流式渲染把详情页的“正文区域”和“右侧推荐栏”拆成两个数据流。改完后 FCP 降到了 0.7s但 TTI 变化不大因为水合数据量还是那么多。第二次优化做了数据序列化瘦身。我把详情页和推荐栏的数据模型梳理了一遍砍掉所有非展示字段只保留标题、摘要、封面图和正文核心数据。HTML 体积从 860KB 砍到了 220KB这一刀下去 TTI 从 3.8s 降到 2.4s提升非常明显。第三次优化做了按需水合。详情页的评论区是唯一的交互点我把它做成了一个单独的“岛”正文区彻底静态化。这次改完后 TTI 从 2.4s 降到了 1.5s工具栏加载量也少了一大截。三轮调优下来页面核心指标变化如下表优化阶段FCPTTIHTML 体积全量水合1.1s3.8s860KB流式渲染0.7s3.6s830KB数据瘦身0.7s2.4s220KB按需水合0.6s1.5s210KB第二轮优化时流式渲染对 FCP 的影响其实有限因为首次内容已经很快了但它让首屏体验的稳定性变好了。真正的质变发生在数据瘦身和按需水合这两步。5.2 性能平衡的本质不是追求某一个指标而是权衡整套体验做完这轮优化之后我把整个思考过程复盘了一下发现性能平衡的本质不是追求“首屏最快”或“脚本最小”而是在多项指标之间寻找用户体感最佳的点。FCP 代表的是“视觉反馈”TTI 代表的是“可操作反馈”脚本体积代表的是“成本和可维护性”。你想让 FCP 更快就多做静态化你想让 TTI 更快就要减少水合范围。但这二者有时候是矛盾的——比如你为了 FCP 更快把更多内容静态化但内容一多HTML 也变大解析时间变长反过来影响 TTI。所以更要紧的是想清楚你服务的用户到底更关注什么如果是内容消费型产品资讯、文档、博客用户的耐心更多放在阅读上FCP 的意义大于 TTI如果是工具型、交易型产品购物下单、后台操作那 TTI 才是生死线。5.3 避坑提示别让 SSR 毁掉你的可维护性最后聊几个我真心觉得要避开的坑。第一个坑滥用 SSR 到一切页面。一个登录后的后台管理系统所有页面都有权限控制任何一个页面都要求用户先登录那这些页面根本不需要 SSR。服务端渲染只会增加服务器压力而且登录态数据也不适合给爬虫看。判断标准很简单内容是否公开是否对 SEO 有意义是否需要快速首屏三个都满足才考虑 SSR否则不如老老实实做 CSR。第二个坑盲目信任缓存。SSR 的输出可以缓存但缓存什么、缓存多久要非常小心。如果是登录用户的个性化页面随便一顿缓存很容易把别人的数据串到另一个人头上。我见过线上事故就是缓存 Key 设计不当A 用户下单后看到了 B 用户的收货地址。各位先把缓存策略想清楚再做 SSR 加速。第三个坑忽视错误边界。服务端渲染时如果某个组件抛了异常轻则整页 500重则进程崩溃。Hydration 时如果真实 DOM 和虚拟 DOM 不一致也可能出现难以排查的诡异交互问题。所以上 SSR 之前务必要把错误边界Error Boundary体系建立起来把服务端和客户端两侧的容灾逻辑都设计好。5.4 一点补充关于水合失败的常见表现和排查方向在实际布局 SSR 的过程中水合失败往往是最让人头疼的。这里分享几个排查方向和定位方法。水合失败通常的表现是页面显示正常但某些事件点击无响应或者某个区域的状态在页面刷新后发生变化。排查时我一般分三步走。第一步检查你的时间相关函数和随机函数。这些函数在服务端和客户端执行结果不同是最常导致虚拟 DOM 与真实 DOM 不一致的原因。对策是服务端和客户端使用确定性的数据源对于时间相关内容允许客户端水合后用真实时间校正。第二步检查第三方脚本是否在 HTML 解析时向 body 或 root 节点注入额外的 DOM。这类干扰不容易一眼发现但会导致框架遍历真实 DOM 时遇到“多出来的节点”从而匹配失败。解决方案是在 Hydration 的根节点范围内尽量避免第三方脚本注入。第三步开启框架自带的“水合不匹配警告”功能在开发环境把警告视为错误来处理。大多数框架都有这个开关开发期就暴露出所有不一致比线上埋雷强一万倍。这个习惯我坚持了几年可以说帮我避开了不少线上事故。关于 SSR 和 Hydration我最后想留给各位一句话它是一把双刃剑用得好它让你像开挂一样享受首屏优势和 SEO 红利用不好你会在服务器损耗和交互延迟里煎熬。核心不是要不要用 SSR而是怎么在展示与交互、服务端与客户端、缓存与个性化之间找到那根最适合自己业务形态的平衡线。你所做的每一次取舍都是在用自己的业务逻辑定义“性能”这个词的真正含义。
返回列表