优化博客的累计布局偏移(CLS)问题
距离上一篇文章发布已有四个月了,是时候写几篇文章给博客除草了。上一次我介绍了我如何迁移、重构了我的博客的架构,这次我想来谈谈我在重构中优化和打磨访客体验时解决的一个问题。
我的博客优化之旅
- 图片 lazyload 的学问和在 Hexo 上的最佳实践
- 我的博客有多快?
- 天下武功,唯快不破 —— 我是这样优化博客的
- 再快一点,再快一点 —— 优化博客白屏时间的实践
- 使用 Next.js + Hexo 重构我的博客
- 优化博客上累计布局偏移(CLS)问题 你在这里!
问题起源
耗时两周、在用 React 重写了博客主题、将博客迁移到 Next.js + Hexo 架构、还写了一篇两万五千字的文章以后,我心满意足地打开自己新发布的文章「使用 Next.js + Hexo 重构我的博客」预览效果,结果,我心里一沉:这看起来好像并不对头。
从上述视频中可以清楚地发现非常严重的 CLS 问题。
什么是 CLS?
当用户浏览一个页面的时候,若是想要点击一个按钮或者其他交互时,页面的布局突然出然抖动,可以会造成用户的交互行为造成期望之外的结果。大多数情况下,这些体验只是令人恼火——用户不得不返回上一个页面,但在某些情况下,后果有可能非常严重:
Google 针对这种页面布局抖动,提供了一系列计算公式,用于衡量和标准化一个页面的抖动对用户体验的影响,这就是 CLS(Cumulative Layout Shift,累计布局偏移)。CLS 是 Google 衡量网站用户体验的指标 Web Vitals 之一。
关于 CLS 的衡量,Google 在 web.dev 上有一篇专门的文章,介绍了 CLS 的计算公式和测试方法。
分析页面布局抖动的原因
我的博客主题的布局是一个典型的响应式三栏「双飞翼」布局,桌面端为三栏布局,在平板上为两栏,移动端为单栏。在实现时,使用了 CSS Grid,通过 CSS 和 media 查询、为不同的屏幕宽度设置对应的 grid-template-columns
,实现响应式设计。
由于在移动端上优先展示主要内容,因此侧边栏的 markup 位于主要内容的后面;而在更大的屏幕上,则通过设置 CSS order
的方式进行排序,将主要内容移到中间(即第二列),伪代码如下:
export default function MainLayout(props) {
return (
<Container>
<Main className={css`@media screen and (min-width: breakpoint) { order: 0 }`} />
<Left className={css`@media screen and (min-width: breakpoint) { order: -1 }`} />
<Right className={css`@media screen and (min-width: breakpoint) { order: 1 }`} />
</Container>
)
}
通过视频可以发现,页面布局偏移的原因是浏览器在第一次绘制时,主要内容被绘制到了第一列,第二次绘制时左侧边栏才将主要内容「挤到了」第二列,因此导致了 CLS 问题。
这个原因听起来就非常诡异。我已经将 Critical CSS(浏览器首次绘制所需的关键 CSS)全部内联在 HTML 的 <head>
中,因此浏览器解析生成 CSSOM 不会比 DOM 晚、浏览器已经知道 CSS 中声明的 order
。因此唯一的可能,是浏览器在首次绘制时并没有完整解析 DOM、只知道 <Main />
的存在、但不知道 <Left />
或者 <Right />
的存在,才因此将 <Main />
渲染进第一列而不是第二列;直到第二次绘制时,浏览器才将 <Main />
渲染进第二列、将 <Left />
渲染进第一列。
网络上关于浏览器这一行为的描述寥寥无几,不过我还是成功地在 StackOverflow 上找到了一篇相关讨论 Cumulative Layout Shift with Bootstrap 4 grid,其中提到了一个 Chrome 的 Quirk。Chrome 并不是一次完整解析 HTML 的,在以下两种情况下,Chrome 会暂停解析、开始渲染和绘制:
- Chrome 解析器在读取了 65535 字节的 HTML 后暂停
- Chrome 在遇到
<script>
标签后,会继续读取约 50 个「Token」之后暂停
关于 Chrome 解析器的这一 Quirk,在 Chromium Bugtracker 中可以找到两个 Issue:
- Issue 1041006: The parser yielding heuristics should be revisited
- Issue 1130290: Parser yields after 65536 bytes, causing CLS
Chromium 在 commit c11809b8 中修复了这两个问题,启用了一个 flag ForceSynchronousHTMLParsing
,这个改动从 Chrome 96 开始生效,但是显然并没有解决这个问题。于是我在 Chromium Bugtracker 提交了一个新的 Issue:
这个 Issue 被 Chromium 团队标记为「Working as Intended」和「Won't Fix」。
修复页面布局抖动
Chormium 团队并没有修复这个 Issue,不过给出了一个 Workaround:将需要渲染到第一列元素的 markup 放置在 HTML 靠前的位置,使 Chrome 能够提前发现需要绘制在第一列的 DOM:
export default function MainLayout(props) {
return (
<Container>
{/* 将 <Left /> 放置在 <Main /> 前面 */}
<Left />
<Main />
<Right />
</Container>
)
}
但是由于我的响应式布局需要同时考虑移动端和桌面端的体验,因此我不得不将 <Main />
的 markup 放置在最前面。不过,我还是实现了一个 workaround,在 <Main />
前面插入一个 100% 宽度 0 高度的空 div 元素:
import { memo } from 'react';
const styles = stylex.create({
main: {
display: 'block',
gridColumnStart: 1,
gridColumnEnd: 1,
order: '-1',
minWidth: 0,
height: 0,
maxHeight: 0
}
});
const LeftHolder = memo(() => <div className={styles('main')} />);
export default function MainLayout(props) {
return (
<Container>
{/* 将 <LeftHolder /> 放置在 <Main /> 前面 */}
<LeftHolder />
<Main />
{/* 将 <Left /> 放置在 <Main /> 后面 */}
<Left />
<Right />
</Container>
)
}
workaround 的效果可谓是立竿见影,CLS 的问题马上解决了: