Core Web Vitals 实战:首页从 59 MB 到 2.27 MB
LCP、INP、CLS 三个指标对应三种完全不同的病理。用本站真实数据说明:30 张没压缩的照片,比任何代码优化都致命。
这篇文章的数据来自本站(一个 Halo 博客)的真实优化过程。优化前:首页 59 MB、Lighthouse 性能分 12 分、移动端首屏 8 秒以上。优化后:首页 2.27 MB、性能分 96、首屏 1.2 秒。
过程中修复的问题都是很典型的:图片没压缩、字体全量加载、h1 有 20 个、SEO 元信息缺失。这篇文章按 Core Web Vitals 的三个指标组织,每个指标给出「怎么测 → 常见成因 → 怎么修」。
一、先搞清楚三个指标测什么
| 指标 | 全称 | 阈值(好 / 需改进 / 差) | 侧重 |
|---|---|---|---|
| LCP | Largest Contentful Paint | ≤2.5s / ≤4s / >4s | 加载速度 |
| INP | Interaction to Next Paint | ≤200ms / ≤500ms / >500ms | 交互响应 |
| CLS | Cumulative Layout Shift | ≤0.1 / ≤0.25 / >0.25 | 视觉稳定 |
INP 在 2024 年 3 月替代了 FID。FID 只测「首次交互的输入延迟」,INP 测的是「整个生命周期内所有交互的响应延迟」,并且取的是高百分位(接近最差的那次)。所以 INP 比 FID 严格得多,很多站点 FID 是绿的但 INP 是红的。
其余辅助指标:
- TTFB(首字节时间):服务端响应速度,目标 < 800ms
- FCP(首次内容绘制):目标 < 1.8s
- TBT(总阻塞时间):主线程被长任务占用的总时长,目标 < 200ms
二、怎么测
三个层次,缺一不可:
1. 实验室数据(可复现,用于优化对比)
# Lighthouse CLI
npx lighthouse https://example.com \
--preset=desktop \
--output=json --output-path=./lh.json \
--chrome-flags="--headless --no-sandbox"
# 或者用 PageSpeed Insights API 拿到 CrUX 真实用户数据
curl "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://example.com&strategy=mobile"
2. 真实用户数据(RUM)
实验室数据是模拟环境,真实用户的网络、设备差异很大。要拿真实数据必须埋点:
import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals'
function report(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
id: metric.id,
navigationType: metric.navigationType,
})
navigator.sendBeacon('/api/vitals', body)
}
onLCP(report)
onINP(report)
onCLS(report)
onTTFB(report)
web-vitals 库只有 2 KB,sendBeacon 保证页面卸载时也能发出。数据落到时序数据库后,按 P75 统计——Google 的评分标准就是 P75。
3. 现场诊断
// 控制台直接看 LCP 元素是谁
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('LCP:', entry.startTime, entry.element)
console.log(' 资源:', entry.url)
}
}).observe({ type: 'largest-contentful-paint', buffered: true })
// 看布局偏移是谁造成的
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
console.log('CLS 贡献:', entry.value, entry.sources)
}
}
}).observe({ type: 'layout-shift', buffered: true })
三、LCP:那个 59 MB 的首页
诊断
用 Lighthouse 跑出来,LCP 是 8.4 秒,Opportunities 里第一条触目惊心:
Properly size images Est. savings 54,200 KiB
Serve images in modern formats Est. savings 3,100 KiB
打开 Network 面板按 Size 排序,真相是:首页 30 张卡片图,每张原图 1.5~3 MB,都是相机直出的 4000×3000 JPEG,而实际显示尺寸只有 400×225。
30 张 × 平均 2 MB = 60 MB。这就是 59 MB 的来源。
修复 1:按显示尺寸输出图片
原则:图片的实际像素尺寸不应超过显示尺寸的 2 倍(Retina 上限)。
# 批量处理(ImageMagick)
# 400x225 显示 → 输出 800x450
mogrify -resize 800x450^ -gravity center -extent 800x450 -quality 82 *.jpg
结果:单张从 2 MB 降到 60~120 KB,30 张合计 2.6 MB。
修复 2:用 srcset 让浏览器选合适尺寸
<img
src="/upload/cover.jpg?width=800"
srcset="/upload/cover.jpg?width=400 400w,
/upload/cover.jpg?width=800 800w,
/upload/cover.jpg?width=1200 1200w"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 400px"
width="800" height="450"
loading="lazy"
decoding="async"
alt="文章封面"
>
sizes 告诉浏览器「在不同视口下图片会占多宽」,浏览器根据这个 + DPR 选出最合适的 srcset 项。移动端会自动选 400w 的那张,省一半流量。
三个必写属性:
width/height:让浏览器提前算出宽高比,避免加载时的布局抖动(直接改善 CLS)loading="lazy":首屏外的图片延迟加载decoding="async":图片解码不阻塞主线程
注意:首屏图片不要用 lazy。 首屏大图加 lazy 会延迟 LCP——浏览器要等布局完成才知道图片在视口内,白等一个来回。首屏图应该用 fetchpriority="high":
<img src="/hero.webp" fetchpriority="high" decoding="sync" ...>
修复 3:现代图片格式
<picture>
<source srcset="/cover.avif" type="image/avif">
<source srcset="/cover.webp" type="image/webp">
<img src="/cover.jpg" ...>
</picture>
体积对比(同一张 800×450 的科技感插画):
| 格式 | 体积 | 相对 JPEG |
|---|---|---|
| JPEG (q82) | 82 KB | 100% |
| WebP (q80) | 46 KB | 56% |
| AVIF (q60) | 28 KB | 34% |
AVIF 压缩率最好,但编码慢(是 WebP 的 10 倍)。构建期一次性编码可以接受,运行时转码不划算。
本次优化没有做格式转换(Halo 的缩略图链路不支持),只做了尺寸压缩——已经拿到 96% 的收益。这说明先修「尺寸」比修「格式」重要得多。
修复 4:预加载关键资源
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">
<link rel="preconnect" href="https://cdn.example.com">
只 preload LCP 元素那一张图。preload 太多会挤占其他资源的带宽,反而变慢。
修复 5:减少渲染阻塞资源
本次问题的另一个大头:首页加载了 3 个已经失效的统计脚本(Umami),每个都要等到超时才继续:
umami.js 请求失败 等待 30s(超时)
浏览器对 <script> 的处理是阻塞解析(除非加了 defer/async)。三个死链脚本让 FCP 卡了十几秒。
修复:删掉失效脚本 + 非关键脚本加 defer。
<!-- 阻塞解析 -->
<script src="/analytics.js"></script>
<!-- 不阻塞,DOMContentLoaded 前执行 -->
<script src="/analytics.js" defer></script>
<!-- 异步,下载完就执行(不保证顺序) -->
<script src="/analytics.js" async></script>
LCP 修复结果
| 项 | 优化前 | 优化后 |
|---|---|---|
| 首页总大小 | 59 MB | 2.27 MB |
| LCP | 8.4s | 1.1s |
| FCP | 6.2s | 0.8s |
| 图片总数/总大小 | 30 张 / 58 MB | 30 张 / 2.1 MB |
一个「压缩图片尺寸」的动作,解决了 90% 的 LCP 问题。 这是最高性价比的优化,没有之一。
四、CLS:布局抖动
常见成因
CLS 的四个主要来源:
- 图片没写宽高 → 加载完成时撑开容器,下方内容全部下移
- 字体切换(FOIT/FOUT) → 自定义字体加载完成时文字宽度变化
- 动态插入内容 → 广告位、公告条、cookie 提示在首屏渲染后插入
- 用
top/left做动画 → 触发重排(应该用transform)
修复:给所有媒体元素预留空间
<!-- 差:不知道高度,加载完撑开 -->
<img src="/a.jpg">
<!-- 好:明确宽高比(浏览器用 aspect-ratio 计算占位) -->
<img src="/a.jpg" width="800" height="450" style="height:auto">
<!-- 或者用 CSS -->
<style>
.media { aspect-ratio: 16 / 9; }
.media img { width: 100%; height: 100%; object-fit: cover; }
</style>
这里踩过一个坑值得单独说。 theme-earth 用的是 Tailwind 的 aspect-w-16 aspect-h-9 方案,它靠 padding-bottom: 56.25% 撑出高度:
.aspect-w-16 { position: relative; padding-bottom: calc(9 / 16 * 100%); }
我在定制样式里给卡片内容区加了 padding: 1.15rem,选择器恰好命中了这个容器,把 padding-bottom: 56.25% 覆盖掉了——封面图高度塌成 37px,所有卡片都变成了横条。
教训:改 Tailwind 项目里带 aspect-* 类的元素时,千万不要用通配的 padding/margin 覆盖。 要用 :not([class*="aspect-w-"]) 排除。
修复:字体加载
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap; /* 先用系统字体显示,加载完再换 */
size-adjust: 100%; /* 微调,减少字体切换时的宽度变化 */
ascent-override: 90%;
descent-override: 22%;
}
font-display 四个值的行为:
| 值 | 行为 | CLS 影响 |
|---|---|---|
block |
最多 3s 不可见,然后换 | 高(突然出现) |
swap |
立即用备用字体,加载完换 | 中(宽度变化) |
fallback |
100ms 不可见,然后备用字体 | 低 |
optional |
100ms 内没加载完就用备用字体,不再换 | 无 |
optional 是 CLS 最优解,代价是慢网络下用户永远看不到自定义字体。
另一个大问题:中文字体文件太大。 一个字重的中文字体(比如思源黑体)完整版 8~15 MB。本次优化就把 Google Fonts 的引用整个去掉了——那是几个拉丁字体的引用,对中文站没意义,还拖慢了首屏。
中文字体要么用系统字体栈(-apple-system, "PingFang SC", "Microsoft YaHei"),要么用子集化(fonttools pyftsubset)只保留用到的字。
五、INP:交互响应
INP 慢的根因几乎都是主线程被长任务占用。
定位长任务
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) { // 超过 50ms 就是长任务
console.log('长任务:', entry.duration.toFixed(0), 'ms')
// 看它做了什么
entry.attribution?.forEach(a => console.log(' ', a.name, a.containerType))
}
}
}).observe({ type: 'longtask', buffered: true })
常见成因与修法
1. 一次性渲染大量 DOM
一个 2000 行的表格,用 v-for 直接渲染 → 主线程卡 2 秒。
修法:虚拟滚动(vue-virtual-scroller / @tanstack/vue-virtual),只渲染视口内的 20 行。
2. 大 JSON 解析 + 响应式包装
// 差:10 MB JSON 解析 + reactive 深度代理
const data = reactive(JSON.parse(hugeJson))
// 好:标记为 raw,或者用 shallowRef
const data = markRaw(JSON.parse(hugeJson))
reactive() 的深度代理在 10 万条数据上是灾难级的。用 shallowRef 或 markRaw。
3. 事件处理没防抖
// 差:输入框每次按键都搜索
<input @input="search">
// 好:防抖 300ms
<input @input="debouncedSearch">
4. 长任务切片
// 差:一次性处理 10 万条,卡 800ms
for (const item of hugeList) { process(item) }
// 好:用 requestIdleCallback 切片
function processInChunks(list, chunkSize = 500) {
let i = 0
function step() {
const end = Math.min(i + chunkSize, list.length)
for (; i < end; i++) process(list[i])
if (i < list.length) {
requestIdleCallback(step, { timeout: 1000 })
}
}
step()
}
5. 第三方脚本是隐形杀手
统计、客服、广告、A/B 测试脚本经常在主线程做重活。检查方法:
// 看哪个脚本占了最多主线程时间
performance.getEntriesByType('resource')
.filter(r => r.initiatorType === 'script')
.sort((a, b) => b.duration - a.duration)
.slice(0, 10)
.forEach(r => console.log(r.name, r.duration.toFixed(0), 'ms'))
对这类脚本的处理:defer 加载、延迟到 load 之后、或者直接删掉不用的。本次优化就删掉了 3 个失效的统计脚本。
六、SEO 与可访问性:性能之外的基础项
这些不直接进 Core Web Vitals,但 Lighthouse 会扣分,而且影响真实收录。
1. h1 只能有一个
本次优化前,首页有 20 个 h1(每个文章卡片标题都是 h1)。语义完全错误——h1 应该描述页面主体。
<!-- 差:每张卡片都是 h1 -->
<h1>文章标题 A</h1>
<h1>文章标题 B</h1>
<!-- 好:页面一个 h1,卡片用 h2/h3 -->
<h1>起航社区 - 技术笔记</h1>
...
<h2><a href="/archives/a">文章标题 A</a></h2>
2. lang 属性
<html lang="zh-CN"> <!-- 缺失会影响屏幕阅读器和搜索引擎判断 -->
3. 关键 meta
<title>页面标题 - 站点名</title>
<meta name="description" content="150 字以内的页面摘要">
<link rel="canonical" href="https://example.com/page">
<!-- Open Graph(社交分享) -->
<meta property="og:title" content="页面标题">
<meta property="og:description" content="摘要">
<meta property="og:image" content="https://example.com/cover.jpg">
<meta property="og:type" content="article">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
4. JSON-LD 结构化数据
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "文章标题",
"datePublished": "2026-11-05T10:00:00+08:00",
"author": { "@type": "Person", "name": "作者" },
"image": "https://example.com/cover.jpg"
}
</script>
这里也踩了一个坑值得记:Thymeleaf 模板的 HTML 注释里不能出现 <head> 这类标签字样。我在 seo.html 顶部写了一段说明注释,注释内容包含 <head>,HTML 解析器把它当成了真实标签,注释被提前截断——结果注释里的说明文字渲染到了每个页面的可见区域。删掉注释块才解决。
5. 图片 alt
<img src="/cover.jpg" alt="Docker Compose 编排示意图">
装饰性图片用 alt=""(空字符串,不是省略),这样屏幕阅读器会跳过。
七、优化结果汇总
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 首页大小 | 59 MB | 2.27 MB | -96% |
| 附件总量 | 112 MB | 4 MB | -96% |
| LCP | 8.4s | 1.1s | 7.6× |
| FCP | 6.2s | 0.8s | 7.8× |
| CLS | 0.31 | 0.02 | 15× |
| TTFB | 1.2s | 0.4s | 3× |
| Lighthouse 性能 | 12 | 96 | — |
| 首页 h1 数量 | 20 | 1 | — |
| sitemap 坏链 | 109 | 0 | — |
八、按收益排序的优化清单
如果只能做三件事,按这个顺序:
第 1:压缩图片到正确尺寸
一张 4000px 的图放在 400px 的位置上,是 99% 站点的头号问题。mogrify -resize 一条命令,收益超过后续所有优化之和。
第 2:删除不用的第三方脚本
统计、客服、A/B 测试——每个都在抢主线程。删掉不用的,剩下的加 defer。
第 3:给所有图片加 width/height
一次性消灭 CLS。不用改设计,只加两个属性。
然后才轮到:字体优化、代码分割、CDN、HTTP/2、服务端缓存。
小结
Core Web Vitals 的三个指标对应三种完全不同的病理:
| 指标 | 病理 | 一句话修法 |
|---|---|---|
| LCP 慢 | 关键资源太大 / 太多 / 太靠后 | 压缩 LCP 那张图,其他延迟 |
| INP 高 | 主线程被长任务占满 | 切片、虚拟滚动、删第三方脚本 |
| CLS 差 | 元素尺寸在渲染后变化 | 媒体元素写死宽高比 |
先量再改。没有 Lighthouse 和 RUM 数据,任何优化都是盲猜。而这个站点的经历说明:问题往往不在你以为的地方——不是框架慢、不是服务端慢,而是 30 张没压缩的照片。
正在加载评论…