前端性能优化实践:从 Lighthouse 到实际落地
性能优化不是一次性的工作,而是一个持续的过程。本文从真实项目出发,覆盖从性能指标、度量方法到优化手段的完整闭环,每一项都给出可落地的代码。
性能指标与度量
Core Web Vitals
Google 定义的三个核心体验指标,直接影响搜索排名:
| 指标 | 全称 | 含义 | 目标 | 差 |
|---|---|---|---|---|
| LCP | Largest Contentful Paint | 最大内容绘制时间 | < 2.5s | > 4.0s |
| INP | Interaction to Next Paint | 交互到下一次渲染 | < 200ms | > 500ms |
| CLS | Cumulative Layout Shift | 累积布局偏移 | < 0.1 | > 0.25 |
注意:Google 在 2024 年 3 月用 INP 替代了 FID 作为核心指标。INP 衡量的是整个页面生命周期中所有交互的响应速度,比 FID 更严格。
使用 Lighthouse 检测
# CLI 检测
npx lighthouse https://example.com --view --preset=desktop
# 只检测性能
npx lighthouse https://example.com --only-categories=performance
在代码中采集真实用户指标
// 使用 PerformanceObserver 采集 LCP
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries()
const lastEntry = entries[entries.length - 1]
console.log('LCP:', lastEntry.startTime, 'ms')
// 上报到监控平台
navigator.sendBeacon('/api/metrics', JSON.stringify({
name: 'LCP',
value: lastEntry.startTime,
page: location.pathname
}))
}).observe({ type: 'largest-contentful-paint', buffered: true })
// CLS
let clsValue = 0
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value
}
}
console.log('CLS:', clsValue)
}).observe({ type: 'layout-shift', buffered: true })
// INP
let maxINP = 0
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (entry.duration > maxINP) {
maxINP = entry.duration
console.log('INP:', maxINP, 'ms')
}
}
}).observe({ type: 'event', buffered: true })
使用 web-vitals 库(更简单)
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals'
function report(metric: any) {
// 上报到你的监控平台
fetch('/api/metrics', {
method: 'POST',
body: JSON.stringify({ name: metric.name, value: metric.value })
})
}
onLCP(report)
onINP(report)
onCLS(report)
onFCP(report)
onTTFB(report)
减少加载体积
代码分割:路由级
// Vue Router
const routes = [
{ path: '/about', component: () => import('./pages/About.vue') },
{ path: '/dashboard', component: () => import('./pages/Dashboard.vue') }
]
代码分割:组件级
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
// 带加载状态和错误处理的重型组件
const HeavyChart = defineAsyncComponent({
loader: () => import('@/components/HeavyChart.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // 200ms 后才显示 loading
timeout: 10000 // 10s 超时
})
</script>
第三方库按需加载
// ❌ 全量引入 lodash
import _ from 'lodash'
_.get(obj, 'a.b.c')
// ✅ 按需引入
import get from 'lodash/get'
get(obj, 'a.b.c')
// ✅ 更好:使用 es-toolkit(tree-shakeable 的 lodash 替代)
import { get } from 'es-toolkit'
分析体积来源
# Vite 项目
npx vite-bundle-visualizer
# Webpack 项目
npx webpack-bundle-analyzer dist/stats.json
图片优化
使用现代图片格式
<picture>
<source srcset="/img/photo.avif" type="image/avif" />
<source srcset="/img/photo.webp" type="image/webp" />
<img src="/img/photo.jpg" alt="描述" loading="lazy" />
</picture>
体积对比(同一张 1920x1080 图片):
| 格式 | 大小 | 说明 |
|---|---|---|
| JPEG | 350KB | 传统格式 |
| WebP | 180KB | 比 JPEG 小 49% |
| AVIF | 95KB | 比 JPEG 小 73% |
响应式图片
<img
srcset="photo-320w.webp 320w,
photo-640w.webp 640w,
photo-1280w.webp 1280w"
sizes="(max-width: 320px) 280px,
(max-width: 640px) 600px,
1200px"
src="photo-1280w.webp"
alt="响应式图片"
loading="lazy"
/>
使用 Nuxt Image 组件(Nuxt 项目)
<!-- 自动生成多种格式和尺寸 -->
<NuxtImg
src="/photo.jpg"
width="1280"
height="720"
format="webp"
sizes="sm:100vw md:50vw lg:1280px"
loading="lazy"
/>
减少 CLS(布局偏移)
为图片和嵌入内容预留空间
/* 图片必须设置宽高比 */
img {
aspect-ratio: 16 / 9;
width: 100%;
height: auto;
}
/* 广告位预留空间 */
.ad-slot {
min-height: 250px;
}
字体加载优化
/* 使用 font-display: swap 避免字体加载阻塞渲染 */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* 先用系统字体,字体加载后替换 */
}
<!-- preload 关键字体 -->
<link
rel="preload"
href="/fonts/custom.woff2"
as="font"
type="font/woff2"
crossorigin
/>
渲染优化
虚拟滚动(大列表)
<script setup lang="ts">
import { useVirtualList } from '@vueuse/core'
const data = ref(Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `Item ${i}`
})))
const { list, containerProps, wrapperProps } = useVirtualList(
data,
{ itemHeight: 60 }
)
</script>
<template>
<div v-bind="containerProps" style="height: 500px; overflow-y: auto;">
<div v-bind="wrapperProps">
<div v-for="item in list" :key="item.data.id" style="height: 60px;">
{{ item.data.name }}
</div>
</div>
</div>
</template>
减少不必要的重新渲染
<script setup lang="ts">
import { computed, shallowRef, triggerRef } from 'vue'
// shallowRef:大对象只追踪引用变化,不深层响应
const largeList = shallowRef<Item[]>([])
function addItem(item: Item) {
largeList.value.push(item)
triggerRef(largeList) // 手动触发更新
}
// v-memo:列表项只在 selected 变化时重新渲染
</script>
<template>
<div
v-for="item in largeList"
:key="item.id"
v-memo="[item.id, item.selected]"
>
<span>{{ item.name }}</span>
</div>
</template>
防抖与节流
import { useDebounceFn, useThrottleFn } from '@vueuse/core'
// 搜索输入:停止输入 300ms 后才请求
const search = useDebounceFn((q: string) => {
fetch(`/api/search?q=${q}`)
}, 300)
// 滚动事件:每 100ms 最多执行一次
const onScroll = useThrottleFn(() => {
updateScrollPosition()
}, 100)
网络优化
资源预加载
<!-- 预加载关键资源 -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/css/critical.css" as="style" />
<!-- 预连接到第三方域名 -->
<link rel="preconnect" href="https://cdn.example.com" />
<link rel="dns-prefetch" href="https://api.example.com" />
<!-- 预取下一页可能需要的资源 -->
<link rel="prefetch" href="/js/dashboard.js" />
Service Worker 缓存
// sw.js
const CACHE_NAME = 'app-v1'
const PRECACHE_URLS = ['/', '/styles.css', '/app.js']
// 安装时预缓存关键资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_URLS))
)
})
// 运行时缓存策略:网络优先,失败回退缓存
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
)
})
API 请求优化
// 请求去重:同一时间多个相同请求只发一次
const pending = new Map<string, Promise<any>>()
async function fetchOnce(url: string): Promise<any> {
if (pending.has(url)) return pending.get(url)
const promise = fetch(url).then(res => {
pending.delete(url)
return res.json()
})
pending.set(url, promise)
return promise
}
// 请求取消:页面切换时取消未完成的请求
const controller = new AbortController()
fetch('/api/large-data', { signal: controller.signal })
// 路由切换时取消
controller.abort()
SSR 优化(Nuxt)
组件级 SSR 优化
<!-- 只在客户端渲染的重型组件 -->
<ClientOnly>
<HeavyChart :data="data" />
<template #fallback>
<LoadingSpinner />
</template>
</ClientOnly>
数据预取
// Nuxt 中使用 useFetch 自动处理 SSR 数据预取
const { data } = await useFetch('/api/articles')
// SSR 时服务端获取数据,客户端 hydrate 时直接复用,不重复请求
构建优化
Vite 配置
// vite.config.ts
export default defineConfig({
build: {
// 手动分块
rollupOptions: {
output: {
manualChunks: {
'vendor-vue': ['vue', 'vue-router', 'pinia'],
'vendor-ui': ['element-plus'],
'vendor-utils': ['lodash-es', 'dayjs']
}
}
},
// chunk 大小警告阈值
chunkSizeWarningLimit: 1000,
// 启用 CSS 代码分割
cssCodeSplit: true,
// 压缩
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // 移除 console.log
drop_debugger: true
}
}
}
})
性能优化清单
按优先级排列,从投入产出比最高的开始:
| # | 优化项 | 影响 | 难度 |
|---|---|---|---|
| 1 | 开启 Gzip/Brotli 压缩 | 体积减少 70% | ⭐ |
| 2 | 图片使用 WebP/AVIF | 图片体积减少 50-73% | ⭐ |
| 3 | 路由级代码分割 | 首屏 JS 减少 50%+ | ⭐ |
| 4 | 图片设置宽高比 | CLS 降到接近 0 | ⭐ |
| 5 | 第三方库按需引入 | 体积减少 30-50% | ⭐⭐ |
| 6 | 关键字体 preload | LCP 降低 200-500ms | ⭐⭐ |
| 7 | 虚拟滚动(大列表) | INP 从秒级降到毫秒级 | ⭐⭐ |
| 8 | Service Worker 缓存 | 二次访问秒开 | ⭐⭐⭐ |
| 9 | SSR / 静态生成 | FCP 降到 500ms 内 | ⭐⭐⭐ |
总结
性能优化的原则是度量优先,优化其次:
- 先用 Lighthouse + web-vitals 采集真实数据
- 找到影响最大的指标(通常 LCP 和 INP)
- 针对性优化
- 再度量,验证效果
不要凭感觉优化——很多看起来「应该有用」的优化实际效果微乎其微。让数据告诉你该优化什么。
Comments | 0条评论