我们ssg/ssr场景下的css inline方案演进
摘要:为优化SSR项目首屏加载速度,团队探索CSS内联策略。最初方案是构建时渲染页面并提取CSS注入HTML,虽有效果但存在冗余CSS问题。随后尝试使用Critters在SSG场景下提取关键CSS,但在SSR中因DOM不一致导致样式不全。最终方案是SSR运行时找出并内联使用到的组件CSS,避免冗余且保持首屏效果,适用于打包方案不易确保无冗余的情况。此方案帮助将端外落地页FMP p50优化至1秒内。
背景
首先知识背景是我们知道css 加载会阻塞HTML渲染。下图通过 chrome 模拟弱网环境下的SSR页面加载时序图。从图中可以看出styles.fb201fce.chunk.css 下载耗时 18s,阻塞了页面的渲染,HTML 主文档耗时 2.38s 就已经下载完成了,但是实际渲染时间却是在 20s 之后。
然后现实背景是团队有一个性能优化专项,我们希望通过将首屏用到的css注入到html中的方式来进一步优化SSR项目FMP的时长。
示例ssr最终产出的html:
// 原
<html lang="en">
<link rel="stylesheet" href="/index-b1759291.css">
</html>
---->
// css inline
<html lang="en">
<style>
.a{
font-size:15px;
}
</style>
</html>
社区经验
据笔者考究,目前社区的一些SSR框架nuxt, next 都不具备一键开启critical css inline 的功能,都需要业务去自己实现。而且从社区的实践来看也基本围绕我们演进路线上的第一和第二种方案。next.js相关: dev.to/focusreacti…stackoverflow.com/questions/5…github.com/vercel/next…segmentfault.com/a/119000004…
nuxt.js相关:www.npmjs.com/package/@nu…github.com/nuxt/nuxt/d…
nitropack:nitropack.io/blog/post/c…
演进路线
1、构建时渲染一遍并从首页引用到的bundle中提取出css
我们使用的SSR框架为团队自研,render过程是利用了vue的renderToString等能力。基于此我们设计了第一版方案,第一版方案是在构建时去渲染页面,然后在渲染页面的过程中记录下引用到的bundel,将这些bundle中css部分提取出来注入到html中。
由于这种方案的开发成本比较低,且很多社区里的实践也是这种方式,所以第一版上了这个方案。并且在跟有无css inline 的线上abtest中确实取得了较好的效果:
但其弊端也显而易见,由于是使用了bundle里的css,而一个bundle里可能包含了首屏没有用到的组件,比如条件渲染的一些dom,也就是说会有多余的css。 基于此,我们希望探索出一种只注入实际被首屏使用的css的css inline 方案。
2、构建时通过critter去获取关键css。
时间来到 我们遇到了一个需求是需要在SSR框架里引入无痛降级至SSG的能力。而css inline 在SSG场景下同样对FMP有优化的效果。SSG在构建时就已经是一个完整html了,也就是说我们只要把这份html里dom用到的css注入进来就行,有了明确的标的之后我们舍弃了之前在SSR场景中使用的css inline方案,选用 Critters,通过分析 SSG HTML 内容和 CSS 代码,将首屏需要的部分进行内联,和现有 SSR css inline 方案的区别是编译时的 DOM 已经确定,可以精准分析出对应样式内容,所以内联 CSS 利用率会变高,相应 HTML 会变小。
这套方案在SSG下是奏效的,并取得了不错的效果。
第二版方案index.html体积 | 第一版方案index.html体积 |
于是我们考虑能否在SSR场景下也使用这种方式。实验下来发现这个方案有一个很痛的问题是样式不全。根本原因还是构建时和运行时的dom不一致。比如某个组件无数据时是不会渲染的,所以没有就没有产出这个组件的样式。导致直出的html在客户端渲染后会有一些凌乱,而不是较完整的首屏样式。这样会导致用户看到凌乱的样式后再跳变成整齐的样式,显然我们不能为了追求明面上FMP数据的好看而舍弃了实际的用户体验(快手派:痴迷客户👍),所以这个方案不可行。
SSR第一版方案效果 | SSR使用critter方案效果 |
但是想拿到完整的用户数据只能在SSR运行时,而这种方案又不能放在SSR运行时去做,因为他会做选择器匹配的逻辑,这对运行时性能损耗很大,降低服务器性能的同时还隐藏着一些风险。
3、运行时找出使用到的组件的css
critter的方案不可行,我们把目光放回第一个方案。第一个方案会包含冗余的css的根本原因是bundle,于是我们想如果不bundle呢。当然在生产环境客户端访问的资源不打包是不可能的,反而可能带来性能劣化。但是在SSR运行时使用的资源可以是不打包的。于是便有了第三个方案:
这个方案的优势在于:
-
与打包方案解耦。如果打包方案有很多冗余则第一版方案会inline进很多冗余的css,而新方案不管打包方案如何都不会inline进冗余的css。
-
确保只打进用到的css。如果代码里写了clientOnly某些逻辑比如某种情况不会渲染那个静态导入的组件,那么打包后的文件里会有这个静态导入的组件的代码,那就会inline进了这个组件的css。 而新方案由于是ssr 运行时,且不打包,所以用到哪个组件就inline 哪个组件的css。比如
这种情况下如果GuideHand是静态导入的,在默认打包策略下则会被旧方案inline进去(因为这个组件会被打包进index.js),而新方案则不会inline这部分css。
当然这个方案还存在弊端:客户端运行时还需要再请求原css文件。因为critical是只包含了首屏用到的,还需要请求全量的css。目前没法在原打包出的css文件中删掉已经被inline的css,在ssr运行时去筛选出用到的css后再在构建产物里剔除,这样风险很高,可能影响了样式的优先级,而且服务端运行时做这个操作会增加耗时以及对服务器能承载的qps等都有很大风险。
既然理论分析是没问题的,那我们就实验验证一下吧:
第一版方案 | 第三版方案 | |
index.html体积 | 【购物金】150kb 【端外落地页】47.4kb 【c2c】159kb | 【购物金】56.9kb 【端外落地页】45.2kb 【c2c】159kb |
首屏效果 | 各实验项目的首屏效果新旧方案都一样,这里偷懒不贴图啦😝 | |
综合实验结果和三个项目的特点,该方案的适用场景如下:
-
默认打包方案情况下:
-
如果静态引入的都是ssr阶段会触发的组件,那么旧方案与新方案一致。比如 【c2c】
-
如果静态引入了很多ssr阶段不会触发的组件,旧方案会有很多冗余的css。比如 【端外】 的GuideHand组件等。
-
-
其他打包方案:
- 比如我们的common方案,两个以上入口使用的就抽出来放common里,所以common里有首页冗余的部分。这种情况下优化比较大,比如 【购物金】。
一句话概括就是 如果打包方案本身就能保证ssr运行时引用的bundle里都是使用到的部分,那第一版方案和第三版方案效果一致。但是我们知道保证bundle里只有真正使用到的部分往往不是一件易事,这也正是第三版方案的核心优势所在。
三种方案对比
第一版方案 | 第二版方案 | 第三版方案 | |
适用场景 | ssr | ssg | ssr |
html体积 | 大 | 小 | 小 |
运行时损耗 | 🈚️ | 🈚️ | 小(几乎可忽略) |
成果
最开始背景提到性能优化专项,其中一个指标是把端外落地页FMP p50做到一秒内。经过各种手段数据已经来到了1070ms ,就差临门一脚了。所以当时我们回看css inline第一版方案,觉得在这一步还能做一些功。最后通过新的方案成功干掉这个长尾,p50去到991ms,成功达成我们的目标。