Frontend · #performance#frontend#web-vitals

Web性能优化:Core Web Vitals全攻略

2025.06.18 6 min 2.6k
// 目录 · contents

引言

Web 性能直接影响用户体验和商业指标。Google 的 Core Web Vitals(CWV)已成为搜索排名因素之一,也是衡量 Web 性能的标准指标体系。本文将深入解析 LCP、FID(已被 INP 取代)、CLS 三大核心指标的优化策略,覆盖图片优化、代码分割、懒加载、资源提示、Service Worker 缓存以及性能监控体系的搭建。

Core Web Vitals 指标详解

graph LR
    A[Core Web Vitals] --> B[LCP<br/>Largest Contentful Paint<br/>最大内容绘制]
    A --> C[INP<br/>Interaction to Next Paint<br/>交互到下次绘制]
    A --> D[CLS<br/>Cumulative Layout Shift<br/>累计布局偏移]

    B --> B1["Good: ≤2.5s<br/>Needs Improvement: ≤4s<br/>Poor: >4s"]
    C --> C1["Good: ≤200ms<br/>Needs Improvement: ≤500ms<br/>Poor: >500ms"]
    D --> D1["Good: ≤0.1<br/>Needs Improvement: ≤0.25<br/>Poor: >0.25"]

    style B fill:#0cce6b,color:#000
    style C fill:#ffa400,color:#000
    style D fill:#ff4e42,color:#fff

LCP — 最大内容绘制

LCP 衡量视口内最大可见元素的渲染时间。常见的 LCP 元素包括 <img><video> 封面、CSS 背景图和大文本块。

优化策略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- 1. Preload LCP image -->
<link rel="preload" as="image" href="/hero-image.webp" fetchpriority="high" />

<!-- 2. Use fetchpriority for LCP image -->
<img
src="/hero-image.webp"
alt="Hero"
width="1200"
height="600"
fetchpriority="high"
decoding="async"
/>

<!-- 3. Preconnect to third-party origins -->
<link rel="preconnect" href="https://cdn.example.com" />
<link rel="dns-prefetch" href="https://cdn.example.com" />
1
2
3
4
5
6
7
8
9
// 4. Inline critical CSS to avoid render-blocking
// Use tools like critters or critical to extract above-the-fold CSS

// next.config.js (Next.js auto-optimizes fonts)
const nextConfig = {
experimental: {
optimizeCss: true, // Uses critters for critical CSS
},
};

INP — 交互到下次绘制

INP(取代了 FID)衡量用户交互到页面视觉响应的延迟,包括事件处理、DOM 更新和绘制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// BAD: Long task blocks main thread
button.addEventListener('click', () => {
// 200ms synchronous computation
const result = heavyComputation(data);
updateDOM(result);
});

// GOOD: Break into smaller tasks with scheduler
button.addEventListener('click', async () => {
// Yield to main thread between tasks
const chunk1 = processChunk(data.slice(0, 1000));
await scheduler.yield(); // or use setTimeout(0) as fallback

const chunk2 = processChunk(data.slice(1000, 2000));
await scheduler.yield();

updateDOM([...chunk1, ...chunk2]);
});

// GOOD: Use Web Worker for heavy computation
const worker = new Worker('/compute-worker.js');
button.addEventListener('click', () => {
worker.postMessage(data);
});
worker.addEventListener('message', (e) => {
updateDOM(e.data);
});

CLS — 累计布局偏移

CLS 衡量页面可见元素在加载过程中的意外移动程度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/* 1. Always set explicit dimensions for media */
img, video {
width: 100%;
height: auto;
aspect-ratio: 16 / 9; /* Reserves space before loading */
}

/* 2. Reserve space for dynamic content */
.ad-slot {
min-height: 250px; /* Prevent shift when ad loads */
}

/* 3. Use CSS containment */
.card {
contain: layout style;
}

/* 4. Avoid inserting content above existing content */
/* Use transform animations instead of top/left */
.toast-enter {
transform: translateY(-100%);
animation: slideIn 0.3s forwards;
}

@keyframes slideIn {
to { transform: translateY(0); }
}
1
2
3
4
5
6
7
8
// 5. Handle web font loading to avoid FOUT/FOIT
// Use font-display: swap with size-adjust
const fontFace = new FontFace('CustomFont', 'url(/fonts/custom.woff2)', {
display: 'swap',
});

document.fonts.add(fontFace);
fontFace.load();

图片优化

flowchart TD
    A[图片优化策略] --> B[格式选择]
    A --> C[响应式图片]
    A --> D[懒加载]
    A --> E[CDN + 缓存]

    B --> B1[WebP: 通用最佳]
    B --> B2[AVIF: 更高压缩率]
    B --> B3[SVG: 图标/插图]

    C --> C1[srcset + sizes]
    C --> C2["&lt;picture&gt; element"]

    D --> D1[loading='lazy']
    D --> D2[Intersection Observer]

    style A fill:#3498db,color:#fff
    style B1 fill:#2ecc71,color:#fff
    style B2 fill:#9b59b6,color:#fff
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!-- Modern responsive image with format fallback -->
<picture>
<source
srcset="/images/hero-400.avif 400w,
/images/hero-800.avif 800w,
/images/hero-1200.avif 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
type="image/avif"
/>
<source
srcset="/images/hero-400.webp 400w,
/images/hero-800.webp 800w,
/images/hero-1200.webp 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
type="image/webp"
/>
<img
src="/images/hero-800.jpg"
alt="Hero image"
width="1200"
height="600"
loading="lazy"
decoding="async"
/>
</picture>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Next.js Image component handles all of this automatically
import Image from 'next/image';

function HeroSection() {
return (
<Image
src="/images/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // For LCP imagedisables lazy loading
sizes="(max-width: 768px) 100vw, 50vw"
quality={85}
/>
);
}

代码分割

路由级分割

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// React lazy + Suspense
import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() =>
import('./pages/Analytics').then(module => ({
default: module.AnalyticsPage, // Named export
}))
);

function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
);
}

组件级分割

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Heavy component — only load when visible
const HeavyChart = lazy(() => import('./components/HeavyChart'));

function DashboardPage() {
const [showChart, setShowChart] = useState(false);

return (
<div>
<button onClick={() => setShowChart(true)}>显示图表</button>
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
)}
</div>
);
}

// Dynamic import for libraries
async function handleExport() {
// xlsx library only loaded when user clicks export
const XLSX = await import('xlsx');
const workbook = XLSX.utils.book_new();
// ...
}

资源提示与预加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!-- DNS prefetch: resolve domain early -->
<link rel="dns-prefetch" href="https://api.example.com" />

<!-- Preconnect: DNS + TCP + TLS handshake -->
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />

<!-- Preload: critical resource for current page -->
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/critical.css" as="style" />

<!-- Prefetch: resource for likely next navigation -->
<link rel="prefetch" href="/dashboard.js" />

<!-- Modulepreload: preload ES module -->
<link rel="modulepreload" href="/src/utils.js" />

<!-- Speculation Rules API: prefetch/prerender pages -->
<script type="speculationrules">
{
"prefetch": [
{
"urls": ["/dashboard", "/settings"]
}
],
"prerender": [
{
"where": {
"href_matches": "/product/*"
},
"eagerness": "moderate"
}
]
}
</script>

Service Worker 缓存策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// sw.js — Service Worker with Workbox patterns
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
CacheFirst,
StaleWhileRevalidate,
NetworkFirst,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// Precache app shell
precacheAndRoute(self.__WB_MANIFEST);

// Cache-First: static assets (fonts, images)
registerRoute(
({ request }) =>
request.destination === 'image' || request.destination === 'font',
new CacheFirst({
cacheName: 'static-assets',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 3600 }),
],
})
);

// Stale-While-Revalidate: CSS and JS
registerRoute(
({ request }) =>
request.destination === 'style' || request.destination === 'script',
new StaleWhileRevalidate({
cacheName: 'static-resources',
})
);

// Network-First: API calls
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 3,
plugins: [
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 5 * 60 }),
],
})
);
graph TD
    A[请求类型] --> B{静态资源?<br/>图片/字体}
    A --> C{CSS/JS?}
    A --> D{API 请求?}

    B -->|是| E[Cache First<br/>优先缓存]
    C -->|是| F[Stale While Revalidate<br/>先返回缓存再更新]
    D -->|是| G[Network First<br/>优先网络]

    E --> H[缓存命中 → 直接返回]
    E --> I[缓存未命中 → 网络请求 → 缓存]

    F --> J[返回缓存 + 后台更新]
    G --> K[网络请求 → 成功则返回并缓存]
    G --> L[网络失败 → 返回缓存兜底]

    style E fill:#2ecc71,color:#fff
    style F fill:#f39c12,color:#000
    style G fill:#3498db,color:#fff

性能监控

Web Vitals 上报

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Performance monitoring with web-vitals library
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';

function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
url: window.location.href,
timestamp: Date.now(),
});

// Use sendBeacon for reliability during page unload
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/analytics/vitals', body);
} else {
fetch('/api/analytics/vitals', {
method: 'POST',
body,
keepalive: true,
});
}
}

onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);

Performance Observer API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Long Task detection
const longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn('Long Task detected:', {
duration: entry.duration,
startTime: entry.startTime,
name: entry.name,
});
}
}
});

longTaskObserver.observe({ type: 'longtask', buffered: true });

// Resource loading performance
const resourceObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 1000) {
console.warn('Slow resource:', {
name: entry.name,
duration: entry.duration,
transferSize: entry.transferSize,
type: entry.initiatorType,
});
}
}
});

resourceObserver.observe({ type: 'resource', buffered: true });

性能优化检查清单

优化项 影响指标 优先级
预加载 LCP 资源 LCP
使用 WebP/AVIF 图片格式 LCP
设置图片/视频尺寸 CLS
代码分割与懒加载 INP, LCP
移除 render-blocking CSS/JS LCP
使用 CDN LCP, TTFB
Service Worker 缓存 LCP, TTFB
Web Font 优化 CLS, LCP
避免主线程长任务 INP
资源压缩(Brotli/gzip) LCP

总结

Web 性能优化是一个系统工程,需要从网络层、资源层、渲染层和监控层全方位考虑。Core Web Vitals 提供了可量化的优化目标,LCP 关注关键资源加载速度、INP 关注交互响应性、CLS 关注视觉稳定性。建议建立持续的性能监控体系,将 CWV 指标纳入 CI/CD 流程中的性能预算检查,确保性能不会随迭代退化。

作者 · authorzt
发布 · date2025-06-18
篇幅 · length2.6k 字 · 6 min
许可 · licenseCC BY-SA 4.0
$ echo "comments" · 评论