累积布局偏移是怎么发生的:CLS 优化的具体手段

商品详情页有个反馈反复出现:"刚要点收藏,页面自己跳了一下,点到了别的地方。" 这不是某一个具体 bug,而是同一类现象在不同页面反复发生——图片加载完、字体换了、异步内容插进来,都会让页面在用户毫无预期的情况下挪动位置。这类"意外布局偏移"这几年一直存在,只是这一年被 Core Web Vitals 里的 CLS 指标第一次量化成了一个具体分数,团队才第一次认真坐下来把它的成因和治理办法过了一遍。

CLS 全称 Cumulative Layout Shift,中文一般译作"累积布局偏移",它量化的是页面生命周期内所有意外布局偏移的严重程度之和。这里"意外"两个字是关键——用户主动触发的偏移(点了个按钮,页面因此变化)不计入,只有那种用户没做任何操作、内容却自己动了位置的情况才算数。计算公式是 impact fraction(受影响区域占视口比例)乘以 distance fraction(位移距离占视口比例),每次不稳定的偏移都会产生一个分数,页面存活期内所有分数累加就是 CLS。这套算法比"重排"这个笼统的性能概念要精确得多:不是所有触发 Layout 的操作都会拉高 CLS,只有那种发生了"可见内容位移"的重排才算,一次纯粹的样式计算(比如 display: none 之后重新 display: block 但位置没变化)并不会计分。

impact fraction 和 distance fraction 这两个分量值得拆开说清楚,不然容易把 CLS 和"偏移了多少像素"简单划等号。假设视口高度是 800px,一个原本占据视口上半部分、高度 300px 的区块因为上方广告加载完成被推下去 200px,那么受影响区域是"偏移前"和"偏移后"两个矩形的并集占视口的比例——这里并集覆盖了视口的 500px 高度,impact fraction 就是 500/800,约等于 0.625;而 distance fraction 是位移距离占视口最大维度的比例,200/800 等于 0.25。两者相乘,这一次偏移的分数是 0.625 乘 0.25,约等于 0.156。谷歌给出的经验阈值是单页 CLS 低于 0.1 算"良好",这么一算就能直观感受到,一次这个量级的偏移就足够让分数逼近及格线,如果页面里再叠加两三次类似的跳动,很容易就超标。这也是为什么这个指标叫"累积"——它不管你是一次性跳了一大截还是分好几次小步跳,只要发生了就往上加,不会因为拆成几次发生就变得"更可以接受"。

没留位置的图片,是 CLS 最常见的来源

商品详情页有张主图,原本 HTML 是这么写的:

1<img src="/product/12345.jpg" alt="商品主图" />

图片资源没下载完之前,这个 <img> 标签在文档流里不占任何高度(或者只占默认的几个像素),周围的文字和按钮会先顶上来。等图片请求回来,浏览器拿到真实宽高,把 <img> 撑开,下面所有内容再被推挤一次。用户刷着刷着页面,内容忽然往下跳,手指点下去点到的可能已经不是原来那个按钮了——这是 CLS 里最典型也最容易被用户实际感知到的场景。

修法很直接,在图片还没加载完之前就把它该占的空间留出来:

1<img src="/product/12345.jpg" alt="商品主图" width="600" height="400" />

这里有个容易被误解的点:很多人以为写死 width/height 会让图片在不同屏宽下无法自适应,其实不会。现代浏览器会用这两个属性算出宽高比,再结合 CSS 里的 width: 100%; height: auto; 这类自适应写法,提前预留一个正确比例的空间占位,图片加载完之后直接嵌进这个占位框,不会二次推挤。这背后的浏览器行为叫 aspect ratio(纵横比)推导,原理是:只要同时知道 widthheight 属性(哪怕后面 CSS 会把实际尺寸改掉),浏览器就能算出比例,先用这个比例撑开一个占位盒子。

如果图片来源不固定、没法提前知道宽高(比如用户上传的头像、CMS 配的运营图),更通用的办法是用 CSS 的 aspect-ratio 属性直接声明容器比例:

1.avatar-wrap {
2  aspect-ratio: 1 / 1;
3  width: 80px;
4}

aspect-ratio 是 CSS 里今年才逐步铺开的一个新属性,原理和图片的宽高推导类似,提前告诉浏览器"这个盒子无论内容如何,都保持 1:1",内容加载进来只是填充,不会改变盒子尺寸,自然不会引发偏移。目前 Chrome 88 起已经支持,Safari 和 Firefox 还在跟进,用之前最好搭配一个兜底方案——比如给容器一个固定的 padding-top 百分比撑高度,这是 aspect-ratio 出现之前业内通用的"百分比内边距撑比例"技巧,老浏览器也认。

图片懒加载和 CLS 之间还有个容易被忽略的交互关系。团队长列表页早就上了 loading="lazy",这看着是纯粹的带宽优化,实际和布局偏移关联很紧密——懒加载图片如果没写尺寸属性,滚动到它进入视口触发加载的那一刻,恰好是用户视线正对着它的时候,这时候发生偏移,比首屏图片没写尺寸更容易被用户察觉,因为首屏的偏移常常发生在用户还没来得及细看页面的一瞬间,而懒加载图片的偏移发生在用户已经开始阅读、视线焦点正落在附近内容上的时候。这也是为什么懒加载和尺寸预留必须绑定着一起做,不能只加了 loading="lazy" 就觉得万事大吉:

1<img
2  src="/list/thumb-2088.jpg"
3  loading="lazy"
4  width="240"
5  height="160"
6  alt="商品缩略图"
7/>

另外浏览器原生的 loading="lazy" 在判断"什么时候算进入视口附近该开始加载"这件事上,各浏览器的预加载距离(通常叫 lazy load distance threshold)并不统一,取决于网络状况自动调整,快网络下这个距离会缩短,意味着图片经常是"刚进视口边缘就已经开始下载",尺寸预留没做好的话,用户几乎是在滚动的同时看着图片撑开页面,体验比想象中还要明显。

响应式图片:同一张图不同尺寸下,占位比例得跟着变

商品列表页这几年陆续把图片换成了 srcset/sizes 这套响应式图片写法,让浏览器根据视口宽度和屏幕像素密度自己挑一张最合适的图,省流量也省解码时间:

1<img
2  src="/list/thumb-400.jpg"
3  srcset="/list/thumb-400.jpg 400w, /list/thumb-800.jpg 800w, /list/thumb-1200.jpg 1200w"
4  sizes="(max-width: 600px) 100vw, 400px"
5  width="400"
6  height="300"
7  alt="商品缩略图"
8/>

这里容易被忽略的一点是,width/height 这两个属性此时描述的其实是图片的"固有宽高比",而不是某个具体尺寸下的实际像素值。浏览器计算占位框时,用的是这两个属性算出来的宽高比,再结合 CSS 里实际生效的宽度反推出占位高度——只要 srcset 里列出的这几张图裁切比例保持一致(都是 4:3,只是分辨率不同),这套占位逻辑就是稳的;但如果图片源本身来自不同尺寸的原图裁切,比如移动端用的是方形裁切、桌面端用的是横向裁切,width/height 只能描述其中一种比例,另一种视口下占位框和实际图片比例对不上,一样会在图片加载完成后产生偏移,只是幅度比完全不写尺寸小一些。团队后来在切图规范里明确要求同一张图的多档 srcset 必须保持相同裁切比例,不同版式(比如移动端方图、桌面端横图)要当成两张完全独立的图片、各自声明各自的比例,不要试图用同一组 width/height 属性去覆盖两种版式。

视频的封面帧(poster)同样存在这个问题。<video> 元素在 poster 图片和视频本身完成首帧解码之前,如果没有显式的宽高声明,也会经历一次从"默认尺寸"到"实际尺寸"的跳变,处理思路和图片完全一致,该写 width/height 或者用 aspect-ratio 包一层容器,没有例外。

广告位和推荐位:占位骨架比"loading 文字"更管用

除了图片,另一个重灾区是异步加载的模块——广告位、个性化推荐位、评论区这类需要额外请求才能渲染出内容的区块。团队后台首页有个"猜你喜欢"模块,数据要等一个单独接口返回,原来的写法是接口没回来之前这块 DOM 压根不存在,回来了才插入,于是首页在加载完一两秒后忽然多出一大块内容,把下面的footer 硬生生往下推了几百像素。

正确的处理思路不是让这块内容"晚一点出现",而是让它对应的空间提前占好。给这个区块一个和最终渲染结果高度接近的占位容器:

1<div class="rec-section" style="min-height: 320px;">
2  <div class="rec-skeleton" v-if="loading"></div>
3  <div class="rec-list" v-else>
4    <!-- 真实推荐内容 -->
5  </div>
6</div>

这里 min-height 是关键,它保证了这个区块在数据没回来之前也占着一个大致正确的高度,内容填进来之后哪怕高度有细微出入,偏移量也很小。骨架屏(skeleton)本身其实是个附带的视觉优化,核心作用还是这个占位——很多团队做骨架屏只关注"好看""有加载感",反而忽略了它对 CLS 最直接的价值就是防止推挤。

广告位场景更麻烦一点,因为广告尺寸往往由第三方 SDK 决定,业务方自己都不确定最终渲染出来是多高。这种情况下通用做法是提前按广告位规格(比如常见的 300×250、728×90 这些标准尺寸)预留固定容器,SDK 无论内部怎么渲染,都被约束在这个尺寸里,超出部分交给 overflow: hidden 兜底,而不是让容器随内容自由伸缩。

第三方 <iframe> 嵌入(广告、地图、内嵌表单这类)还有个更棘手的地方——第三方脚本内部什么时候改变 iframe 高度、改成多少,业务方完全不可控,唯一能做的就是给 iframe 本身套一层受控容器,用固定尺寸或者 aspect-ratio 约束外层,iframe 内部的变化被裁在容器内,不传导到外部布局。如果第三方明确提供了"自适应高度"的回调或者 postMessage 通信机制,更稳妥的做法是监听这个消息,拿到实际高度后用过渡动画平滑地调整容器高度,而不是让它突变——过渡本身依然会占用一小段时间发生偏移,但因为是渐变而不是突变,视觉上的冲击会小很多,虽然从 CLS 计分的角度看,只要发生了偏移分数还是会累加,平滑与否不影响算分,只影响用户的主观感受。

评论区这类由用户生成内容(UGC)驱动、条数本身不确定的模块,预留固定高度的思路就不太适用了——评论可能是 0 条也可能是几十条,没有一个"合理高度"可言。这类场景更实际的做法是把"评论总数"这个轻量级的数据提前和页面主体一起返回(比如后端渲染时把评论条数放进初始 HTML 的一个数据属性里),前端先根据条数渲染出对应数量的骨架占位行,再异步替换成真实内容——异步替换阶段,由于骨架占位的行数和数量已经对上,内容填充进具体某一行时不会引发行数变化,顶多是这一行内部文字从骨架灰块变成真实文字,不会推挤下面的整个列表。

字体切换引起的文字闪烁和二次排版

另一类不太显眼但真实存在的偏移来自 Web 字体加载。团队后台从这年开始在几个对外展示页用了自定义字号的中文字体,加载策略原本没细想,直接用了最简单的 @font-face:

1@font-face {
2  font-family: "BrandFont";
3  src: url("/fonts/brand.woff2") format("woff2");
4}
5body {
6  font-family: "BrandFont", sans-serif;
7}

浏览器处理自定义字体有几种默认策略,不同浏览器早期的默认行为不完全一致,但这一年主流浏览器基本收敛到两种模式:一种是先用系统字体把文字画出来,字体文件下载完之后换成目标字体(这个过程叫 FOUT,Flash of Unstyled Text);另一种是先把文字隐藏,等字体下载完才显示(FOIT,Flash of Invisible Text)。FOUT 的问题在于,如果目标字体和系统字体的字宽、行高不一致,替换的瞬间整段文字的占用面积会变化,连带影响下面所有内容的位置,这也会被计入 CLS。FOIT 虽然不会引发布局偏移(反正文字本来就不可见),但会造成一段时间的白屏或空文本,体验上是另一种代价。

CSS 里 font-display 这个属性可以控制这个策略,团队后来统一改成:

1@font-face {
2  font-family: "BrandFont";
3  src: url("/fonts/brand.woff2") format("woff2");
4  font-display: swap;
5}

font-display: swap 是 FOUT 策略的显式声明,先用后备字体渲染,字体到了就替换,不阻塞文字先出现。但前面说了,替换本身可能引发偏移,治本的办法是尽量让后备字体和目标字体的度量(metrics)接近。做法是选一个和目标字体宽高比例相近的系统字体作为 fallback,或者用 size-adjustascent-override 这类字体描述符去微调后备字体的度量,让它替换前后占的空间基本一致——这几个描述符是这一年 CSS Fonts Module Level 4 草案里的新东西,Chrome 已经开始支持,可以先在小范围页面上试。更省事但更取巧的做法是干脆把字体文件通过 <link rel="preload"> 提前预加载,尽量缩短"先用系统字体渲染"到"替换成目标字体"之间的时间窗口,窗口越短用户越不容易感知到跳变:

1<link rel="preload" href="/fonts/brand.woff2" as="font" type="font/woff2" crossorigin />

除了 swap,font-display 实际上一共定义了五种取值,分别对应不同的"允许多久空白、多久之后允许回退"的策略:auto 交给浏览器自己决定,block 允许一小段隐藏期(通常 3 秒内)但几乎不设回退超时,swap 几乎不隐藏、立刻用后备字体渲染,fallbackblockswap 的折中(极短隐藏期,之后短暂允许回退但超时后不再替换),optional 最激进——如果字体没能在极短时间内下载完,本次会话就直接放弃使用它,全程用后备字体渲染,不再替换,自然也就不存在文字面积变化的问题。这里能看出一个明显的取舍:optional 对 CLS 最友好(压根不会因为换字体产生偏移),但代价是慢网络下用户可能完全看不到自定义字体;swap 保证了字体最终一定会用上,但要为偏移买单。团队后来针对不同页面区分处理——品牌 Logo 区域这种小面积、字数少的地方用 swap 问题不大(哪怕度量差异存在,面积小、影响范围也小),大段正文这种一旦切换会牵动整页高度的地方倾向于用 optional,认为品牌字体在正文区域是加分项而非必需项。

动态插入的公告条,该怎么不推挤下面的内容

后台顶部有个运营公告条,逻辑是页面加载完之后异步拉一次配置接口,如果当前有公告就临时插入一条横幅,插入位置在导航栏下方、主内容上方。这类"内容加载完之后才决定要不要插入"的场景,是 CLS 里比图片、字体更难处理的一类,因为它连"有没有"都是不确定的。

如果直接在数据回来后插入 DOM,哪怕只有一行公告,也会让下面所有内容瞬间下移一个公告条的高度,这是最坏的做法。稍微好一点的做法是给公告条一个固定高度的容器,不管有没有内容都先占位,没有公告时容器保持隐藏但保留高度(用 visibility: hidden 而不是 display: none,后者不占空间起不到预留作用):

1.announcement-bar {
2  height: 40px;
3  visibility: hidden;
4}
5.announcement-bar.has-content {
6  visibility: visible;
7}

但这个做法有个明显副作用——如果大多数时候压根没有公告,那这 40px 空白就白白挤占了首屏空间,得不偿失。更合理的处理是把"有没有公告"这个判断尽量提前,能在服务端渲染阶段就决定的,不要留到客户端异步请求之后再决定。如果技术上做不到服务端注入,退而求其次的方案是把公告配置这类"影响首屏结构"的请求,提到关键渲染路径里尽早发出(比如页面刚开始解析就用一个不阻塞的 fetch 发起请求,而不是等某个业务组件挂载完才触发),尽量让这个决定在用户还没开始阅读页面内容之前就落定,自然也就不存在"看着看着跳一下"的问题。

这里还有一个容易被忽略的思路:如果这块内容注定会让页面高度发生变化,又没法回避,那至少要保证变化发生在用户视线之外或者用户尚未开始交互的阶段。CLS 的计分逻辑里,视口内、且发生在用户输入之后 500 毫秒内的偏移会被特殊处理(不计入),这也是为什么很多"点击后才展开"的交互不会拉低 CLS 分数——只要变化是用户自己点出来的结果。反过来说,凡是纯粹被动发生、和用户操作无关的偏移,才是要重点排查的对象。

合成层到底怎么形成,will-change 的滥用代价该怎么量化

前几年已经反复提过一个结论:用 transformopacity 做动画比用 leftwidth 顺滑,原理是前者可能被提升到独立的合成层,交给 GPU 处理,不占用主线程的布局和绘制。"可能"这两个字背后的具体条件值得查清楚——一个元素并不是写了 transform 就自动上层,浏览器判断是否需要为某个元素单独建层,依据的是一套具体规则,常见的触发条件包括:元素有 3D 变换(translateZmatrix3d 等);元素是 <video><canvas> 这类天然需要独立层的媒体节点;元素应用了 CSS 动画或过渡且动画属性是 transform/opacity;元素设置了 will-change: transformwill-change: opacity;元素有 position: fixed 且需要在滚动时独立于其他内容合成;元素是另一个合成层的裁剪路径或者遮罩。这些条件在 Chromium 的合成器代码里叫作"直接合成原因"(direct compositing reasons),DevTools 的 Layers 面板可以逐个元素查看它当前是因为哪一条被提升成层的。

理解这套条件之后,will-change 的正确用法应该更精确一点,不是"焦虑就加",而是根据这个属性的语义去用——它告诉浏览器"这个属性接下来大概率会变,提前准备"。但提前准备的代价是实打实的显存开销:每个独立合成层都要为自己维护一份完整的位图(bitmap),这份位图的内存占用大致是元素渲染尺寸(像素宽乘高)乘以每像素字节数(通常 4 字节,RGBA),一个占满屏宽、几百像素高的区块,提升成层之后可能就要多占几 MB 显存,页面里如果有几十个这样的元素同时持有独立层,叠加起来的显存开销在低端设备上是能把整个页面拖垮的。这也是为什么"给所有可能会动的元素都提前加 will-change"是一种反模式:显存不是无限资源,合成层数量涨上去之后,GPU 合成阶段本身的耗时也会跟着涨,原本用来解决卡顿的手段,过量使用之后反而成为新的卡顿源。

一个更谨慎的用法是把 will-change 的声明周期和动画的生命周期绑定,而不是写死在样式表里长期生效:

1function playExpandAnimation(el) {
2  el.style.willChange = 'transform';
3  el.classList.add('expanding');
4  el.addEventListener('transitionend', function onEnd() {
5    el.style.willChange = 'auto';
6    el.removeEventListener('transitionend', onEnd);
7  });
8}

动画开始前才声明,动画结束立刻撤销,让元素只在真正需要独立层的这一小段时间里占用额外显存,平时归还给普通的绘制流程。DevTools 的 Rendering 面板里有个 "Layer borders" 选项,打开后页面上所有独立合成层会被描边框出来,能直观看到当前页面到底建了多少层、边界划在哪里,配合 Layers 面板看每层的内存估算,是排查"合成层滥用"这类问题最直接的手段。

contain 和 content-visibility:把布局成本圈起来

除了控制单个元素要不要上合成层,这一年 CSS 里还有一组新属性从另一个方向解决类似的问题——不是优化"这个元素怎么动画",而是告诉浏览器"这一块区域的内部变化,不会影响外面,你可以放心地把它的布局计算延后或者跳过"。这就是 contain 属性,以及建立在它之上、Chrome 85 起支持的 content-visibility

contain: layout 声明之后,元素内部发生的布局变化(子元素增删、尺寸变化)不会传导到外部,浏览器计算布局时可以把这个元素当成一个"黑盒"处理,不需要因为它内部的变化去重新检查整个文档的布局关系。这对页面里那种"内部经常变、但外部尺寸稳定"的模块(比如一个固定高度的卡片,里面的图表在轮播)是个很直接的优化,能缩小重排的波及范围。

content-visibility: auto 更激进一点,它做的事情是:如果一个元素当前不在视口附近,浏览器会跳过它的布局和绘制计算,把这部分成本推迟到它即将进入视口的时候才做。这对长列表、长文章这类"页面很长、但用户任意时刻只看得到一小部分"的场景效果很明显——传统认知里,页面首次渲染的成本和 DOM 节点总数是正相关的,content-visibility 相当于把这个正相关关系打破了一部分,不在视口里的内容,浏览器压根不去算它的布局。

1.article-section {
2  content-visibility: auto;
3  contain-intrinsic-size: 0 600px;
4}

这里 contain-intrinsic-size 是必须搭配使用的属性,它告诉浏览器"这个元素在还没被实际渲染之前,先按这个尺寸占位"。如果不写这个属性,浏览器对视口外的元素会把它当成零尺寸处理,等它进入视口开始渲染的那一刻,尺寸从 0 变成实际高度,这本身就是一次实打实的布局偏移——这也是这一年不少团队上手 content-visibility 踩到的第一个坑,原本是为了提升渲染性能引入的新特性,如果漏掉尺寸声明,反而制造出了新的 CLS 来源。团队在把长文章详情页的分段内容接入 content-visibility 试点时,专门验证过这一步,靠预先量出的段落平均高度给 contain-intrinsic-size 一个大致合理的估算值,没有的话宁可先不接入这个特性。目前这两个属性 Firefox 和 Safari 都还没跟进,只能算是在 Chromium 系浏览器上的渐进增强,不能作为跨浏览器都能生效的方案来设计核心交互。

用 Layout Instability API 把偏移量实际测出来,而不是靠眼睛数

前面讲的这些场景,光凭肉眼刷页面判断"有没有跳一下"很不靠谱,尤其是那种偏移量小、发生得快的情况,人眼未必能捕捉到,但 CLS 的分数照样会累积。这一年 Chrome 已经在 PerformanceObserver 里加入了 layout-shift 这个 entry 类型,可以在页面里实际监听每一次布局偏移,拿到具体的分数:

1let clsValue = 0;
2const observer = new PerformanceObserver((list) => {
3  for (const entry of list.getEntries()) {
4    if (!entry.hadRecentInput) {
5      clsValue += entry.value;
6      console.log('本次偏移分数:', entry.value, entry.sources);
7    }
8  }
9});
10observer.observe({ type: 'layout-shift', buffered: true });

这里 entry.hadRecentInput 就是前面提到的"用户输入豁免"的具体体现——如果这次偏移是在用户最近一次输入(点击、按键)之后短时间内发生的,这个字段会是 true,按照 CLS 的官方计分规则就不该累加进去。entry.sources 更实用,它是一个数组,列出了这次偏移具体牵涉了哪些 DOM 节点、偏移前后的位置矩形,拿到这些信息基本就能直接定位到是哪个元素在捣鬼,不用再对着 Performance 面板的火焰图去猜。

线上环境更常见的做法是把这段监听逻辑接入前端监控 SDK,每次页面卸载前把累积的 CLS 分数连同来源信息一起上报,这样能拿到真实用户设备上的分布数据,而不是只看自己开发机上跑出来的结果——同一个页面,在低端安卓机上因为图片解码更慢、字体请求排队更久,实际的偏移可能比开发机严重得多。Chrome 团队同期也在 DevTools 的 Performance 面板里加了专门显示布局偏移的 "Experience" 分区,红色的方块直接标出偏移发生的时间点,点开能看到具体分数和牵涉的节点,不需要自己手写 PerformanceObserver 也能大致定位,只是要拿到精确数值、或者要接入线上监控,还是得走 API 这条路。

实验室数据和真实用户数据,看到的 CLS 可能不是同一个数

排查这次的问题时,还发现一个容易让人误判的地方:本地用 Lighthouse 跑出来的 CLS,和上线之后从 Chrome UX Report(简称 CrUX,谷歌收集的真实用户浏览数据)里看到的 CLS,经常对不上号,有的页面本地测出来干干净净,线上却有相当比例的用户实际经历了明显偏移。

这背后的原因是两者的数据来源性质完全不同。Lighthouse 跑的是"实验室数据"(lab data)——固定网络环境、固定设备性能、固定的一次页面加载过程,每次跑出来的结果高度可复现,但也只反映了这一次特定条件下的表现。CrUX 收集的是"现场数据"(field data)——来自真实用户在真实设备、真实网络条件下的访问过程,同一个页面在千差万别的手机型号和网络状况下,表现的方差会很大,尤其是 CLS 这种和"图片解码有多快""字体请求排多久队"直接相关的指标,慢设备上出现的偏移,在开发者自己的高配电脑和稳定的 Wi-Fi 环境下未必能复现出来。

团队后来在做这类验证时留了个心眼:凡是判断某个 CLS 问题"已经修好了",不能只看本地 Lighthouse 跑出来的分数从红变绿就结案,还要等一段时间观察 CrUX 或者自建监控里真实用户的分布数据是不是也同步好转——本地环境测出来的分数,更适合当作日常开发时的快速反馈信号,真正决定这个指标好不好、要不要继续投入的,还是线上真实用户的数据。这也是为什么前面那段 PerformanceObserver 监听代码值得接入线上监控体系,而不只是开发阶段临时打几行 console.log 看一眼——本地能复现的问题只是冰山一角,真正拖累分数的偏移,很多时候只在特定机型、特定网络状况下才会暴露出来。

CLS 和 LCP 并不是两件互不相干的事

团队一开始是把 LCP(最大内容绘制)、FID(首次输入延迟)、CLS 三项指标当成三条独立的优化线来分工的,后来在商品详情页这个具体案例上发现,它们其实经常互相牵连,治理 CLS 时如果不考虑对 LCP 的影响,很容易顾此失彼。

最直接的冲突点在图片加载策略上。为了不让主图产生偏移,前面的做法是提前声明 width/height 占位,这没问题;但商品详情页的主图往往同时也是 LCP 候选元素(页面里视觉面积最大、最早完成渲染的内容,大概率就是这张图),如果因为过度追求"先占位、再异步加载"而给主图也套上了懒加载策略,反而会推迟这张图片真正开始下载的时机,拖累 LCP。这里的正确判断是:首屏内、大概率会成为 LCP 候选的图片,应该优先下载、甚至用 <link rel="preload"> 提前预加载,而不是无差别地把"懒加载"当成万能的性能优化手段扣在所有图片头上;只有非首屏、明确在初始视口之外的图片,才适合用 loading="lazy" 延后加载。CLS 关心的是"有没有占好位置",LCP 关心的是"关键内容多快画出来",两者对同一张图片的诉求方向不完全一致,不能用同一套策略无脑覆盖所有图片。

字体加载策略上也有类似的取舍。font-display: optional 对 CLS 最友好,但如果页面的 LCP 候选元素恰好是一段用自定义字体渲染的大标题文字,optional 策略下如果字体没能及时下载完,就会永久使用后备字体渲染,这段文字本身作为"内容"完成绘制的时机其实没有被拖慢,LCP 反而不受影响;但如果换成 block 策略、也就是允许一段隐藏期,反而会让这段文字的绘制时间往后推,拖累 LCP。这也是为什这一年做这类优化,不能孤立地针对单一指标去调参数,要把三项指标放在同一份清单里通盘考虑,一个策略调整对其中一项有利,未必对另外两项也有利,需要具体到某个元素、某个资源去逐个判断。

这几种手段放在一起怎么排优先级

真要在一个页面里推进 CLS 治理,几种手段的收益并不对等。图片和视频缺少尺寸属性,是最容易出现、也最容易根治的一类,补上 width/heightaspect-ratio 基本是一次性投入、长期见效,应该优先处理,而且这类改动几乎不需要业务判断,只是补全一个本该写的 HTML 属性,推进阻力最小。异步内容(广告位、推荐位、公告条)的占位,需要业务上判断一个合理的预留高度,调整成本略高,但收益也直接可见,通常放在第二优先级。字体度量匹配这类属于精细化优化,对绝大多数中文站点来说,由于中文字符本身宽度相对固定、换字体造成的宽度差异没有西文那么夸张,优先级可以放在图片和异步内容之后,除非页面里恰好有大段用自定义字体渲染的标题或正文。而合成层和 will-change 的治理,严格来说不完全是 CLS 的问题(它更多影响的是动画流畅度而非布局偏移本身),但两者背后是同一套"浏览器渲染成本"的知识框架,治理时经常一起排查,值得放在同一次性能专项里一并盘一遍。

排这个优先级的时候,还有一条更朴素的判断标准值得放在最前面:先看 PerformanceObserver 或者 CrUX 报回来的 sources 字段,哪个元素、哪个区块的偏移分数占比最高,就优先处理哪个,而不是按"图片、字体、异步内容"这种类型上的先后顺序机械地过一遍。同一套治理手段,在不同页面上的收益完全不同——纯图文的商品详情页,主图尺寸预留可能就解决了八成的分数;而信息流首页,异步推荐模块的占位往往才是大头。把数据摆在前面,比凭经验猜测更可靠。

ResizeObserver:从"猜内容什么时候变"到"直接听内容变了"

排查公告条和推荐位这类异步内容的过程中,还牵出了另一个相关但不完全是 CLS 本身的问题——很多组件内部需要知道"自己的尺寸变了",才能做一些联动调整,比如图表组件需要在容器宽度变化时重新计算画布尺寸并重绘。这年之前团队处理这类需求的土办法是监听 windowresize 事件,再手动读取目标元素的 getBoundingClientRect()

1window.addEventListener('resize', function () {
2  var rect = chartContainer.getBoundingClientRect();
3  chart.resize(rect.width, rect.height);
4});

这套写法只能感知"浏览器窗口本身变了",如果容器尺寸变化的原因不是窗口缩放,而是布局本身的变化——比如侧边栏收起展开导致主内容区宽度变化、或者前面提到的公告条从占位状态变成实际显示状态——windowresize 事件根本不会触发,图表就會停留在旧尺寸,直到用户手动缩放窗口才会被动更新一次。

ResizeObserver 解决的正是这个问题,它直接监听某个具体元素的尺寸变化,不管这个变化是窗口缩放引起的还是布局内部的其他因素引起的:

1const ro = new ResizeObserver(function (entries) {
2  for (const entry of entries) {
3    var width = entry.contentRect.width;
4    var height = entry.contentRect.height;
5    chart.resize(width, height);
6  }
7});
8ro.observe(chartContainer);

entry.contentRect 给出的是内容区域(不含 padding、border)的尺寸,如果需要包含边框的尺寸,较新的规范里还提供了 entry.borderBoxSize,这个字段在 2021 年这个时间点浏览器支持还不算完全统一,用之前最好做一次特性检测,不支持就退回到手算 getBoundingClientRect() 减去 padding 的笨办法。

ResizeObserver 和 CLS 治理之间有个值得留意的关联:它本身不能预防偏移,但可以用来精确测量偏移发生的具体时机和幅度,在开发阶段调试某个模块的尺寸变化行为时,比反复手动缩放窗口去观察更可靠。团队在排查公告条占位方案是否真的做到了"内容切换、高度不变"时,就临时接了一段 ResizeObserver 打日志,验证 visibility 切换前后容器的 contentRect 高度确实保持一致,比单纯看 Lighthouse 的分数更直观地确认了这个具体点位没问题。

一次真实的排查:详情页评价区展开后引发的连锁偏移

有了这套指标和工具之后,团队在商品详情页评价区遇到一次值得记录的具体案例。现象是:CrUX 数据显示这个页面的 CLS 分数在移动端明显偏高,但开发机上无论怎么操作都复现不出明显的跳动。

第一步是把线上监控接的 PerformanceObserver 数据拉出来看 entry.sources,发现绝大多数偏移都指向同一个区域——评价区下方紧跟着的"猜你喜欢"模块。评价区本身有个"展开全部"的按钮,点击后会加载更多评价内容,这个操作理论上属于"用户主动触发",按 CLS 规则应该有 500 毫秒的豁免窗口,不该被计分。

深入看 entry.hadRecentInput 字段才发现问题所在:评价内容的展开逻辑里,点击按钮后先发起了一个异步请求去拉取更多评价,请求耗时在慢网络下经常超过 500 毫秒,等数据真正回来、DOM 真正更新、触发实际布局偏移的时候,已经超出了"最近有过用户输入"的豁免窗口,这次偏移就被正式计入了 CLS。这是一个很容易被忽略的时序陷阱:豁免窗口保护的是"输入之后短时间内的变化",不是"由输入引发的所有变化",如果响应链路本身较慢,因果关系还在,但时间窗口已经过期,浏览器没法区分"这是用户操作的自然延续"还是"恰好在这个时间点发生的、和用户无关的偏移"。

找到根因之后,修法有两条思路。一条是尽量缩短请求到渲染之间的耗时,比如加一个骨架屏立即撑开预计的高度、数据回来后再替换内容,把"实际引发偏移的那一刻"提前到豁免窗口之内。另一条更彻底:既然按钮点击到内容真正展开之间有明显的异步等待,不如在按钮被点击的同一时刻就先把评价区容器撑到一个预估高度(比如按平均每条评价的高度乘以即将加载的条数估算),后续内容到达时只是往这个已经撑开的容器里填充,不再触发容器整体高度的变化。团队最终选了后一种方案,因为它同时也解决了"点击按钮后界面长时间无变化、用户不确定是否点击生效"的交互疑虑,一次改动同时改善了两个问题。

这个案例留下来的经验是:豁免规则不能简单理解成"用户点了就没事",只要交互到实际渲染之间存在明显的异步延迟,就应该假设这次变化可能会被计入 CLS,处理思路要和"完全被动发生的偏移"一视同仁,提前占位、缩短延迟,而不是依赖"这是我自己点出来的,理论上不该算分"这种想当然的判断。

移动端和桌面端,同一个页面的 CLS 表现经常不是一回事

排查评价区这个案例时还发现一个规律:同一个页面,移动端的 CLS 分数普遍比桌面端难看不少,原因不完全是移动端网络更慢(虽然这确实是一部分原因),还有一层是移动端视口本身更窄,同样像素的位移,占视口比例(distance fraction)天然更高,分数被放大。一个在桌面端 1920 像素宽视口下几乎感知不到的小偏移,换到 375 像素宽的手机视口上,同样的像素位移占比会扩大五倍不止。这意味着做 CLS 治理,不能只在桌面端浏览器里开着 DevTools 反复调试就觉得完事,至少要在几个常见的移动端视口宽度下各测一遍,很多在桌面端"看起来没什么"的偏移,缩到手机屏幕的比例尺下会明显放大。

团队后来在需要重点盯的几个页面模板上,固定用 Chrome DevTools 的设备模拟模式,选几档常见的移动端分辨率(375、390、414 像素宽这几档覆盖了当时大部分主流机型),录制 Performance 面板逐一过一遍,而不是默认拿桌面端的测试结果去代表所有设备。CrUX 的数据也支持按设备类型拆开看,真正决定要不要投入精力去修的,是移动端这部分用户的真实分布,而不是开发时用惯的那台高配笔记本上跑出来的分数。

小结:这几类手段背后是同一套判断逻辑

回过头梳理这几类布局偏移的来源——没留位置的图片、异步插入的公告条、字体切换、合成层的滥用——表面上是不同的技术点,但排查和修复的思路其实高度一致:先确定这块内容最终会占多大空间,在它真正渲染出来之前,用某种方式把这个空间提前占住,让"内容从无到有"这个过程尽量不影响周围其他元素的位置。图片和视频靠 width/height 或者 aspect-ratio,异步模块靠占位容器和骨架屏,字体靠度量匹配和预加载,合成层靠限定动画生命周期,思路上都是同一件事的不同变体。

真正需要工具介入的地方,是那些"占多大空间"这个前提本身就不确定的场景——用户生成内容、第三方嵌入、数据驱动的动态高度。这几类场景没有一劳永逸的固定占位方案,只能靠尽量提前拿到关键信息(内容总数、大致高度范围)、或者把变化控制在用户能预期的交互窗口之内,来把偏移的影响降到最低。CLS 这个指标本身不复杂,真正花时间的,是把这套"提前占位"的思路,一个一个套到项目里那些容易被忽视的具体角落上去。