再快一点,再快一点 —— 优化博客白屏时间的实践
两个多月以前,我写了一篇文章 介绍我是如何优化我的博客的,但是我对于博客的白屏时间仍然不满意。过去一个月我在博客上进行了一系列优化实践,终于成功将博客的白屏时间减少了将近 50%,这篇文章就来记录优化的过程和方案。
确定和分析白屏时间
First Paint 和 First Contentful Paint 是衡量白屏时间的重要指标。Google Chrome 团队提供了专门的库 web-vitals
用于在浏览器中衡量这些指标。直接在本地开发环境中引入该库:
<script type="module">
import {getFCP, getLCP, getFID} from 'https://unpkg.com/web-vitals@0.2.4/dist/web-vitals.es5.min.js?module';
// 获取 First Contentful Paint
getFCP(({ name, value }) => console.log(name, value));
// 获取 Largest Contentful Paint
getLCP(({ name, value }) => console.log(name, value));
// 获取 First Input Delay
getFID(({ name, value }) => console.log(name, value));
</script>
访问在本地运行的 Hexo Server 实例(http://localhost:4000
),打开任意一篇文章,然后在 Dev Tools 中切换到「Performance」Tab 中限制 Network 和 CPU 性能:
进行性能测试时,模拟移动端的网络和性能是非常重要的。然而,Firefox 的 Dev Tools 至今很遗憾地没有实现这个功能(许多类似的 Feature Request 在 Bugzilla 已经 stall 数个月了)。这也是为什么我钟情于使用 Chromium Based 的浏览器开发的原因。
刷新页面,Console 中会输出三个数值(单位均为毫秒):
FCP 1537.4400000000605
LCP 1921.934
FID 3.559999997378327
可以看到,First Contentful Paint 时间在 1.5 秒左右、而 Largest Contentful Paint(最大的可视元素,此时是文章的头图)是 1.9 秒。考虑到这是在本地环境、TTFB 只受模拟的「Fast 3G」限制,不难想象在实际访客体验中白屏时间绝对不止 1.5 秒。
分析性能瓶颈
肯定了问题的确存在,接下来就需要寻找性能瓶颈了。在「Performance」Tab 中将 CPU 性能修改为「6x slowdown」放大性能瓶颈,然后用「Start profiling and reload page」按钮刷新页面和获取火焰图:
其中,Layout 占据的时间(117.43ms)比 Parse HTML(22.48ms)和 Recalculate Style(20.37ms)都要长得多,基本可以认定这就是性能瓶颈了。接下来判断是页面什么元素导致了 Layout 的性能瓶颈。对博客中其它页面进行 Profiling,并将火焰图进行对比:
从左往右分别是 「我就感觉到快 —— zsh 和 oh my zsh 冷启动速度优化」、首页、「Hello World」页面的火焰图和 Layout 用时。
根据火焰图和三个页面的特征,猜测是文章内容部分导致了 Layout 用时过长。为了加以验证,在 CSS 中使用 display: none
将文章内容直接从 DOM 中离线,然后重新生成火焰图。
在页面渲染时,
display: none
的元素会直接从 DOM 中离线、不参加 Style 和 Layout。
将文章内容设置 display: none
后,Layout 性能直接提升了三倍,所以可以确认性能瓶颈就是文章内容的 Layout 了。
优化白屏时间
文章内容的 Layout 时间比较长,而文章内容在加载完之前不会触发 First Paint。所以如果需要缩短白屏时间,就必须缩短文章内容 Layout 的用时。
Layout 是浏览器计算元素几何信息的过程:元素的大小、在页面中的位置。Layout 性能一般和 DOM 元素数量、布局复杂性、布局模型有关。对于 DOM 元素数量这一点没有什么好的解决方案 —— 文章就这么长、每个段落就是一个 <p>
元素;对于文章内容也没有布局复杂性或布局模型可言。因此这是一条死路。
直接对着自己的博客动死脑筋是行不通的,我决定先和其他的内容网站的 Layout 性能对比一下:
上图左一为知乎专栏文章「PWA 在饿了么的实践经验」的火焰图;左二为 QuQuBlog「TLS 握手优化详解」的火焰图;左三为 dev.to 的「CSS Grid: illustrated introduction」的火焰图。
和其它内容网站比较发现,当页面包含较长篇幅的内容时,「CPU 6x slowdown」下 Layout 用时大抵在 100ms 到 200ms 左右。我的博客内容页面 Layout 用时在 120ms 属于正常范围、基本没有进一步优化的空间。
不过,我在看 dev.to 的火焰图时发现了一个很有趣的现象:虽然完整 DOM 的 Layout 用时在 123.70ms、但是却发生在 First Paint 和 First Contentful Paint 之后。
结合截图和火焰图可以发现,dev.to 在加载文章页面时,先只渲染 Navbar、触发 First Paint、结束白屏;之后继续 Parse HTML、渲染页面主体内容;最后是 Lazyload 后的文章头图、触发 Largest Contentful Paint。这种思路在 H5、小程序中都是很常见:使用 Placeholder (被称为 AppShell)缩短白屏时间、然后再通过 AJAX 获取数据填充到页面上。但是静态博客和小程序最大的区别就是不需要获取数据、文章内容是直接包含在 HTML 中返回的,所以在博客上实践这样的思路需要做一些改变。
我的做法则是将 CSS 拆分,将 Navbar 和右下角 Fab 按钮的 CSS 提取出来、内联在 HTML 中、当页面加载时就可以 Style & Layout。同时为页面主题内容添加 display: none
使其在 DOM 中离线,使其不影响 First Paint;页面主体内容的 CSS(包括 display: block
) 拆分成独立的 CSS。由于 CSS 是「渲染阻塞(Render Blocking)」的资源,浏览器在 Parse HTML 时如果遇到 CSS 就会开始请求、并在 CSS 下载完成之前不会开始 Style & Layout。因此,需要一个小 trick 实现异步加载 CSS(使 CSS 不再阻塞渲染):
<link rel="stylesheet" href="defer.css" media="print" onload="this.media='all';this.onload=null">
<noscript><link rel="stylesheet" href="defer.css"></noscript>
带有 [media=print]
属性的 CSS 仍然会以低优先级加载,但并不会直接参与 Style & Layout、因此不会阻塞渲染。当 CSS 文件下载后触发 onload
事件、将 media
属性改为 all
、使 CSS 在当前页面生效。
为了使白屏不显得枯燥,我还加了一个「加载中」的闪烁动画,使用 animation-delay
延迟 0.6 秒显示。
不过使用这种方案需要注意两个问题。一是当页面内容被 display: none
后、页面的高度会小于 viewport、因此浏览器不会展示滚动条;当页面内容被覆盖为 display: block
后、浏览器会重新展示滚动条、导致抖动,因此需要为 <html>
添加 overflow-y: scroll
。另一个问题是新的 CSS 生效时会触发新的 Style & Layout、可能会导致已经渲染过的 Navbar 和 Fab 按钮被再次 Layout,造成性能浪费;解决方案是使用「CSS Containment」草案中引入的 contain
属性,通过在 CSS 中显式声明当前元素及其后代与 DOM 的关系,当浏览器重新计算样式和布局时只会影响有限的 DOM。截至本文写就,Edge(Chromium Based)、Firefox、Chrome 都已经对 contain
属性提供了支持。关于 CSS Containment 的用法,可以参考 MDN 上对 contain 的说明。
同时,如果使用异步加载 CSS,那么页面主体内容的显示时机就会受到两个因素制约 —— 除 Style & Layout 外、还有 CSS 的加载。为了尽可能消除 CSS 加载对文章内容显示的影响,我为 CSS 设置了 HTTP/2 Push,这样 CSS 能够和 HTML 同时到达浏览器、但不会马上参与 First Paint 的 Style & Layout。关于 HTTP/2 Push 的更多细节,可以参考我的文章「静态资源递送优化:HTTP/2 和 Server Push」。
实践的效果妙不可言:First Paint 之前的 Style & Layout 用时加起来也不超过 50ms、几乎 HTML 一下载完就可以看到 Navbar。当 defer.css
加载完、样式和布局计算完后文章内容即绘制到屏幕上。如果 defer.css
出于某种原因没有及时加载(如 User-Agent 不支持 HTTP/2 Push、defer.css
未能命中缓存),那么「加载中...」就会展示出来,使访客不会认为页面失去响应。
尝试新属性
虽然减少了白屏时间,但是长篇幅的内容的布局计算仍然非常耗时;当文章越来越长时,用户仍然可能会对「加载中」失去耐心。不过 Chromium 85 开始对一些 CSS Containment 草案中的 CSS 属性(如 content-visibility
)提供支持。当一个元素被声明 content-visibility
属性后,如果这个元素不在 viewport 中、浏览器就不会计算其后代元素样式和属性,从而大幅节省 Style & Layout 耗时。目前,仅 Chrome/Chromium 85 提供对该属性的支持(没错,Firefox 把这个 Feature 也扔进「值得一试」里了)。更多关于 content-visibility
的介绍可以查看 web.dev 上的相关文章。
使用 content-visibility
属性需要将页面内容分块。于是我写了一个 Hexo 插件,在文章内容渲染时将每两个 <h2>
之间的内容分为一块、用 <div class="story">
包裹起来。然后为 .story
声明 content-visibility: auto
。
需要注意的是,content-visibility
绕过的是不在当前 viewport 的元素的后代元素的样式和布局、只保留一个元素盒子。如果没有显式声明元素的高度的话那么这个元素的高度就是 0 了。虽然 Chrome/Chromium 在实现 content-visibility
时会试图避免 Curative Layout Shift(在元素即将进入 viewport 时就开始渲染),但是滚动条的高度会发生改变。所以「CSS Containment」草案中还提出了一个新属性 contain-intrinsic-size
、用于声明一个「元素盒子」的高度。这个属性不影响渲染后元素的实际尺寸,实际使用时只需要预估高度即可:
.story {
content-visibility: auto;
contain-intrinsic-size: 1000px; // 不靠谱地取个 1000px
}
content-visibility
除了可以改善 Layout 性能外,值得一提的还有其另一个取值hidden
。众所周知display: none
会使元素「离线」,元素会从 DOM 中消失、同时渲染状态也会随之消失;而visibility: hidden
只是会隐藏元素、而元素本身依然保留在 DOM 中,渲染状态也保留。而content-visibility: hidden
则介于两者之间,元素会从 DOM 中消失、但是保留渲染属性。
利用 content-visibility
和 contain-intrinsic-size
后,文章的 Layout 时间从 120ms 减少到了 70ms、减少了将近 40%,只能希望越来越多的浏览器能够提供对这两个属性的支持了。