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 的四个主要来源:

  1. 图片没写宽高 → 加载完成时撑开容器,下方内容全部下移
  2. 字体切换(FOIT/FOUT) → 自定义字体加载完成时文字宽度变化
  3. 动态插入内容 → 广告位、公告条、cookie 提示在首屏渲染后插入
  4. 用 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 张没压缩的照片。

查看「前端与性能」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。