前阵子帮人看一个站,运维报的问题是「首屏有一块灰了半秒,然后图片跳出来,CLS 分数一直上不去,移动端体验分卡在黄区」。我打开页面源码看了一眼,发现列表页的每个卡片都用了同一张占位图,而真实图片是懒加载的,尺寸由内容决定。浏览器一开始只能按占位图的比例排版,等真实图片加载完,高度变了,整个页面重排一次。用户看到的就是「先看到一张图,然后它突然变成另一张、位置还挪了」。
这件事里有三个可以分别讨论的东西,很多人把它们混成一个:骨架屏(skeleton)是加载期间的占位视觉;图片占位与尺寸预留是防止布局位移的技术手段;字体子集化是减少首屏文字闪烁与等待的方法。三者都归属「首屏感知性能」,但实现方式和踩坑点完全不同。这篇按三块讲,每块给出可直接抄的写法与验证方法,最后讲怎么把它们串起来。
一、图片占位:三种写法,只有一种不引起位移
先说最常见、也最容易被做错的一块。图片导致布局位移(CLS)的根本原因是:图片在加载完成前,浏览器不知道它有多大。所以凡是没有预留尺寸的图片,加载完必然推开下面的内容。解决办法只有三类。
写法一:宽高属性(最基础,别省)
<img src="photo.jpg" width="800" height="600" alt="现场照片">加上 width 和 height 之后,现代浏览器会在图片加载前就按这个比例预留空间,等比缩放到容器宽度。这两个属性必须写真实的原始比例,不能乱写,否则图片加载后会因为比例不符而拉伸或者留白。
配合 CSS 里的 img { max-width: 100%; height: auto; },就得到了「先按比例占位、加载后等比缩放」的效果。这一行 CSS 是必须的,少了它图片在窄屏上会溢出容器。
写法二:aspect-ratio(更灵活,适合响应式)
如果图片的宽度由容器决定(比如栅格布局里宽度不固定),用 aspect-ratio 更干净:
.card-thumb {
aspect-ratio: 16 / 9;
width: 100%;
object-fit: cover;
background: #f2f2f2;
}好处是不需要知道具体像素,只需要知道比例。代价是:object-fit: cover 会裁切,如果原图比例和容器比例差别大,主体可能被裁掉。所以要么保证上传时就按比例裁好,要么在内容管理里允许编辑者选择焦点位置。
写法三:背景色 + 模糊占位(提升观感,不解决位移)
给容器一个浅灰背景、或者用一张极小的模糊版图片做占位(所谓的 LQIP),能显著改善观感——从「一块空白」变成「一块有色调的预览」。但要清楚:这解决的是感知问题,不是 CLS 问题。CLS 仍然需要一个确定的尺寸。两者要一起用,不能只做前者。
LQIP 的实现方式是把原图缩到 20 像素宽、压成 base64 内联到 HTML 里,体积通常 300~800 字节。超过 1KB 就不划算了,因为内联的字节直接加在 HTML 上,会推迟首屏渲染。
一个必须避开的坑:懒加载的占位图
回到开头那个案例,问题出在「占位图和真图比例不同」。解决方式很简单:占位图必须和真图同比例。如果做不到,那就只留背景色,不要放占位图。一张比例不对的占位图比没有占位图更糟,因为它会让用户看到两次布局跳动。
二、懒加载的正确边界:别把首屏也懒了
原生 loading="lazy" 是现在最省事的懒加载方式,但它有三个明显的边界。
边界一:首屏图片绝对不要加
loading="lazy" 会让浏览器推迟到「即将进入视口」时才发起请求。首屏图片本来就在视口内,加了之后请求反而被推迟到布局完成之后,LCP 会变差。最稳妥的规则是:第一屏能看到的图片一律用 loading="eager" 或者干脆不写,同时给它加 fetchpriority="high"。
判断第一屏有多少图,不要凭感觉,用真实设备打开看。桌面端 1920×1080 下,列表页通常只有前两三个卡片在首屏;移动端 390×844 下只有第一张。
边界二:懒加载 + 无尺寸 = 更大的 CLS
这是最反直觉的一条。图片不懒加载时,浏览器在解析 HTML 时就知道有这么个资源要占位;而一旦懒加载,浏览器在触发之前完全不知道这个位置会有东西,所以尺寸预留从「推荐」变成「必须」。我见过不少站点,本来 CLS 还行,加了懒加载之后反而变差,就是这个原因。
边界三:锚点跳转与打印
如果页面上有锚点链接(目录、评论区直达),用户点击跳转时,目标位置下方未加载的图片会让页面高度在滚动后又变化一次。解决方式是跳转时给目标元素加一点滚动偏移,或者把锚点区域附近的图片设为 eager。打印同理,loading="lazy" 在打印时会全部加载,但布局已经算过一轮,容易错位。
三、骨架屏:什么时候值得做,什么时候是自欺欺人
骨架屏这几年被滥用了。很多站点把骨架屏做得很精致,但页面其实三百毫秒就加载完了,用户看到骨架屏一闪而过,反而产生了「闪屏」的不适感。我自己的判断标准是三条,满足两条以上才值得做。
判断标准
- 首屏内容依赖异步请求,且该请求在正常网络下的 P75 耗时超过 800 毫秒;
- 内容区域的结构是可预测的,比如固定行数的列表、固定比例的卡片;
- 服务端不能直接输出完整 HTML(能输出就别用骨架屏,直接渲染更快)。
反过来说,如果是纯静态渲染的页面、或者请求在两百毫秒内返回,做骨架屏是负收益:多一次状态切换、多一段 CSS、用户体验上的增益为零。
实现要点:骨架屏的尺寸要等于真实内容
骨架屏最常见的错误是「骨架比真实内容矮」。加载完成后内容撑开,整页往下推一次,CLS 直接爆掉。所以骨架屏的每个元素都必须和真实元素尺寸一致:
.skeleton-card { height: 320px; } /* 必须等于真实卡片高度 */
.skeleton-title { height: 20px; margin-bottom: 8px; }
.skeleton-line { height: 14px; margin-bottom: 6px; }固定高度是最省事的做法。如果卡片高度是内容自适应的(比如标题可能两行),那就按最长的情况预留,宁可多留几像素也不要少。
动画要克制
骨架屏的呼吸动画(shimmer)会让用户感觉「它在动,快好了」,这是它的价值所在。但动画实现方式要讲究:用 CSS transform 做的位移动画不触发重排,用 background-position 做则会。而且动画应该放在 @media (prefers-reduced-motion: reduce) 里关掉,对这类用户来说持续的动效会带来不适。
还有一条:骨架屏不要用「假的」内容撑场面。有些站点的骨架屏里放了灰色方块模拟文字,但行数和真实文章的行数完全不同,加载完切换时视觉跳跃很明显。宁可风格抽象一点,把尺寸对上。
四、字体子集化:中文字体加载最省的那一刀
中文字体是首屏性能里最贵的一项。一个完整的中文字体动辄 5~10MB,这是因为 GB2312 有六千多字,加上标点和符号,字形数量大。很多站长想在站上用一款好看的思源黑体,结果首屏白屏三秒,最后放弃。
实际上中文字体不需要全量加载。一个普通的博客或资讯站,正文用到的汉字通常在 1500~2500 个之间,常用标点几十个,拉丁字母与数字不到一百个。子集化之后,字体体积能降到 200~400KB,压缩后一百多 KB。
怎么切子集
子集化的原理是从字体里只保留用到的字形。工具有几个,最常用的是 Python 的 fontTools:
pip install fonttools brotli
pyftsubset SourceHanSansSC-Regular.otf \
--text-file=chars.txt \
--flavor=woff2 \
--output-file=body-subset.woff2 \
--layout-features='kern,liga' \
--no-hintingchars.txt 里放你站点实际用到的字符。生成这个文件的方法是从数据库里导出所有文章正文,去重取汉字,再补上常用标点、数字、字母。注意要留余量:以后新发的文章如果用了新字,字形缺失会显示成豆腐块。
动态子集与按需加载
更完善的做法是服务端动态生成子集:请求 CSS 时带上页面用到的字符集,服务端按需切一份缓存起来。Google Fonts 对中文就是这么做的,把字库切成上百个小片,按 unicode-range 分段加载,浏览器只下载用到的那些片。
如果不想自己搭这套,次优方案是分片:把一个完整字体按常用字(前 3000 字)、次常用(3000~7000)、生僻字切成三份,用 unicode-range 声明,浏览器按需取。这样首屏只加载常用字那一份。
更省的选择:根本不用网络字体
说句实话,中文站用系统字体栈往往是更好的选择。现代系统的默认中文字体质量都不错,加载成本是零:
font-family: -apple-system, "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", "Source Han Sans CN",
"Noto Sans CJK SC", sans-serif;只有当你需要特殊字重(比如 900 超粗标题)、或者需要跨平台完全一致时才值得上网络字体。而且要用 font-display: swap,让文字先用系统字体显示,字体加载完再换——代价是文字会闪一次(FOUT),但这比白屏三秒(FOIT)好得多。
顺带提一句:font-display: optional 更激进,如果字体在 100 毫秒内没到就直接用系统字体、本次不换。适合「字体只是锦上添花」的场景,完全没有闪烁。
五、把三块串起来:一个列表页的完整改造
上面三块单独看都成立,但真正见效的是组合起来。以一个典型的文章列表页为例,改造顺序是这样的:
第一步:先量,别猜
打开 Chrome DevTools 的 Performance 面板,开节流到「Fast 3G」,录一次首屏加载。重点看三条:LCP 元素是什么(通常是第一张图或者大标题)、CLS 发生在什么时刻(通常是有图片加载完成的那一帧)、以及主线程被占用多久(字体解析与 CSS 计算都在这)。
另外用 document.querySelectorAll('img:not([width]):not([height])') 一行就能找出所有没预留尺寸的图片。这个数量是最直接的改善杠杆。
第二步:修尺寸,这一步收益最大
把所有图片补上宽高或者 aspect-ratio,包括占位图、头像、二维码、广告位。这一步不需要改设计,纯技术改动,收益通常能把 CLS 从 0.2 以上降到 0.05 以下。
第三步:区分首屏与非首屏
首屏图片改成 eager + fetchpriority 高,非首屏保持 lazy。
第四步:字体
先尝试去掉网络字体,看看视觉上能不能接受。不能接受再上子集化,同时 font-display: swap,并在 CSS 里用 size-adjust 调整 fallback 字体的度量,减少换字体时的位移。
第五步:最后才考虑骨架屏
如果首屏内容真的是异步拉取的、P75 超过 800 毫秒,再加骨架屏,并且逐项对齐尺寸。顺序很重要:先解决 CLS,再解决感知。反过来做的话,骨架屏会把本来就不稳定的布局变得看起来更不稳。
六、几条容易忽略的细节
响应式图片的比例一致性。用 srcset 配了多张不同尺寸的图时,每张图的宽高比必须一致。如果某个尺寸的图被人工裁成了不同比例,切换时会跳动。建议在构建流程里加一道检查:所有衍生图的比例与原图一致。
广告位的尺寸。这一条是 CLS 的重灾区。如果广告脚本加载后插入一个没有预留尺寸的容器,整个页面会被推一下。解决方式是给广告容器写死 min-height,并且这个高度按该广告位「最常见尺寸」来定。宁可留白也不要跳。
字体的 preload。给主字体加 <link rel="preload" as="font" type="font/woff2" crossorigin> 能提前发起请求。但要注意 crossorigin 属性不能省,字体请求是 CORS 请求,少了这个属性会导致重复下载、反而更慢。而且只 preload 一份字体(正文字体),preload 太多会抢走关键资源的带宽。
图片格式的收益边界。WebP 相比 JPEG 通常能省 25~35%,AVIF 能省 40~50% 但编码慢、兼容性略差。转换收益在大图上最明显,对于几 KB 的小图标,格式带来的差异小于请求开销,不如直接内联成 SVG。
最后回到开头那个站点。改完的清单其实很短:给所有卡片图加 aspect-ratio、占位图换成同比例、首屏前两张改 eager、去掉那个装饰性网络字体。四件事,两个小时的活,CLS 从 0.31 降到 0.04,LCP 从 3.8 秒降到 1.9 秒。没有做骨架屏,也没有上什么高级手段——首屏性能这件事,八成的问题出在「没有预留尺寸」这一个原始错误上,剩下的两成才是优化技巧。