Core Web Vitals 实战优化:LCP/INP/CLS 三项在小内存 VPS 上的落地排查与修复清单

核心网页指标到底在测什么

Core Web Vitals 从 2021 年成为 Google 的排名信号之后,被各种「优化服务」讲得很玄。但落实到个人站长的实际操作上,它其实只有三个数字:LCP(最大内容绘制)、INP(交互到下一次绘制)、CLS(累积布局偏移)。这三个数字分别对应「打开得快不快」「点得动不动」「看着跳不跳」。

这篇文章不讲概念定义,讲的是:怎么在自己那台 1 核 2G 的 VPS 上,用最少的改动把这三个数字压进绿色区间。所有方法都在真实的 Typecho / WordPress 小站上验证过,不依赖付费 CDN 或者前端框架重构。

一、先建立可信的测量基线

优化之前必须先量。这里最大的坑是:你在自己电脑上测出来的分数,和真实用户完全不是一回事。

真实用户的数据来自 Chrome UX Report(CrUX),28 天滚动窗口,按访问量加权。它包含三类设备(桌面、手机、平板)和不同网络条件。而 Lighthouse 实验室数据是模拟的、单次冷启动的、干净的。两者差异可能高达 40%。

所以分两条线走:

  • 实验室数据(用来定位问题):PageSpeed Insights 或本地 Lighthouse,跑移动端,多跑三次取中位数。它告诉你「哪里慢」,但不代表全部用户。
  • 现场数据(用来确认真实效果):Search Console 的「核心网页指标」报告,加上自己埋的 web-vitals.js。它告诉你「多少人受影响」。

自己埋点最简单的方式是用官方库,不依赖构建工具:

<script type="module">
import {onLCP, onINP, onCLS} from 'https://unpkg.com/web-vitals@4/dist/web-vitals.attribution.js';
function send(metric) {
  navigator.sendBeacon('/vitals', JSON.stringify({
    name: metric.name,
    value: metric.value,
    id: metric.id,
    rating: metric.rating,
    path: location.pathname,
    nav: performance.getEntriesByType('navigation')[0]?.type
  }));
}
onLCP(send); onINP(send); onCLS(send);
</script>

服务端只需要收下这个 beacon 落到日志里。一周之后你就有按页面、按设备分组的真实分布,比任何第三方工具都准。注意 metric.attribution 版本的库体积略大,如果只想要数字可以用 web-vitals.js(不带 attribution)。

阈值标准(2026 年现行):

  • LCP:好 ≤ 2.5s,需改进 2.5~4.0s,差 > 4.0s
  • INP:好 ≤ 200ms,需改进 200~500ms,差 > 500ms
  • CLS:好 ≤ 0.1,需改进 0.1~0.25,差 > 0.25

注意是第 75 百分位,不是平均值。也就是说你必须让 75% 的访问都在阈值内才算通过。这意味着长尾的慢请求(老手机、弱网)会严重拉低你的分数。

二、LCP 优化:找到那个「最大元素」

LCP 慢的原因只有四种,且必须按顺序排查,因为前一步不解决,后一步优化都是白费:

  1. 服务器响应慢(TTFB):LCP 不可能快于 TTFB。如果 TTFB 是 1.2 秒,你的 LCP 就不可能在 1.5 秒内。
  2. 资源加载延迟:图片/字体被 CSS 或 JS 阻塞。
  3. 元素渲染延迟:JS 阻塞主线程,导致元素迟迟画不出来。
  4. 元素本身太大:一张 2MB 的 hero 图,即使加载很快,解码也慢。

第 1 步:先量 TTFB。用 curl 直接测:

for i in 1 2 3 4 5; do
  curl -s -o /dev/null -w "TTFB: %{time_starttransfer}s  total: %{time_total}s\n" \
    "https://www.example.com/"
done

如果 TTFB 稳定超过 600ms,先别管前端,去看 PHP-FPM 和数据库。个人站 TTFB 慢的三大原因是:没开 OPcache、MySQL 慢查询、以及没做页面级缓存。其中页面缓存收益最大——一篇文章页如果用静态 HTML 直接返回,TTFB 能从 800ms 降到 30ms。

Nginx 的 fastcgi_cache 是最省事的方案,不装插件也能做:

# nginx.conf 的 http 块
fastcgi_cache_path /var/cache/nginx/fcgi levels=1:2 keys_zone=PHP:100m inactive=60m max_size=1g;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_lock on;
fastcgi_cache_use_stale error timeout updating http_500 http_503;
fastcgi_cache_background_update on;

# server 块内
set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; }
if ($request_uri ~* "/admin|/action|/install|sitemap") { set $skip_cache 1; }

location ~ \.php$ {
    fastcgi_pass unix:/run/php/php8.2-fpm.sock;
    fastcgi_cache PHP;
    fastcgi_cache_valid 200 10m;
    fastcgi_cache_bypass $skip_cache;
    fastcgi_no_cache $skip_cache;
    add_header X-Cache $upstream_cache_status;
}

关键点是 add_header X-Cache,它让你能立刻验证缓存是否命中:

curl -sI "https://www.example.com/post-1.html" | grep -i x-cache
# 期望看到 X-Cache: HIT

第 2、4 步:图片。这是个人站最容易拿分的地方。三个动作,按投入产出排序:

  • 转 WebP/AVIF:同一张图,JPEG 200KB 转 WebP 通常 80KB,转 AVIF 50KB。用 cwebp 或 avifenc 批量转,保留原图做 fallback。
  • 显式写宽高:<img width="800" height="450">,这不是为了 CLS,也是为了让浏览器提前分配空间,LCP 判定更早完成。
  • 首屏图不要 lazy load:这是最常见的错误。给首屏 hero 图加 loading="lazy" 会人为把 LCP 推迟到 1~2 秒后。首屏图必须是 loading="eager"(默认)+ fetchpriority="high"。
<img src="/img/hero-800.webp"
     srcset="/img/hero-400.webp 400w, /img/hero-800.webp 800w, /img/hero-1200.webp 1200w"
     sizes="(max-width: 600px) 100vw, 800px"
     width="800" height="450"
     fetchpriority="high"
     decoding="sync"
     alt="文章题图">

第 3 步:JS 阻塞。个人站常见的是统计代码、广告代码、和几个 jQuery 插件。处理原则:

  • 非关键的(统计、广告)加 defer 或 async。
  • 第三方脚本可以用 requestIdleCallback 延后加载。
  • 如果某段 JS 只在一个页面上用,就只在那页引,别全局引。

检查方法是在 Chrome DevTools 的 Performance 面板录制一次加载,看主线程有没有超过 50ms 的长任务(Long Task)落在 LCP 之前。有的话点开看是谁。

三、INP 优化:让主线程空出手来

INP 取代了 FID,它衡量的是用户每一次交互的响应延迟,取最差的一次(准确说是高百分位)。对内容站来说,最常见的交互是点击菜单、切换标签、以及评论框输入。

INP 差的根因几乎总是同一个:主线程被长任务占满,用户的点击只能排队。

几个立竿见影的做法:

  1. 拆分长任务。一个大循环处理 5000 条数据,改成每 50 条 setTimeout(fn, 0) 让出主线程。
  2. 事件处理器里不要做同步重活。点击后先给视觉反馈(改 class),再 requestAnimationFrame 里做实际计算。
  3. 用事件委托而不是给每个元素绑监听。列表 500 项各绑一个 click,绑定本身就吃掉几十毫秒。
  4. 输入框防抖。搜索框每敲一个字就发请求 + 重渲染列表,是最典型的卡顿来源,加 300ms debounce。
  5. 第三方脚本隔离。广告和统计脚本尽量放 iframe,避免它们的 JS 跑在你的主线程上。

验证 INP 的办法:DevTools Performance 面板录制,然后在页面上点几下,看 Interactions 轨道上每条的 duration。超过 200ms 的就要查。

四、CLS 优化:把所有会跳的东西钉死

CLS 是最容易一次修好、永久受益的指标。它统计的是页面加载过程中,可见元素的非预期位置变化。常见的四个来源:

  • 图片没写宽高 —— 图片加载完成后撑开空间,下方内容集体下移。修法:写 width/height 属性,或者用 CSS aspect-ratio。
  • 广告位没预留空间 —— 广告加载后插入,把正文推下去。修法:给广告容器写死 min-height。
  • 字体替换导致重排 —— 自定义字体加载完成后文字宽度变化。修法:font-display: optional(不换字体)或 size-adjust 调整 fallback 字体度量。
  • 动态插入的内容在视口上方 —— 比如延迟加载的公告条。修法:要么预留空间,要么插入到视口下方。

广告位的正确写法:

<div class="ad-slot" style="min-height:280px;contain:layout">
  <ins class="adsbygoogle" ...></ins>
</div>

contain: layout 告诉浏览器这个容器的内部变化不影响外部布局,是防止广告抖动的一个额外保险。

字体的正确写法(用 size-adjust 让 fallback 字体度量接近真实字体):

@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');
  font-display: swap;
  size-adjust: 96%;
  ascent-override: 92%;
  descent-override: 22%;
}
@font-face {
  font-family: 'MyFontFallback';
  src: local('PingFang SC'), local('Microsoft YaHei');
  size-adjust: 104%;
}

对中文站来说,还有一个特有的大坑:中文字体文件动辄 5~10MB。全量加载一定会拖慢首屏。正确做法是子集化——只保留页面实际用到的字符。用 fonttools 的 pyftsubset:

pip install fonttools brotli
pyftsubset SourceHanSans.ttf \
  --text-file=常用字.txt \
  --flavor=woff2 \
  --output-file=sourcehan-subset.woff2 \
  --layout-features='*'

一个 7000 常用汉字的子集能压到 1.5MB 左右,如果只做标题字体(几百字),能压到 200KB 以内。再配合 unicode-range 分片,按需加载。

五、那些「看起来有用但其实没用」的优化

市面上流传的很多做法,对 Core Web Vitals 几乎没有影响,白费功夫:

  • 合并所有 CSS/JS 成一个大文件:HTTP/2 下多路复用已经解决了请求数问题,合并反而导致缓存粒度变粗、首字节内容要等整个包。现在更推荐按页面拆分。
  • 全站 CDN 加速:如果你的用户 90% 在国内而服务器也在国内,CDN 对 TTFB 的提升很有限,回源配置错了还更慢。先测 TTFB 再决定。
  • 追求 Lighthouse 满分:100 分不等于用户体验好。Search Console 的现场数据才是真相。
  • 给所有图片加 lazy load:首屏图 lazy 会直接恶化 LCP,这是最常见的负面优化。
  • 上 PWA / Service Worker 提速:对首次访问完全没帮助,只影响回访。个人站先别管。

六、一个真实的优化过程

拿一个 Typecho 站做例子,原始数据(移动端 CrUX,75 分位):LCP 4.3s、INP 210ms、CLS 0.18。全部不通过。

第一步,测 TTFB,发现文章页 1.1s。查 PHP-FPM 慢日志,发现是每页都在查同一个「最新文章」列表和「热门标签」,且没缓存。加 fastcgi_cache 之后 TTFB 降到 40ms。

第二步,重测 LCP,降到 2.8s。用 Performance 面板看最大元素是文章头图。检查发现那张图是 1.8MB 的 PNG,且带 loading="lazy"。转成 WebP(82KB)+ 去掉 lazy + 加 fetchpriority="high" 后,LCP 降到 1.6s。

第三步,CLS 0.18 的来源是广告位。给广告容器加 min-height:280px,CLS 降到 0.02。

第四步,INP 210ms。录制后发现点击「展开评论」时同步渲染了 200 条评论。改成先渲染 20 条 + 分页按钮,INP 降到 90ms。

整个过程只改了一个 Nginx 配置、一张图、一个 CSS 属性、一段 JS。没有重构,没有换主题,没有上付费服务。三周后 CrUX 更新,三项全绿。

七、日常巡检:别让分数悄悄退回去

Core Web Vitals 是会被「改回去」的:新装一个插件、加一个统计代码、换一张没压缩的图,分数就掉了。所以要做定期巡检。

方案一,用 PageSpeed Insights API 做定时任务,每天跑一次首页和三个热门页,把分数落库:

#!/bin/bash
KEY="你的API_KEY"
for u in "https://www.example.com/" "https://www.example.com/hot-1.html"; do
  curl -s "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=$u&strategy=mobile&key=$KEY" \
  | jq -r --arg u "$u" '"\($u) LCP=\(.loadingExperience.metrics.LARGEST_CONTENTFUL_PAINT_MS.percentile)ms CLS=\(.loadingExperience.metrics.CUMULATIVE_LAYOUT_SHIFT_SCORE.percentile) INP=\(.loadingExperience.metrics.INTERACTION_TO_NEXT_PAINT.percentile)ms"' \
  >> /var/log/cwv.log
done

方案二,用自建的 web-vitals beacon 数据,每周统计一次 P75。这个更准,但要自己写聚合逻辑。

无论哪种,关键是用 cron 跑起来、数据落盘。分数一旦从 1.6s 涨到 2.9s,你能在一周内看到,而不是等到流量掉了才发现。

八、小结

把上面所有内容压成一张检查清单,按执行顺序排列:

  1. 测 TTFB,超过 600ms 先做页面缓存。
  2. 测 LCP 元素是谁,图片就转 WebP + 去 lazy + fetchpriority,脚本就 defer。
  3. 给所有图片写 width/height,给广告位写 min-height,给字体写 font-display。
  4. 录制一次交互,找超过 200ms 的长任务,拆分或延后。
  5. 埋 web-vitals beacon,每周看 P75 趋势。

这套流程在一台普通 VPS 上,通常能把移动端三项指标从「全红」拉到「全绿」,代价是两个小时的工作量。比起买任何优化服务,这是个人站长性价比最高的投入。

Last modification:October 17th, 2026 at 10:26 pm

Leave a Comment