优化博客的累计布局偏移(CLS)问题

优化博客的累计布局偏移(CLS)问题

技术向约 1.5 千字

距离上一篇文章发布已有四个月了,是时候写几篇文章给博客除草了。上一次我介绍了我如何迁移、重构了我的博客的架构,这次我想来谈谈我在重构中优化和打磨访客体验时解决的一个问题。

我的博客优化之旅

问题起源

耗时两周、在用 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:

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 的问题马上解决了:

优化博客的累计布局偏移(CLS)问题
本文作者
Sukka
发布于
2022-06-08
许可协议
转载或引用本文时请遵守许可协议,注明出处、不得用于商业用途!
如果你喜欢我的文章,或者我的文章有帮到你,可以考虑一下打赏作者
评论加载中...