mcpField
C
cwv-audit

cwv-audit

github.com/cognyai测试与质量11150 次安装2 周前更新
代码仓库

关于 cwv-audit

cwv-audit 是用于理解和改进 Core Web Vitals 的参考技能,面向开发者和网站负责人,涵盖指标说明、问题排查、测量方法、CrUX 查询及监控配置。

主要功能

  1. 01LCP、INP 和 CLS 阈值
  2. 02常见原因与优化指导
  3. 03真实用户及实验室测量参考
  4. 04CrUX BigQuery 查询示例
  5. 05web-vitals 与监控配置指导

适用场景

  1. 01诊断加载缓慢或页面不稳定
  2. 02使用 CrUX 数据比较网站指标
  3. 03规划 Core Web Vitals 监控
  4. 04集成 web-vitals 测量

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/cognyai/claude-code-marketing-skills --skill cwv-audit

安装后在 Claude Code 中输入/cwv-audit确认已加载。

详细介绍

Core Web Vitals 参考

关于 Core Web Vitals(LCP、INP、CLS)的完整技术参考:阈值、常见原因、修复方法、测量工具、CrUX BigQuery 查询、web-vitals 库设置,以及对 SEO 排名的影响。

完整文档:https://cogny.com/docs/core-web-vitals

用法

/cwv-audit                       # Full CWV overview and audit guidance
/cwv-audit LCP                   # Deep-dive into LCP causes and fixes
/cwv-audit INP                   # Deep-dive into INP causes and fixes
/cwv-audit CLS                   # Deep-dive into CLS causes and fixes
/cwv-audit CrUX competitor       # CrUX competitor comparison queries
/cwv-audit monitoring            # GA4 + BigQuery monitoring setup
/cwv-audit web-vitals            # web-vitals library integration guide

说明

你是一位 Core Web Vitals 性能专家。使用此参考帮助用户了解 CWV 指标、诊断性能问题、编写 CrUX BigQuery 查询、设置监控,并优化网站以改善用户体验和 SEO 排名。

用户提出问题时,找到下方相关部分,并提供准确且可执行的回答。如果有可用的 Search Console 或 GA4 MCP 工具,请使用它们获取用户网站的实际数据。

如果用户提供了具体指标或主题作为参数,请重点关注该领域。否则,请概述全部三项 Web Vitals 指标及其当前阈值。


概览

Core Web Vitals(CWV)是 Google 在搜索中用作排名信号的三项以用户为中心的性能指标。它们衡量加载性能、交互性和视觉稳定性。每个页面都按真实用户现场数据的第 75 百分位数进行评估。

指标全称衡量内容良好需要改进差
LCPLargest Contentful Paint加载性能≤ 2.5 s≤ 4.0 s> 4.0 s
INPInteraction to Next Paint交互性≤ 200 ms≤ 500 ms> 500 ms
CLSCumulative Layout Shift视觉稳定性≤ 0.1≤ 0.25> 0.25

当某页面在第 75 百分位数下的三项指标都达到“良好”阈值时,该页面即“通过” Core Web Vitals。


LCP — Largest Contentful Paint

衡量内容

LCP 报告视口中最大图像、视频或文本块的渲染时间,相对于页面首次开始加载的时间计算。

阈值
  • 良好: ≤ 2.5 秒
  • 需要改进: > 2.5 s 且 ≤ 4.0 s
  • 差: > 4.0 秒
LCP 候选元素
  • <img> 元素(包括 <picture> 内的元素)
  • <svg> 内的 <image> 元素
  • <video> 元素(海报图像或首次显示的帧)
  • 通过 url() 加载了 background-image 的元素(不包括 CSS 渐变)
  • 包含文本节点或行内级文本子元素的块级元素

页面加载时,最大的元素可能会发生变化。用户交互前的最后一条记录即为 LCP 值。

常见原因和修复方法

1. 服务器响应时间(TTFB)慢

  • 使用 CDN 从边缘节点提供内容
  • 在边缘缓存 HTML(stale-while-revalidate)
  • 预连接到所需来源:<link rel="preconnect" href="https://cdn.example.com">
  • 使用 103 Early Hints

2. 阻塞渲染的资源

  • 内联关键 CSS,延迟加载非关键样式表
  • 延迟或异步加载非关键 JavaScript:<script defer src="app.js">
  • 尽量减小 CSS 体积,避免在 CSS 中使用 @import

3. 资源加载时间长

  • 预加载 LCP 图像:<link rel="preload" as="image" href="hero.webp">
  • 使用现代格式(WebP、AVIF),并通过 <picture> 提供备用格式
  • 为 LCP <img> 设置 fetchpriority="high"
  • 使用带有 srcset 和 sizes 的响应式图像

4. 客户端渲染延迟

  • 对 LCP 内容使用 SSR 或 SSG
  • 预渲染关键路由
  • 减小 JavaScript 包体积

5. Web 字体阻塞 LCP 文本

  • 使用 font-display: swap 或 font-display: optional
  • 预加载主要字体文件
  • 对字体进行子集化,仅包含所需字符

INP — Interaction to Next Paint

衡量内容

INP 衡量整个页面生命周期中所有点击、轻触和键盘交互的延迟,并报告代表最差交互情况的单一值。它于 2024 年 3 月取代 FID,成为 Core Web Vital 指标。

阈值
  • 良好: ≤ 200 毫秒
  • 需要改进: > 200 ms 且 ≤ 500 ms
  • 差: > 500 毫秒
与 FID 的区别
FIDINP
范围仅首次交互所有交互
衡量内容仅输入延迟完整延迟:输入延迟 + 处理时间 + 呈现延迟
状态已弃用(2024 年 3 月)官方核心网页指标
交互延迟的三个阶段
  1. 输入延迟 — 从用户操作到事件处理程序开始运行的时间
  2. 处理时间 — 运行事件处理程序所花费的时间
  3. 呈现延迟 — 从处理程序执行完毕到下一次绘制的时间
常见原因及解决方案

1. 长任务阻塞主线程

  • 使用 scheduler.yield() 或 setTimeout(0) 拆分长任务
  • 使用 requestIdleCallback 处理非紧急工作
  • 将繁重的计算移至 Web Workers

2. JavaScript 执行负担过重

  • 使用动态 import() 拆分代码
  • 在构建时对未使用的导出进行摇树优化
  • 延迟加载非关键脚本和第三方标签

3. 代价高昂的事件处理程序

  • 对频繁触发的处理程序进行防抖或节流
  • 避免在处理程序中强制执行同步布局
  • 对屏幕外内容使用 content-visibility: auto

4. DOM 过大且渲染代价高昂

  • 对长列表进行虚拟化(react-window、@tanstack/virtual)
  • 使用 CSS contain: layout style paint
  • 减少 DOM 深度和元素数量

5. SPA 框架中过度重新渲染

  • 对组件进行记忆化处理(React.memo、useMemo)
  • 对非紧急状态更新使用 startTransition

CLS — 累积布局偏移

衡量内容

CLS 衡量页面生命周期内意外布局偏移分数的总和。当可见元素的位置在没有用户交互触发的情况下发生变化时,就会出现布局偏移。

阈值
  • 良好: ≤ 0.1
  • 需要改进: > 0.1 且 ≤ 0.25
  • 较差: > 0.25
会话窗口计算
  1. 将偏移分组到会话窗口中(最长 5 秒,间隔 1 秒则结束窗口)
  2. CLS = 最高会话窗口分数
  3. 每次偏移分数 = 影响比例 x 距离比例
哪些情况算作布局偏移
  • 位置变化不是由用户输入触发的
  • CSS transform 动画不计入
  • 用户交互后 500ms 内发生的偏移会被排除
常见原因及解决方案

1. 未指定尺寸的图片/视频


<img src="hero.jpg" alt="Hero" width="800" height="400">

<style>.hero-img { aspect-ratio: 16 / 9; width: 100%; }</style>

2. 动态插入的内容

.ad-slot { min-height: 250px; min-width: 300px; contain: layout style paint; }

3. Web 字体导致 FOIT/FOUT

@font-face {
  font-family: 'Custom Font';
  src: url('custom.woff2') format('woff2');
  font-display: optional; /* eliminates layout shift entirely */
}

4. 未预留空间的广告/嵌入内容

.embed-container {
  position: relative;
  padding-bottom: 56.25%; /* 16:9 */
  height: 0; overflow: hidden;
}
.embed-container iframe { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }

5. 延迟加载的横幅/ Cookie 栏

  • 使用 position: fixed 或 sticky,使其覆盖内容而不引发布局偏移

测量工具

实际用户数据
工具说明
CrUX通过 BigQuery、API 和 PageSpeed Insights 提供 28 天滚动数据
Search Console CWV report按良好/需要改进/较差对 URL 进行分组
PageSpeed Insights (field section)每个 URL/来源的 CrUX 第 75 百分位值
web-vitals library在真实浏览器中测量 CWV,并发送到任意端点
实验室数据(模拟)
工具说明
Lighthouse模拟 LCP 和 CLS(不包括 INP)
Chrome DevTools Performance panel显示所有三个指标的完整火焰图
WebPageTest提供带胶片条和瀑布图的多步骤测试

重要: 实验室工具无法完整测量 INP。务必使用实际用户数据进行验证。

JavaScript API
// Observe LCP
new PerformanceObserver((list) => {
  const entries = list.getEntries();
  console.log('LCP:', entries[entries.length - 1].startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });

// Observe CLS
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) console.log('Shift:', entry.value);
  }
}).observe({ type: 'layout-shift', buffered: true });

// Observe INP (Event Timing API)
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.interactionId) console.log('Interaction:', entry.duration, 'ms');
  }
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });

web-vitals 库

安装
npm install web-vitals
基本用法
import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(console.log);
onINP(console.log);
onCLS(console.log);
发送到 GA4
import { onLCP, onINP, onCLS } from 'web-vitals';

function sendToGA4(metric) {
  gtag('event', metric.name, {
    value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
    event_category: 'Web Vitals',
    event_label: metric.id,
    non_interaction: true,
  });
}

onLCP(sendToGA4);
onINP(sendToGA4);
onCLS(sendToGA4);
发送到自定义端点
import { onLCP, onINP, onCLS } from 'web-vitals';

function sendToAnalytics(metric) {
  const body = JSON.stringify({
    name: metric.name, value: metric.value, rating: metric.rating,
    delta: metric.delta, id: metric.id, page: window.location.pathname,
  });
  if (navigator.sendBeacon) navigator.sendBeacon('/api/vitals', body);
  else fetch('/api/vitals', { body, method: 'POST', keepalive: true });
}

onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
归因构建
import { onLCP, onINP, onCLS } from 'web-vitals/attribution';

onLCP((metric) => {
  console.log('LCP element:', metric.attribution.element);
  console.log('TTFB:', metric.attribution.timeToFirstByte);
  console.log('Resource load delay:', metric.attribution.resourceLoadDelay);
  console.log('Element render delay:', metric.attribution.elementRenderDelay);
});

onINP((metric) => {
  console.log('Target:', metric.attribution.interactionTarget);
  console.log('Input delay:', metric.attribution.inputDelay);
  console.log('Processing:', metric.attribution.processingDuration);
  console.log('Presentation delay:', metric.attribution.presentationDelay);
});

onCLS((metric) => {
  console.log('Largest shift target:', metric.attribution.largestShiftTarget);
  console.log('Largest shift value:', metric.attribution.largestShiftValue);
  console.log('Load state:', metric.attribution.loadState);
});

CrUX BigQuery 查询

按来源查看 CWV 分数
SELECT
  origin,
  form_factor.name AS device,
  ROUND(SAFE_DIVIDE(
    SUM(IF(lcp.start < 2500, lcp.density, 0)), SUM(lcp.density)
  ), 4) AS lcp_good_pct,
  ROUND(SAFE_DIVIDE(
    SUM(IF(inp.start < 200, inp.density, 0)), SUM(inp.density)
  ), 4) AS inp_good_pct,
  ROUND(SAFE_DIVIDE(
    SUM(IF(cls.start < 0.1, cls.density, 0)), SUM(cls.density)
  ), 4) AS cls_good_pct
FROM `chrome-ux-report.all.202501`,
  UNNEST(largest_contentful_paint.histogram.bin) AS lcp,
  UNNEST(interaction_to_next_paint.histogram.bin) AS inp,
  UNNEST(cumulative_layout_shift.histogram.bin) AS cls
WHERE origin = 'https://www.example.com'
GROUP BY origin, device
ORDER BY device
按页面类型查看 CWV(URL 级别)
SELECT
  url,
  form_factor.name AS device,
  ROUND(p75_lcp / 1000, 2) AS p75_lcp_sec,
  p75_inp AS p75_inp_ms,
  ROUND(p75_cls, 3) AS p75_cls
FROM `chrome-ux-report.materialized.device_summary`
WHERE origin = 'https://www.example.com'
  AND url LIKE '%/product/%'
  AND date = (SELECT MAX(date) FROM `chrome-ux-report.materialized.device_summary`)
ORDER BY p75_lcp DESC
LIMIT 50
CWV 随时间变化的趋势
SELECT
  yyyymm,
  ROUND(SAFE_DIVIDE(
    SUM(IF(lcp.start < 2500, lcp.density, 0)), SUM(lcp.density)
  ), 4) AS lcp_good_pct,
  ROUND(SAFE_DIVIDE(
    SUM(IF(inp.start < 200, inp.density, 0)), SUM(inp.density)
  ), 4) AS inp_good_pct,
  ROUND(SAFE_DIVIDE(
    SUM(IF(cls.start < 0.1, cls.density, 0)), SUM(cls.density)
  ), 4) AS cls_good_pct
FROM `chrome-ux-report.all.*`,
  UNNEST(largest_contentful_paint.histogram.bin) AS lcp,
  UNNEST(interaction_to_next_paint.histogram.bin) AS inp,
  UNNEST(cumulative_layout_shift.histogram.bin) AS cls
WHERE origin = 'https://www.example.com'
  AND _TABLE_SUFFIX BETWEEN '202401' AND '202501'
  AND form_factor.name = 'phone'
GROUP BY yyyymm
ORDER BY yyyymm
竞争对手比较
WITH origins AS (
  SELECT origin FROM UNNEST([
    'https://www.yoursite.com',
    'https://www.competitor-a.com',
    'https://www.competitor-b.com'
  ]) AS origin
)
SELECT
  o.origin, form_factor.name AS device,
  ROUND(SAFE_DIVIDE(
    SUM(IF(lcp.start < 2500, lcp.density, 0)), SUM(lcp.density)
  ), 4) AS lcp_good_pct,
  ROUND(SAFE_DIVIDE(
    SUM(IF(inp.start < 200, inp.density, 0)), SUM(inp.density)
  ), 4) AS inp_good_pct,
  ROUND(SAFE_DIVIDE(
    SUM(IF(cls.start < 0.1, cls.density, 0)), SUM(cls.density)
  ), 4) AS cls_good_pct
FROM origins o
INNER JOIN `chrome-ux-report.all.202501` crux ON crux.origin = o.origin,
  UNNEST(largest_contentful_paint.histogram.bin) AS lcp,
  UNNEST(interaction_to_next_paint.histogram.bin) AS inp,
  UNNEST(cumulative_layout_shift.histogram.bin) AS cls
WHERE form_factor.name = 'phone'
GROUP BY o.origin, device
ORDER BY o.origin, device

SEO 影响

页面体验排名信号

CWV 是 Google 页面体验信号的一部分,与 HTTPS、移动设备友好性和无侵入式插页广告并列。

  • CWV 起到决胜作用——当相关性和质量相同时,CWV 较好的页面可能排名更高
  • CWV 不是主导信号——它们不会凌驾于优质内容或反向链接之上
  • Google 使用 CrUX 的现场数据,以第 75 百分位为准(而非实验室评分)
  • 移动设备 CWV 会影响移动端排名;桌面端 CWV 会影响桌面端排名
优化优先级

优先针对移动设备进行优化——由于处理能力较低、网络速度较慢,移动设备通常限制最严格。


优化模式

图片优化
<picture>
  <source srcset="hero.avif" type="image/avif">
  <source srcset="hero.webp" type="image/webp">
  <img src="hero.jpg" alt="Hero" width="1200" height="600"
       fetchpriority="high" decoding="async">
</picture>


<img src="hero-800.webp" alt="Hero"
     srcset="hero-400.webp 400w, hero-800.webp 800w, hero-1200.webp 1200w"
     sizes="(max-width: 600px) 400px, (max-width: 900px) 800px, 1200px"
     loading="lazy" decoding="async" width="1200" height="600">

**关键规则:**切勿对 LCP 图片启用延迟加载。应在该图片上设置 fetchpriority="high"。

字体优化
<link rel="preload" as="font" type="font/woff2" href="/fonts/main.woff2" crossorigin>
@font-face {
  font-family: 'Main Font';
  src: url('/fonts/main.woff2') format('woff2');
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153;
}
JavaScript 优化
<script defer src="/js/app.js"></script>
<script async src="https://analytics.example.com/tag.js"></script>
// Dynamic import for code splitting
const Chart = await import('./components/Chart.js');

// Non-urgent work
requestIdleCallback(() => { initializeAnalytics(); });
CSS 优化
/* Limit recalculation scope */
.card { contain: layout style paint; }

/* Skip rendering off-screen content */
.below-fold { content-visibility: auto; contain-intrinsic-size: 0 500px; }
第三方脚本管理
  • 在 window.load 之后加载非关键第三方脚本
  • 对所需的第三方来源使用 <link rel="preconnect">
  • 尽可能使用服务器端标记
  • 使用 Chrome DevTools 的 Coverage 标签页审查第三方影响

GA4 + BigQuery 监控设置

将 CWV 发送到 GA4
import { onLCP, onINP, onCLS } from 'web-vitals/attribution';

function sendToGA4(metric) {
  gtag('event', metric.name, {
    value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
    event_category: 'Web Vitals',
    event_label: metric.id,
    metric_rating: metric.rating,
    debug_target: metric.attribution?.interactionTarget
      || metric.attribution?.element
      || metric.attribution?.largestShiftTarget
      || '(not set)',
    non_interaction: true,
  });
}

onLCP(sendToGA4);
onINP(sendToGA4);
onCLS(sendToGA4);
从 GA4 BigQuery 导出数据中查询 CWV
WITH vitals AS (
  SELECT
    DATE(TIMESTAMP_MICROS(event_timestamp)) AS date,
    event_name AS metric_name,
    (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'value') AS metric_value,
    (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'metric_rating') AS rating,
    (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'debug_target') AS debug_target,
    device.category AS device_type
  FROM `project.analytics_123456789.events_*`
  WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 28 DAY))
                          AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
    AND event_name IN ('LCP', 'INP', 'CLS')
)
SELECT
  date, metric_name, device_type,
  COUNT(*) AS samples,
  APPROX_QUANTILES(metric_value, 100)[OFFSET(75)] AS p75,
  COUNTIF(rating = 'good') / COUNT(*) AS good_pct,
  COUNTIF(rating = 'poor') / COUNT(*) AS poor_pct
FROM vitals
GROUP BY date, metric_name, device_type
ORDER BY date DESC, metric_name, device_type
找出表现最差的页面
WITH page_vitals AS (
  SELECT
    (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location') AS page,
    event_name AS metric_name,
    (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'value') AS metric_value,
    (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'metric_rating') AS rating,
    (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'debug_target') AS debug_target
  FROM `project.analytics_123456789.events_*`
  WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 28 DAY))
                          AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
    AND event_name IN ('LCP', 'INP', 'CLS')
)
SELECT
  REGEXP_EXTRACT(page, r'^https?://[^/]+(/.*)') AS path,
  metric_name,
  COUNT(*) AS samples,
  APPROX_QUANTILES(metric_value, 100)[OFFSET(75)] AS p75,
  COUNTIF(rating = 'poor') / COUNT(*) AS poor_pct,
  APPROX_TOP_COUNT(debug_target, 3) AS top_debug_targets
FROM page_vitals
WHERE page IS NOT NULL
GROUP BY path, metric_name
HAVING samples >= 50
ORDER BY poor_pct DESC
LIMIT 50

资源

评论

支持 Markdown