累积布局偏移(CLS)
1562 字 · 5 分钟#性能#Web Vitals
你是否经历过在网上阅读一篇文章,页面上的内容突然动了一下?文本毫无预警地移位,你找不到刚才读到哪了;或者更糟——你正要点一个链接,手指落下的瞬间,诶?链接跑了,你点到了别的东西。大多数时候这只是令人恼火,但在支付按钮旁边,它可能带来真正的破坏。
CLS(Cumulative Layout Shift,累积布局偏移)就是把这种「抖动」量化的指标。它测量的是视觉稳定性:量化用户经历意外布局偏移的频率,较低的 CLS 有助于确保页面是令人愉悦的。1
页面内容的意外移动,通常来自异步加载的资源,或者被动态添加到现有内容上方的 DOM 元素:未声明尺寸的图像或视频、渲染后比后备字体更大或更小的网络字体、动态调整自身大小的第三方广告或小组件。让问题更棘手的是,开发环境和真实用户环境的表现往往大不相同——测试图像早就躺在浏览器缓存里,本地 API 快到几乎没有延迟,于是这类问题在开发时常常根本看不见。
预期的偏移不算数
布局偏移只有在用户不期望它发生时才是坏事。
对用户交互(点击链接、按下按钮、在搜索框输入)做出响应的布局偏移通常没有问题——只要偏移发生的时机与交互足够接近,用户对因果关系一目了然。规范也是这么定义的:用户输入之后 500ms 内发生的偏移不计入 CLS。
动画和过渡同样是「预期」阵营的:让内容从一个位置自然地移动到下一个位置,反而能帮用户理解状态的变化。关键是用对属性——CSS transform 可以在不触发布局偏移的情况下做动画:用 transform: scale() 替代改变 height/width,用 transform: translate() 替代改变 top/left。
怎么测量
在代码中测量
JavaScript 里可以通过 Layout Instability API 测量 CLS。下面的示例创建一个 PerformanceObserver 侦听意外的 layout-shift 条目,按会话窗口分组,并记录最大会话值——这正是 CLS 的定义:取所有会话窗口中的最大值,其中相邻两次偏移间隔小于 1 秒、且窗口总时长不超过 5 秒的偏移算同一个会话。2
let clsValue = 0;
let clsEntries = [];
let sessionValue = 0;
let sessionEntries = [];
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
// 只统计不带最近用户输入标志的布局偏移
if (!entry.hadRecentInput) {
const firstSessionEntry = sessionEntries[0];
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
// 与上一条目间隔小于 1 秒、与会话首条目间隔小于 5 秒,
// 则并入当前会话;否则开启新会话
if (sessionValue &&
entry.startTime - lastSessionEntry.startTime < 1000 &&
entry.startTime - firstSessionEntry.startTime < 5000) {
sessionValue += entry.value;
sessionEntries.push(entry);
} else {
sessionValue = entry.value;
sessionEntries = [entry];
}
// 会话值超过当前 CLS 时更新
if (sessionValue > clsValue) {
clsValue = sessionValue;
clsEntries = sessionEntries;
console.log('CLS:', clsValue, clsEntries)
}
}
}
}).observe({type: 'layout-shift', buffered: true});不过自己处理所有边界情况相当繁琐:页面在后台加载时不应报告 CLS;从往返缓存(bfcache)恢复时应把 CLS 归零;iframe 里的偏移 API 不会上报但指标应当考虑;移动端浏览器往往不为后台标签页执行卸载回调,导致「最终值」难以确定……与其独自应付,不如直接使用 web-vitals 库,它替你处理了上述所有情况:
import { getCLS } from 'web-vitals';
getCLS(console.log);
在 DevTools 里调试
Performance 面板的 Experience 一栏会显示性能跟踪期间发生的所有布局偏移——包括发生在用户交互 500ms 内、不计入 CLS 的那些。悬停在某条 Layout Shift 上会高亮受影响的 DOM 元素。
点击 Layout Shift 再打开 Summary,可以看到更多信息:尺寸变化以 [width, height] 格式列出,位置变化以 [x, y] 格式列出,Had recent input 表明偏移是否发生在用户交互的 500ms 内。
偏移的持续时间可以在 Event Log 里看,也可以通过 Experience 栏里红色矩形的长度来近似判断——不过持续时间对偏移得分没有影响。
还有一个更直观的开关:Settings → More Tools → Rendering → Layout Shift Regions,开启后刷新页面,发生偏移的区域会被短暂地高亮成紫色。配合 Animations 面板慢放使用效果更佳。
排查思路
无论偏移是何时、如何发生的,都可以沿着同一条思路排查。Layout Shift 只可能由四种事件引起:DOM 元素的位置改变、尺寸改变、插入或删除,以及触发布局的动画。而紧挨着移位元素之前的那个 DOM 元素,是最有嫌疑的肇事者。所以调查时依次问三个问题:前一个元素的位置或尺寸变了吗?移位元素之前有插入或删除 DOM 吗?移位元素的位置被显式改变了吗?
偏移的幅度本身也是线索:大幅度的下移通常说明插入了一个 DOM 元素;1px、2px 的小幅移动,则往往是冲突的 CSS 样式,或者网络字体加载后的替换。
顺带一提,Lighthouse 也能识别布局偏移并给出建议(比如没有声明宽高的图像),但它只覆盖初始页面加载阶段,运行时的偏移还是要靠上面的手段。
怎么改进
对大多数网站来说,遵循几条原则就能避免所有的意外偏移:3
给图像和视频声明尺寸。始终在 img、video 元素上包含 width/height 属性,或者用 CSS aspect-ratio 预留空间,让浏览器在资源加载期间就能分配正确的空间:
img {
aspect-ratio: attr(width) / attr(height);
}
切勿在现有内容上方插入内容——除非是对用户交互的响应。这样能保证发生的任何偏移都在预期之内。
用 transform 做动画,而不是触发布局的属性。动画过渡的目标是提供状态与状态之间的上下文连续性,而不是惊吓。很多属性的变更会触发重排、绘制与合成,代价差别巨大,这部分展开可以看精读「回流与重绘」。
iframe、广告位这类动态嵌入内容要单独设防:在固定尺寸的容器中用新内容替换旧内容;让用户主动加载新内容(「加载更多」按钮),并在交互前预取好数据——别忘了交互后 500ms 内的偏移不计入 CLS;屏幕外的内容无缝加载,再用一个「向上滚动」之类的通知告诉用户内容已就绪。