来源:之家技术
1. CLS 诞生背景
2. CLS 定义
CLS (Cumulative Layout Shift) 累计布局偏移指标,通过度量“视觉稳定性”反映用户对页面布局偏移所产生的主观视觉体验。
Google 定义每隔 5 秒作为一个 CLS 监听"时间窗口",并且要求在每个"时间窗口"内,相邻两次偏移的时间间隔小于 1 秒。在单个"时间窗口"内,CLS 值是由多个元素的偏移值累加得到的。如果页面中的元素布局持续发生偏移(持续时间大于 5 秒),则可能会形成多个"时间窗口",每个"时间窗口"的 CLS 值可能不同。在这种情况下,将最大"时间窗口"的 CLS 值作为该页面的最终 CLS 值。
如下图:CLS = Max(session window 1,session window 2,session window 3) = 0.105

2.1
如何对 CLS 评级?
谷歌定义页面 CLS 数值应该控制在 0.1 以内,包括移动和桌面设备。为了确保大多数用户达成目标,一个良好的测量阈值为第 75 个百分位数。

3. CLS 如何计算
3.1
不稳定性元素
任何位于可视区域内的可见元素,如果其起始位置在两帧之间发生变化,则被视为“不稳定元素”。这些“不稳定元素”用于计算布局偏移。需要注意的是,如果新元素添加到 DOM 或现有元素的尺寸发生改变,只要这些变化不导致其他可见元素的起始位置发生变化,它们就不会被计算为布局偏移。
3.2
计算公式
布局偏移分数 = 影响分数(impact fraction) * 距离分数 (distance fraction)
举例 : 0.07 = 0.5 * 0.14
► 影响分数
该计算因子的含义是测量“不稳定元素”对两帧之间的可视区域产生的影响,即可视元素的起始位置在两帧之间发生变化后,两帧中元素可视区域的合集占总可视区域的百分比。
如下图所示:

上图中黄色块 P 标签为“不稳定元素”,其自身元素占可视区域的 50%,在两帧中移动了可视区域高度的 25%,红色虚线表示两帧中元素的可见区域集合,该集合占总可视区域的 75%,因此在本例中影响分数为 0.75。
► 距离分数
该计算因子的含义是,测量不稳定元素相对于可视区域在一帧中位移的最大距离(该位移距离可以是水平或者垂直方向,同时存在时取最大者),除以可视区域的最大尺寸维度(尺寸维度可以是宽度或高度,取较大者)。如下图所示:

上图中最大可视区域尺寸维度是高度,不稳定元素位移的距离为可视区域高度的 25%,因此距离分数为 0.25 。
根据公式:布局偏移分数 = 影响分数 * 距离分数,以上图为例 CLS 值为 0.75(影响分数)* 0.25(距离分数)= 0.1875 。
3.3
举个例子

如上图所示,点击 “Click Me!” 按钮时绿色框部分为不稳定元素,发生了向下的位移,其影响范围为红色虚线框部分(底部超出可视区域不做计算),占总可视区域的 50%,即影响分数为 0.5 。
距离分数由紫色箭头表示,在高度尺寸维度发生了位移,其位移距离为可视区域高度的 14%,即距离分数为 0.14 。
所以布局偏移分数是 0.5 * 0.14 = 0.07 。
3.4
符合预期的布局偏移
布局偏移并不总是坏事。(布局偏移只有在用户并不期望其发生时才算是坏事), 以下两种情况, 不会影响 CLS 分数。
(1)由用户发起的布局偏移
用户交互(如单击链接、点选按钮、在搜索框中键入信息等), 500毫秒之后发生的 CLS 都会带有"hadRecentInput"标记, 不会影响 CLS 分数 。
(2)动画和过渡
动画和过渡如果做得好,确实是一个在更新页面内容时不让用户感到突兀的好方法。CSS transform 属性可以帮助我们在不触发布局偏移的情况下为元素设置动画:
用 transform: scale() 来替代和调整 height 和 width 属性。
如需使元素能够四处移动,可以用 transform: translate() 来替代和调整 top、right、bottom 或 left 属性。
4. CLS 测量
在上部分中对 CLS 的基础理论进行了介绍,并通过相关示例演示了 CLS 值是如何计算的,接下来我们看一下在浏览器中如何通过工具对 CLS 进行跟踪测量。
4.1
DevTools
打开 Chrome DevTools,在 Performance 标签选项卡中点击“录制”按钮并刷新页面,您将得到 CLS 的跟踪信息,大部分情况下您可以通过该功能还原定位线上 CLS 问题。
如下图:

点击不同的“红色块”可以查看其对应的 CLS 值及 DOM元素和相关偏移信息。如下图:

4.2
通过 JavaScript 测量 CLS
我们可以使用 JavaScript 和浏览器原生的 PerformanceObserver 来测量 CLS,通过监听 layout-shift 条目,并根据每 5 秒为一个窗口期的最大分数累计规则,对这些监听到的 layout-shift 条目数据进行计算。
实现代码如下:
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 值,
// 那么更新 CLS 及其相关条目。
if (sessionValue > clsValue) {
clsValue = sessionValue;
clsEntries = sessionEntries;
// 将更新值(及其条目)记录在控制台中。
console.log('CLS:', clsValue, clsEntries)
}
}
}
}).observe({type: 'layout-shift', buffered: true});
4.3
web-vitals Javascript 库
GoogleChrome 提供了 web-vitals JavaScript 开源库,用于测量用户浏览器端真实性能,除 CLS 指标外还包括 FID(First Input Delay)、TTFB(Time to First Byte)、FCP(First Contentful Paint)、LCP(Largest Contentful Paint)、INP(Interaction to next Paint)。代码示例:
import { onCLS } from 'web-vitals';
onCLS((metric) => {
console.log(metric);
});
5. CLS 采集与上报
通过使用 web-vitals JavaScript 库,我们可以便捷地获取页面的 CLS 和其他性能指标,这在很大程度上简化了数据测量工作。接下来,为了收集用户页面的真实 CLS 性能数据,我们可以设计和开发一套数据采集 sdk,将采集到的数据上报至服务器端进行存储。随后,通过后端大数据分析展示页面的整体 CLS 性能水平,从而为开发人员提供有针对性的优化建议。
5.1
采集、上报时机
在数据采集和上报过程中,我们应确保数据的准确性和完整性,同时也要充分考虑对采集上报时机的设置,这样可以确保对整体数据统计评分的公平性。
CLS 的"时间窗口"为 5 秒,因此在 sdk 初始化后的第 5 秒,我们将页面性能数据进行数据采集与上报。然而,sdk 作为一个独立的 JS 文件,可以在页面中通过同步或异步加载。特别是在异步加载方式下,如果延迟 5 秒后再进行采集上报,很可能已超过第一个 CLS "时间窗口"。这样的数据采集可能会影响最终统计评分的公平性,其影响主要体现在以下两个方面:
► 大于 5 秒上报
受用户停留页面的时间因素影响,时间越靠后用户数据丢失的概率就越大。

如上图所示,采集工具 js(红框内) 下载时机在第 3.3 秒开始下载,如果在此基础上再延迟 5 秒后采集上报数据,真实的采集时间为 8.3 秒,假如用户停留时长小于 8.3 秒,此情况下若不采取措施该采样数据将会遗漏掉。
► 小于 5 秒上报
相反,如果将延时时间设定为小于 5 秒(如参考 Sentry@7.64.0 的默认设定时间 1 秒),所得的测量值准确性将得不到保证。为此我们用 web-vitals 做了一次数据分析对比实验,在日均 pv 约 10 万左右的网站上持续 3 天对 CLS 数据进行测量,并分别设定在 5 秒和 1 秒后采集和上报。对比结果发现 1 秒上报其各项指标数据丢失率非常大,且数据值偏小,无法保证其准确性。
如下图所示:

► 最优方案
为了解决 sdk 初始化时间大于或小于第一个 CLS "时间窗口"所产生的影响,我们通过研究 web-vitals 源码发现 CLS 以及其他性能指标以 PerformanceNavigationTiming 的 "startTime" 为相对起点。因此,可以将 sdk 初始化时间与该 "startTime" 相减。如果两者的差值大于 5 秒,则可以立即进行数据采集与上报;反之,可以将距离 5 秒的差值作为延迟等待时间。
PerformanceNavigationTiming 模型如下图所示:

► 页面关闭时采集上报
当用户停留时长不足 5 秒提前关闭页面时,可能导致数据采集丢失。为了解决这个问题,我们可以通过监听 visibilitychange、pageHide 事件来采集页面关闭前的 CLS 数据。这种方案尽可能地确保数据不丢失,但无法保证第一个"时间窗口"的 5 秒准确度。
因此,该方案所采集上报的数据只是尽可能的反映页面真实采样 PV,并不参与最终的统计评分。
5.2
采集流程设计
因为 CLS 值的获取是一个持续进行的过程,在监听 DOM 布局偏移过程中,数据对象通过异步回调方式传递。这个持续动作可能会贯穿整个页面生命周期。由于获取到的数据需要上报到服务器端,不断的进行数据上报将对后端服务带来巨大压力。因此,需要设计一个合理的采集流程以避免上述问题。
采集流程如下图所示:

1) sdk 初始化时已满足 5 秒,则立即执行 CLS 数据采集并上报,反之延迟至 5 秒再完成采集上报。
2)用户停留时长不足 5 秒时页面关闭,监听 visibilitychange、pageHide 事件完成采集,并通过 sendBeacon 方式上报。
3) 整个页面生命周期中只允许一次采集、上报。
5.3
数据上报
在上面流程设计图中我们设计了两种上报方式,sdk 初始化后 5 秒上报采用 XmlHttpRequest,页面隐藏或关闭采用 navigator.sendBeacon。
两种方式详细对比见下图:

5.4
CLS 元素辅助定位
在发现网站页面的 CLS 值较高时,我们需要使用开发的 sdk 协助开发人员将 CLS 值与其 DOM 元素关联起来,以便确定页面中累计偏移较大的元素。Web-vitals v3.0.0 及更高版本中新增了 attribution 调试功能,能够将 DOM 元素与 CLS 值关联并输出。然而,这导致了包体积增加了 7K,并且输出内容格式固定,无法实现定制化。
为了定位导致 CLS 值过大的元素,同时防止 js 库体积变大,我们对 web-vitals 中 onCLS 返回的 layout-shift 条目对象进行自定义解析,并对 DOM 元素的 “domPath”路径输出,既帮助开发人员快速定位、修复问题,又缩小了 js 库体积。
如图:

6. 优化实践
利用 sdk 我们已收集取到了页面真实数据,下面通过一个真实案例分享我们是如何进行 CLS 优化的。
6.1
页面结构介绍
为了便于大家对页面结构有清晰的了解,我们在优化之前对页面结构按功能做了如下划分。
1) 文章内容区(Vue 渲染)。
2) 广告部分(之家广告 js 加载)。
3) 页头页尾(公共 js 加载)。
4) 其它(各类第三方 js 库,如统计、埋点、前端各类组件等)。
如图所示:

6.2
页面逻辑说明
本项目采用 SSR + Vue 实现前端页面渲染,在功能职责上 SSR 仅提供一个空页面模版,其主要内容的渲染工作由 Vue 承担,广告位的渲染部分被包含其中,广告位渲染链路为:
第一步,渲染文章内容部分,由 Vue 绑定数据异步实现。
第二步,判断有无广告位,先由 ajax 异步请求 isHave 接口,判断是否存在广告位,如存在则动态下载广告位 js 及相关资源(图片、样式文件)。
第三步,渲染广告位, 广告 js 及资源文件下载完成后,动态插入 DOM 并完成渲染。
6.3
页面问题说明
Vue 渲染在广告位 js 及相关资源的加载之后,且广告位资源的下载需要等待 isHave 异步请求返回后,导致广告内容的渲染过程链路被拉长。其次广告位内容尺寸未知,因此页面在渲染过程中页面会因广告位的动态加载渲染出现一次“抖动”现象,最终导致页面的 CLS 值过大。
下图动画演示了广告出现的整个渲染链路:
6.4
优化措施及内容

6.5
数据评分效果

7. 总结
https://web.dev/optimize-cls/