0
0

再快一点,再快一点 —— 优化博客白屏时间的实践

skk 发表于 2020年10月03日 16:40 | Hits: 421
Tag: 技术向 | 前端性能优化 | CSS

两个多月以前,我写了一篇文章介绍我是如何优化我的博客的,但是我对于博客的白屏时间仍然不满意。过去一个月我在博客上进行了一系列优化实践,终于成功将博客的白屏时间减少了将近 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.4400000000605LCP 1921.934FID 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%,只能希望越来越多的浏览器能够提供对这两个属性的支持了。

再快一点,再快一点 —— 优化博客白屏时间的实践
本文作者
Sukka
发布于
2020-10-03
许可协议
转载或引用本文时请遵守许可协议,注明出处、不得用于商业用途!

原文链接: https://blog.skk.moe/post/improve-fcp-for-my-blog/

0     0

我要给这篇文章打分:

可以不填写评论, 而只是打分. 如果发表评论, 你可以给的分值是-5到+5, 否则, 你只能评-1, +1两种分数. 你的评论可能需要审核.

评价列表(0)