SSR 的线下测试结果,FP 到 FCP 从 825ms -> 408ms
SSR 要怎么做?
▐ 大的方向
SSR 本身意为服务端渲染,这个服务端可以在 任何地方 ,在 CDN 的边缘节点、在云上的中心机房或者就在你家的路由上。
实现一个 SSR 的 demo,熟悉的人应该都知道套路:
搞一个 Rax Server Renderer,传入一个 Rax Component,renderToString,完事了。业界也已经有很多实践的案例,但就像“把大象装进冰箱里”一样,看似简单的事情在双十一所要求的复杂场景稳定性下,需要有稳妥可实施的执行方案。
如何在现有的这套模块化、成熟的渲染架构之上使用SSR呢,一开始我们往常规的思路去想,直接在文档 HTML 响应中返回服务端渲染完成的 HTML,看下来存在几个问题:
-
改造成本高,对现有的服务端架构改动比较大(CDN 缓存失效,文档服务的要求更高)
-
无法复用现有的客户端性能优化能力,比如客户端主文档/Assets 缓存和数据预加载能力,会劣化完全可交互时间
-
CDN 缓存无法利用,TTFB 的时间增加,带来了新的 “完全白屏阶段”
-
SSR 服务不稳定因素较多,自动降级为CSR的方案复杂,无法保证 100% 能够降级
-
主文档 HTML 的安全防护能力较弱,难以抵御黑产的恶意抓取
基于以上的问题,我们考虑是否还有其他的方案可以 低风险 、 低成本 地实现SSR呢?经过短暂且激烈的讨论,我们设计了「数据 SSR」架构方案,分享给大家。
数据 SSR 渲染架构如下,文档服务返回的内容保持静态化不变,数据服务新增调用一个独立的 SSR FaaS 函数,因为数据里有这张页面包含的模块列表和模块需要的数据,SSR FaaS 函数可以直接根据这些内容动态加载模块代码并渲染出 HTML。
这套方案在客户端内的场景下可以很好的将 前端 + 客户端 + 服务端三者的能力结合到一起。
有人可能会问,为什么这个方案会带来性能提升呢?不就是把浏览器的工作移到了服务端吗?我们举个例子(数据仅为定性分析,不代表真实值)。在正常 CSR 渲染流程下,每段消耗的时间如下,首屏可视时间总共耗时1500ms。
在SSR渲染流程下,在**「调用加载基础js」**之前的耗时都是一样的,由于下面两个原因,在服务端渲染的耗时会比客户端低几个数量级。
-
服务端加载模块文件比在客户端快很多,而且服务端模块资源的缓存是公用的,只要有一次访问,后续所有用户的访问都使用这份缓存。
-
服务端的机器性能比用户手机的性能高出几个数量级,所以在服务端渲染模块的耗时很小。根据线上实际耗时统计,服务端单纯渲染耗时平均 40ms 左右。
由于 HTML 被放到了数据响应中,gzip 后典型值增加 10KB 左右,相应的网络耗时会增加 30~100ms不等。最终 SSR 的渲染流程及耗时如下,可以看到 SSR 首屏的可视时间耗时为660ms,比CSR提升了800ms。
总而言之,「数据 SSR」的方案核心哲学是:将首屏内容的计算转移到算力更强的服务端
▐ 核心问题
大方向确定了,我们再来看看 SSR 应用到生产中还存在哪些核心问题
-
如何做到 CSR/SSR 的平滑切换
-
开发者如何开发出“能 SSR”的代码
-
开发者面向前端编写的代码在服务端运行的不可控风险
-
低代码搭建场景下,在服务端解决楼层模块代码加载的问题
-
服务端性能
-
怎么衡量优化的价值
别急,我们一个一个的来看解法
如何做到 CSR/SSR 的平滑切换?
在我们的页面渲染方案中,有两个分支:
-
页面未开启数据SSR,则与原有的 CSR 渲染流程一样,根据数据中的模块列表加载模块并渲染
-
页面开启了数据SSR并且返回的数据中有 SSR HTML,则使用 SSR 的 HTML 塞入到 root container 中,然后根据数据中的模块列表加载模块最终 hyrdate。
优点很明显:
-
风险低,能够无缝降级到CSR,只需要判断数据接口的响应中是否成功返回 HTML 即可。如果 SSR 失败或者超时(未返回 HTML),通过设置合理的服务端超时时间(例如 80ms),不会影响到用户的最终体验
-
能够利用端上成熟的性能优化能力,比如客户端缓存能力,数据预加载能力。有客户端缓存能力,页面的白屏时间与原CSR一致;有了数据预加载能力,能够在页面加载之前就开始请求数据服务
在线上服务时,我们可以通过 HASH 分桶的方式对流量进行划分,将线上的流量缓慢的切换到 SSR 技术方案,既能保证稳定性,同时还可以方便的进行业务效果的进一步评估。
比较好的字符串转换为数字的 HASH 方法有 DJBHash,验证下来分桶效果较为稳定
开发者如何开发出“能 SSR”的代码?
很多做 SSR “demo”分享的往往会忽略一个重要点:开发者
在双十一的场景下,我们有百+的开发者,三百+的楼层模块,如何能推动这些存量代码升级,降低开发者的改造适配成本是我们的一个核心方向。
我们原有的楼层模块构建产物分为 PC/H5/Weex 三个,业界通用的是针对 SSR,单独构建一个 target 为 node 的构建产物。在实际 POC 验证过程中,我们发现其实绝大部分的模块并不需要改造就可以直接适配 SSR,而新增构建产物会牵扯到更多的开发者,于是想找寻别的解决方案。
复用现有 Web 构建产物的一个问题是,Webpack 4 默认会注入一些 Node 环境相关变量,会导致常用的组件库中的类似 const isNode = typeof process !== ‘undefined’ && process && process.env 的判断异常。不过还好这个是可以关闭的,开发环境下其他的类似 devServer 等的注入也是可以关闭的,这给了我们一点慰藉,最终复用了 Web 的构建产物。像更新的 Webpack 5 中把 target 的差异给弱化了,也可以更好的定制,让我们未来有了更好的社区化方向可以继续靠拢。
解决完构建产物的问题,在本地开发阶段,Rax 团队提供了 VSCode SSR 开发插件,集成了一些 best practice 以及 lint 规则,写代码的时候就可以发现 SSR 的相关问题,及时规避和修复。
同时我们模拟真实线上的环境,在本地提供了 Webpack 的 SSR 预览调试插件,直接 dev 就可以看到 SSR 的渲染结果。
针对开发者会在代码中直接访问 window 、 location 等变量的场景,我们一方面开发了统一的类库封装调用抹平差异,另一方面在服务端我们也模拟了部分常用的浏览器宿主变量,比如 window 、 location 、 navigator 、 document 等等,再加上与 Web 共用构建产物,所以大部分模块无需改造即可在服务端执行。
接下来的模块发布阶段,我们在工程平台上增加了发布卡口,若在代码静态检查时发现了影响 SSR 的代码问题就阻止发布并提示修复。
由于实际的业务模块量较大,为了进一步缩小改造的范围,测试团队联合提供了模块的批量测试解决方案。具体的原理是构造一个待改造模块的 mock 页面,通过比较页面 SSR 渲染后的截图与 CSR 渲染后的截图是否一致,来检测SSR 的渲染结果是否符合预期。
开发者面向前端编写的代码在服务端运行的不可控风险
尽管我们在开发阶段通过静态代码检查等方法极力规避问题,实际上仍然存在一些针刺痛着我们的心。
-
开发者把全局变量当缓存用造成内存泄露
-
错误的条件结束语句导致死循环
-
未知情况页面上存在不支持 SSR 的模块
这些疑难点从 SSR 的机制上其实很难解决,需要有完善的自动降级方案避免对用户的体验造成影响。
在说更详细的方案前要先感谢我们自己,前端已经提前做到了 CSR/SSR 的平滑切换,让服务端能每天不活在恐惧里 = =
对于机制上的问题,可以引申阅读到之前分享过的 在 Node.js 中 ”相对可靠” 的高效执行可信三方的代码。我们这里主要聚焦在如何快速止血与恢复。
FaaS 给服务端降低了非常大的运维成本,“一个函数做一件事”的设计哲学也让 SSR 的不稳定性局限在了一块很小的部分,不给我们带来额外的运维负担。
低代码搭建场景下,在服务端解决楼层模块代码加载的问题
业界分享的一些 SSR 场景基本都是整页或者 SPA 类型的,即 SSR 所使用的 bundle 是将整页完整的代码构建后暴露出一个 Root Component,交由 Renderer 渲染的。而我们的低代码搭建场景,由于整个可选的模块池规模较大,页面的楼层模块是动态选择、排序和加载的。这在前端 CSR 情况下很方便,只要有个模块加载器就可以了,但是在服务端问题就比较复杂。
还好我们的模块规范遵守的是特殊的 CMD 规范,有显式的依赖关系声明,可以让我们在获取到页面的楼层组织信息之后一次性的把页面首屏的全部 Assets 依赖关系计算出来。
/**
示例的 CMD 规范显式依赖关系(seed)
**/
{
“packages”: {
“@ali/pcom-alienv”: {
“path”: “//g.alicdn.com/code/npm/@ali/pcom-alienv/1.0.2/”,
“version”: “1.0.2”
},
“modules”: {
“./index”: [
“@ali/pcom-alienv/index”
]
}
}
在服务端加载到代码后,我们就可以拼装出一个 Root Component 交给 Renderer 渲染了。
服务端性能
性能上主要是有几个方面的问题
-
机制问题
-
代码问题
机制问题
由于楼层模块很多,在实际执行的过程中发现存在一些机制上的性能问题
-
代码的 parse 时间较长且不稳定
-
流量较低情况下难以触发 JIT
优化方案的话比较 tricky
-
缓存 vm.Script 实例,避免重复 parse
-
期望一致性 HASH 或自动扩缩容(本次未实现)
巡检的时候还观测到存在小范围的 RT 抖动问题,分析后定位是同步的 renderToString 调用在微观上存在排队执行的问题
在这种情况下会造成部分渲染任务的 RT 为多个排队任务的渲染 RT 叠加,影响单个请求的 RT(但不影响吞吐量)。这种问题要求我们需要更精确的评估备容的资源。机制上有效的解法推测可以让 renderToString 以 fiber 的方式执行,缓解微观排队造成的不公平的问题。
代码问题
性能问题的分析当然免不了 CPU Profile,拿出最爱的 alinode 进行分析,很快的可以找到热点进行针对性优化。
上图中标蓝的方法为 CMD 加载器计算依赖的热点方法,对计算结果进行缓存后热点消除,整体性能提升了 80% 》.》
怎么衡量优化的价值
这么多的投入当然需要完善的评价体系来进行评价,我们从体验性能和业务收益两个分别评估。
体验性能
基于兼容性较好的 PerformanceTiming (将被 PerformanceNavigationTiming 替代),我们可以获取到前端范畴下的一些关键的时间
-
navigationStart
-
firstPaint
其中 navigationStart 将会作为我们的前端起点时间所使用。在前端之外,对用户的交互路径而言真正的起点是在客户端的点击跳转时间 jumpTime ,我们也联合客户端进行了全链路埋点,将客户端 native 的时间与前端的时间串联了起来,纳入到我们的评价体系中。
在最开始的核心指标中,我们看到有 FCP、TTI 这几个指标。目前的 Web 实现中,还未有兼容性较好的可以线上衡量的方案(线下可以使用 DevTools 或者 Lighthouse 等工具),因此我们通过其他的方式来做近似代替
CSR | SSR | |
FCP | componentDidMount | innerHTML to Body |
TTI | componentDidMount | componentDidMount |
线上取到的数据通过 tracker 的方式进行无采样上报,最终我们可以通过多个维度进行分析。
-
机型
-
网络条件
-
是否命中 SSR
-
是否命中其他前端优化
主要的衡量指标有:
-
从用户点击到 FCP 的时间(FCP - jumpTime)
-
从 NavigationStart 到 FCP 的时间(FCP - NavigationStart)
业务收益
这部分很忐忑,体验的优化是否会带来真金白银的收益呢?我们直接通过 AA 和 AB 实验进行业务数据的分析。
基于之前的切流分桶,我们可以通过类似 hash 值 % 10 的方式将流量分为 0~9 号十个桶,首先通过 AA 实验验证分桶是否均匀
桶号 | 0 | 1 | 2 | 3 | ... |
PV | 100 | 101 | 99 | 98 | |
UV | 20 | 21 | 22 | 20 |
统计指标举例
这一步是保证分桶的逻辑本身不会有数据的倾斜影响置信度。接下来我们再进行 AB 实验,逐步增加实验桶验证业务数据的变化。
最终的效果
搞了这么多,得看看产出了。在这次双十一会场中,我们切流了多个核心的页面,拿到的第一手数据分享给大家。
潮流女装会场 | CSR | SSR |
从用户点击到 FSP 的时间小于 1s 的比例 | iOS 66% Android 28% | iOS 86% +30% Android 60% +114% |
UV 点击率提升 | +5% |
小米5 骁龙 820 处理器
可以看到,在 Android 碎片化的生态的下,带来的提升甚至超出了预期,这也给了我们未来更大的动力,将前端 + 客户端 + 服务端的能力更有效的结合到一起,带给用户更好的体验,给业务创造更大的价值。
最后
文章中涉及到的知识点我都已经整理成了资料,录制了视频供大家下载学习,诚意满满,希望可以帮助在这个行业发展的朋友,在论坛博客等地方少花些时间找资料,把有限的时间,真正花在学习上,所以我把这些资料,分享出来。相信对于已经工作和遇到技术瓶颈的朋友们,在这份资料中一定都有你需要的内容。