作者 | 霸剑、步峰
会场是每年双十一的主角之一,会场的用户体验自然也是每年最关注的点。在日趋复杂的业务需求下,如何保障我们的用户体验不劣化甚至能更优化是永恒的命题。
今年(2020)我们在不改变现有架构,不改变业务的前提下,在会场上使用了 SSR 技术,将秒开率提高到了新的高度(82.6%);也观察到在用户体验得到优化的同时,业务指标如 UV 点击率等也有小幅的增长(视不同业务场景有不同的提升,最大可达 5%),带来了不错的业务价值。
本文将从服务端、前端两个角度介绍我们在 SSR 上的方案与经验
- 前端在解决工程化、业务效果评估上的具体实践与方法论
- 服务端在解决前端模块代码于服务端执行、隔离和性能优化上的具体实践与方法论
页面体验性能的核心指标
在正文开始前我们先介绍一下衡量的相关指标,从多年前雅虎 yslow 定义出了相对完整的体验性能评估指标,到后来的谷歌的 Lighthouse 等新工具的出现,体验性能的评估标准逐渐的统一且更加被大家认同。
会场的评估体系
基于 Web.Dev 以及其他的一些参考,我们定义了自己的简化评估体系。
TTFB(Time to First Byte): 第一个字节的时间 - 从点击链接到收到第一个字节内容的时间
FP(First Paint): 第一次绘制 - 用户第一次看到任何像素内容的时间
FCP(First Contentful Paint): 第一次内容绘制 - 用户看到第一次有效内容的时间
FSP(First Screen Paint,首屏可视时间): 第一屏内容绘制 - 用户看到第一屏内容的时间
LCP(Largest Contentful Paint): 第一次最大内容绘制 - 用户看到最大内容的时间
TTI(Time To Interactive): 可交互时间 - 页面变为可交互的时间(比如可响应事件等)
大体上来说 FSP 约等于 FCP 或 LCP。
会场的现状
我们的会场页面是使用基于低代码方案的页面搭建平台产出的,一个由搭建平台产出的会场页面简单而言由两部分组成:页面框架(layout)和楼层模块。
页面框架有一份单独的构建产物(即页面的 layout html 以及基础公共的 js、css 等 assets 资源)。每个楼层模块也有单独的一份构建产物(模块的 js、css 等 assets 资源,基础公共 js 的依赖版本信息等)。
页面框架的任务比较繁杂,负责页面的 layout、根据页面的搭投数据加载具体哪些楼层模块并组织分屏渲染模块。
会场原有的 CSR 渲染架构如下图,可以分成三部分:
- 客户端,包括手机淘宝等阿里系 App
- 文档服务,用于响应页面的主文档 HTML 清求
- 数据服务,用于响应页面的数据请求
原有的CSR渲染流程如下图
针对会场的性能,除了基础的大家都知道的前端优化手段之外,还结合客户端能力做过很多优化方案,比较具有代表性的有两个:
- 客户端主文档/Assets 缓存
在客户端内,我们利用了端侧提供的静态资源缓存能力,将 HTML 和基础公共的 JS 等资源,推送下发至用户侧客户端缓存。当客户端的 WebView 请求资源时,端侧可根据规则来匹配已下发的缓存包,在匹配成功后直接从本地缓存中读取对应的 HTML 和 JS 资源,而无需每次都请求网络、大大缩短了页面的初始化时间。
- 数据预加载
从用户点击跳转链接到页面开始加载数据,中间还要经过客户端动画、WebView初始化、主文档 HTML 请求以及基础公共 js 的加载和执行这些过渡阶段,加起来有 几百ms 的时间被浪费掉。通过客户端提供的数据预加载能力,在用户点击后就可以立即由 native 开始页面的数据加载,等页面的基础公共 js 执行完需要使用页面数据时,直接调用 jsbridge 接口即可从 native 获取已经预先加载好的数据。
在这些优化工作的基础上会场的体验性能已经可以达到不错的水准。
随着时间的推移,基于我们 CSR 渲染体系下的优化存在一些瓶颈:
- 在线上复杂网络环境下(低网速、虚假的 WiFi)、Android 中低端机上的页面体验还是不尽人如意,特别是模块的加载和执行时间比较长,且这部分用户的占比有增长趋势
- 作为拉新的一个重要手段,外部唤起淘宝或者天猫客户端因为需要时间来初始化一些功能组件,比如网络库等,页面的体验从体感上不能追平端内的会场
- 会场是营销活动性质的业务,页面的复访率相对较低,且页面内容全面个性化。离线的 HTML 快照等用户侧缓存手段会因为缓存的数据过期导致出现重复渲染(打开更慢)、页面元素跳动(渲染闪烁、重排)等伤害体验的问题
还有没有优化手段呢?以一个 2020 年双十一会场页面,使用 PC 上的 Chrome DevTools 的 performance 离线分析结果为例,我们看一下重点的问题。
可以看到页面从 FP 到 FCP 这段过渡的时间较长且只有背景色。FCP 到 LCP 这段时间处于等待图片加载的时间,优化空间较小,且难以衡量。
离线分析尚且如此,线上更有着复杂的网络环境/差异化的手机机型等,这样的“背景色”时间对用户的体验有很大的伤害,可能会让用户更加容易跳失。
我们的 CSR 渲染体系依赖前端+客户端的能力,从工作机制上已经很难再有比较大的提升。怎么才能让会场页面的体验更上一层楼呢,我们想到了服务端渲染(SSR), 针对 FP 到 FCP 这段时间进行攻坚优化。
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 处理器
未来的渲染架构还会更复杂吗?
为了更好的用户体验,当然会了!我们可以简单的看看短期和长期的一些事情。
电商体验指标的统一定义
长期以来,业务在用户侧的实现有 Web、Native、Hybrid 混合开发等多种选择,每个体系都有着自己的封闭体验衡量标准,这就造成了一些“鸡同鸭讲”的问题。而 Web.dev 中所定义的 FCP、LCP 通用评价体系也并不适合电商场景,能展示出核心的商品/店铺其实对一张页面来说就完成了它的使命。
后续我们可以将体验指标评估标准对齐,将起点时间、绘制完成时间等在多个体系对齐概念与实现,达到互相之间可以横向比较良性竞争的状态。
工程上还有更多的事情要做...
在 Webpack 5 的 Release Note 中,我们可以看到 Webpack 正在弱化 target 的一些特殊处理,将 Web 描述为了 browserlike 的环境。同时还提供了自定义 browserlist 的能力,可以给予开发者更方便处理跨端的兼容性问题的能力。这一变化将推动我们更快的拥抱社区,获得更好的开发体验。
现有的 SSR 静态代码检查方案会有一些漏网之鱼,还有没有更完善的方案能从工程上前置解决代码风险(性能、安全)问题也是未来的一个方向。
ServiceWorker Cache 离线缓存快照
复访率高,变化不太大的页面可以利用 ServiceWorker Cache 等方案,将之前的渲染结果缓存下来,命中缓存直接用,未命中缓存 SSR。降低服务端压力的同时可以让体验更好。
SSR 的性能优化与安全
现阶段的 Node.js 或者说 V8,对于动态加载代码的情况支持并没有特别的完善,缺失了安全相关的保护逻辑。并且从性能上来说,SSR 属于 CPU 密集型的 workload,纯异步的优势并不明显,也可能需要一些特殊的解决方案来配合。
外部投放场景的覆盖
「数据 SSR」的方案是端内的最佳方案,却是外投场景的最劣方案。外投场景下由于用户是在第三方 App 中打开页面,相应的缺失了客户端的定制化优化能力,SSR 调用会造成数据服务的 RT 增加,反而推后了 FCP。
这时候古老的 HTML 直出方案又可以再捞回来了。
核心在于
- 利用 CDN 的边缘计算能力,可以较好的做到“动静分离”以及容灾
- 使用中心化的 SSR 函数,可以将 SSR 的不稳定性与 CDN 的可靠性分离,保证近端链路的可靠,避免出现近端直接不可用导致的无法恢复
近端的流式方案经常被提及,但是在实际的使用中会遇到当流式输出遇到错误时,用户侧无法有效容灾的问题(HTML 损毁,无法补救)。通过“动静分离”可以将页面分为
仅将 Root Container 进行动态化,进而在享用流式输出带来的 TTFB 提前的好处的同时又能兼顾容灾 SSR 的不稳定。和业务团队更可以一起探讨下如何将页面更好的从业务上做到“动静分离”,而不是仅从技术的角度出发。
总结
渲染架构的不断改进实质上是我们在有限且变化的环境下(终端性能、复杂网络和多变业务)自发做的适应,也许有那么一天,环境不再是问题,性能优化的课题将会消失。我们项目组有时候还开玩笑,等明年手机叒换代了,5G 100% 普及了,是不是这些优化都可以下线了😝
但是!现在看理想还有点远,在 2020 的双十一会场我们走进了一个新的深水区,期待未来技术与业务结合能带给广大用户更棒的体验!
🔥第十五届 D2 前端技术论坛开放报名,速抢!
关注「Alibaba F2E」
把握阿里巴巴前端新动向