WSの小屋

前端性能优化实践:从 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 内 ⭐⭐⭐

总结

性能优化的原则是度量优先,优化其次

  1. 先用 Lighthouse + web-vitals 采集真实数据
  2. 找到影响最大的指标(通常 LCP 和 INP)
  3. 针对性优化
  4. 再度量,验证效果

不要凭感觉优化——很多看起来「应该有用」的优化实际效果微乎其微。让数据告诉你该优化什么。

Comments | 0条评论