接手一个 Vue 3 后台管理系统,首屏 LCP 6.2 秒,FCP 4.5 秒,3G 网络下基本打不开。复盘和优化用了两周时间,把首屏 LCP 压到 1.4 秒。本文实录优化过程,包含 webpack/vite 配置、按需加载、CDN、HTTP/2、骨架屏、字体优化等十几项实战。
初始基线
Lighthouse 报告(快速 3G + 中端设备):
- First Contentful Paint (FCP): 4.5s
- Largest Contentful Paint (LCP): 6.2s
- Total Blocking Time (TBT): 850ms
- Cumulative Layout Shift (CLS): 0.18
- Speed Index: 5800ms
Network 面板:
- HTML: 1.2KB
- vendor.js: 2.8MB(gzip 850KB) ← 罪魁
- main.js: 1.5MB(gzip 480KB)
- main.css: 320KB(gzip 75KB)
- 图标字体: 180KB
- 总下载: 6.4MB
优化 1:路由懒加载
// 错:全部 import 进 main bundle
import UserList from '@/views/UserList.vue'
import UserDetail from '@/views/UserDetail.vue'
import OrderList from '@/views/OrderList.vue'
// ... 100 个 view 都在 main bundle 里
const routes = [
{ path: '/users', component: UserList },
...
]
// 对:用动态 import,webpack 自动切 chunk
const routes = [
{
path: '/users',
component: () => import(/* webpackChunkName: "user" */ '@/views/UserList.vue')
},
{
path: '/users/:id',
component: () => import(/* webpackChunkName: "user" */ '@/views/UserDetail.vue')
},
{
path: '/orders',
component: () => import(/* webpackChunkName: "order" */ '@/views/OrderList.vue')
}
]
// 同 webpackChunkName 的合并到一个 chunk,按业务模块切割
优化 2:第三方库 CDN 化
// vite.config.ts:把 vue / element-plus / echarts 等大库设 external
import { defineConfig } from 'vite'
import { createHtmlPlugin } from 'vite-plugin-html'
export default defineConfig({
build: {
rollupOptions: {
external: ['vue', 'vue-router', 'pinia', 'element-plus', 'echarts'],
output: {
globals: {
vue: 'Vue',
'vue-router': 'VueRouter',
pinia: 'Pinia',
'element-plus': 'ElementPlus',
echarts: 'echarts'
}
}
}
},
plugins: [
createHtmlPlugin({
inject: {
data: {
cdnLinks: `
<link rel="dns-prefetch" href="https://cdn.example.com">
<link rel="preconnect" href="https://cdn.example.com">
<link rel="stylesheet" href="https://cdn.example.com/element-plus@2.6.0/dist/index.css">
`,
cdnScripts: `
<script src="https://cdn.example.com/vue@3.4.0/dist/vue.global.prod.js"></script>
<script src="https://cdn.example.com/vue-router@4.2.0/dist/vue-router.global.prod.js"></script>
<script src="https://cdn.example.com/pinia@2.1.0/dist/pinia.iife.prod.js"></script>
<script src="https://cdn.example.com/element-plus@2.6.0/dist/index.full.min.js"></script>
`
}
}
})
]
})
// index.html 模板
<!DOCTYPE html>
<html>
<head>
<%- cdnLinks %>
</head>
<body>
<div id="app"></div>
<%- cdnScripts %>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
效果:vendor.js 从 850KB 砍到 50KB(只剩业务依赖),CDN 资源有 cache 直接命中。
优化 3:按需引入 + Tree Shaking
// 错:全量引入 Element Plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
app.use(ElementPlus)
// 对:按需引入(unplugin-auto-import)
// vite.config.ts
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
AutoImport({
resolvers: [ElementPlusResolver()]
}),
Components({
resolvers: [ElementPlusResolver()]
})
]
})
// 业务代码直接用,不用 import
<template>
<el-button @click="onClick">Click</el-button>
</template>
// 编译时只打包 el-button 的代码和 css
// echarts 也要按需
// 错
import echarts from 'echarts' // 全量 ~800KB
// 对
import * as echarts from 'echarts/core'
import { LineChart, BarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
echarts.use([
LineChart, BarChart,
GridComponent, TooltipComponent, LegendComponent,
CanvasRenderer
])
// 只用到的图表类型和组件,~150KB
优化 4:图片优化
<!-- 错:大 PNG 直接放 -->
<img src="/banner.png" /> <!-- 1.5MB -->
<!-- 对:WebP / AVIF + 响应式 -->
<picture>
<source type="image/avif" srcset="
/banner.avif 1x,
/banner@2x.avif 2x
">
<source type="image/webp" srcset="
/banner.webp 1x,
/banner@2x.webp 2x
">
<img src="/banner.jpg" alt="" loading="lazy" decoding="async" />
</picture>
<!-- LCP 候选图片需要 priority hint -->
<img src="/hero.jpg" fetchpriority="high" />
<!-- 非首屏图片 lazy -->
<img src="/product1.jpg" loading="lazy" />
# 自动转 WebP / AVIF
$ npm install vite-plugin-imagemin -D
# vite.config.ts
import viteImagemin from 'vite-plugin-imagemin'
plugins: [
viteImagemin({
gifsicle: { optimizationLevel: 7 },
optipng: { optimizationLevel: 7 },
mozjpeg: { quality: 75 },
pngquant: { quality: [0.65, 0.9] },
webp: { quality: 75 },
svgo: { plugins: [{ name: 'removeViewBox', active: false }] }
})
]
# 效果:banner.png 1.5MB → banner.webp 280KB
优化 5:字体优化
/* 错:全字体 + 阻塞渲染 */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom-full.woff2') format('woff2');
/* 全套字体 800KB,渲染被阻塞 */
}
/* 对:font-display: swap + 子集化 */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom-cn-2500.woff2') format('woff2');
font-display: swap; /* 先用降级字体,字体加载完再换 */
unicode-range: U+4E00-9FFF; /* 只匹配中文范围 */
}
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom-en.woff2') format('woff2');
font-display: swap;
unicode-range: U+0020-007F;
}
/* preload 关键字体 */
<link rel="preload" href="/fonts/custom-cn-2500.woff2" as="font" type="font/woff2" crossorigin>
# 字体子集化:fonttools
$ pip install fonttools
$ pyftsubset custom-full.woff2 \
--unicodes=U+4E00-9FFF \
--output-file=custom-cn-2500.woff2 \
--flavor=woff2
# 字体大小:800KB → 200KB(中文常用 2500 字)
优化 6:Critical CSS 内联
// 把首屏需要的 CSS 内联到 HTML,其余异步加载
// vite-plugin-critical
import Critters from 'critters'
const critters = new Critters({
preload: 'swap',
inlineFonts: true
})
const html = await critters.process(rawHtml)
// 效果:
// 首屏 CSS 内联进 HTML(8KB),其余 CSS lazy load
// FCP 提前 500-800ms
优化 7:HTTP/2 推送 + Resource Hints
<head>
<!-- DNS 预解析 -->
<link rel="dns-prefetch" href="https://api.example.com">
<link rel="dns-prefetch" href="https://cdn.example.com">
<!-- 提前建立连接 -->
<link rel="preconnect" href="https://api.example.com" crossorigin>
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<!-- 关键资源 preload -->
<link rel="preload" href="/main.css" as="style">
<link rel="preload" href="/main.js" as="script">
<link rel="preload" href="/hero.webp" as="image">
<!-- 可能用到的资源 prefetch -->
<link rel="prefetch" href="/views/users.chunk.js">
<!-- 提前发起 API 请求,等 main.js 来用 -->
<link rel="preload" href="/api/user/profile" as="fetch" crossorigin>
</head>
优化 8:骨架屏 + Loading 状态
<!-- App.vue 里直接渲染骨架,等 Vue mount 时替换 -->
<template>
<div id="app">
<router-view v-slot="{ Component }">
<Suspense>
<component :is="Component" />
<template #fallback>
<SkeletonLoader />
</template>
</Suspense>
</router-view>
</div>
</template>
<!-- 在 index.html 写死最初的骨架,JS 加载前用户就看到内容 -->
<div id="app">
<div class="initial-skeleton">
<div class="skeleton-header"></div>
<div class="skeleton-sidebar"></div>
<div class="skeleton-content">
<div class="skeleton-row" v-for="i in 5"></div>
</div>
</div>
</div>
<style>
.initial-skeleton { /* CSS 直接写 */ }
.skeleton-row { background: linear-gradient(...); animation: shimmer 1.5s infinite; }
</style>
优化 9:接口并行 + 缓存
// 错:串行调用接口
async function loadDashboard() {
const user = await api.getUser()
const stats = await api.getStats()
const recent = await api.getRecent()
// 3 个串行 = 3 × 200ms = 600ms
}
// 对:Promise.all 并行
async function loadDashboard() {
const [user, stats, recent] = await Promise.all([
api.getUser(),
api.getStats(),
api.getRecent()
])
// 并行 200ms
}
// 进阶:SWR / vue-query 缓存
import { useQuery } from '@tanstack/vue-query'
const { data: user } = useQuery({
queryKey: ['user'],
queryFn: api.getUser,
staleTime: 5 * 60 * 1000, // 5 分钟内不重新请求
gcTime: 30 * 60 * 1000 // 30 分钟后从缓存清除
})
优化 10:打包配置
// vite.config.ts
import { defineConfig } from 'vite'
import { visualizer } from 'rollup-plugin-visualizer'
import compression from 'vite-plugin-compression'
export default defineConfig({
build: {
target: 'es2020', // 现代浏览器,产物小
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log']
}
},
rollupOptions: {
output: {
manualChunks: {
// 把固定的工具库单独切 chunk,缓存友好
'lodash': ['lodash-es'],
'date': ['dayjs'],
'utils': ['axios', 'qs', 'js-cookie']
},
chunkFileNames: 'js/[name]-[hash].js',
entryFileNames: 'js/[name]-[hash].js',
assetFileNames: ({ name }) => {
if (/\.(png|jpe?g|gif|svg|webp|avif)$/.test(name)) return 'images/[name]-[hash][extname]'
if (/\.(woff2?|eot|ttf|otf)$/.test(name)) return 'fonts/[name]-[hash][extname]'
if (/\.css$/.test(name)) return 'css/[name]-[hash][extname]'
return 'assets/[name]-[hash][extname]'
}
}
},
cssCodeSplit: true, // CSS 跟 chunk 一起 split
sourcemap: false, // 生产关闭(或上传 sentry 后删除)
reportCompressedSize: false // 加快构建
},
plugins: [
compression({
algorithm: 'gzip',
ext: '.gz'
}),
compression({
algorithm: 'brotliCompress', // brotli 更好,但需 nginx 支持
ext: '.br'
}),
visualizer({ open: true, gzipSize: true })
]
})
优化前后对比
指标 优化前 优化后
==========================================
FCP 4.5s 1.0s (-78%)
LCP 6.2s 1.4s (-77%)
TBT 850ms 180ms (-79%)
CLS 0.18 0.02
Speed Index 5800 1500 (-74%)
首屏请求数 42 8 (-81%)
首屏字节数(gzip) 1.4MB 95KB (-93%)
Lighthouse 总分 42 → 96
核对清单
- 路由懒加载 + 业务模块切 chunk
- 大库 CDN 化(vue / element / echarts)
- 按需引入 + Tree Shaking
- 图片格式 WebP / AVIF + lazy loading
- 字体子集化 + font-display: swap
- Critical CSS 内联
- preconnect / preload / prefetch 用好
- 骨架屏写到 index.html
- 接口并行 + SWR 缓存
- Brotli + gzip 双压缩
- HTTP/2 (尽量 HTTP/3)
- 关键 LCP 图加 fetchpriority="high"
首屏优化是个堆细节的活,没有银弹。这次两周的优化涉及十几项配合,LCP 从 6.2 秒降到 1.4 秒。最大的几个杠杆是:CDN 化大库(省 800KB)+ 路由懒加载(主 bundle 砍 70%)+ 关键 CSS 内联(FCP 提前 500ms)。后端 API 也跟着配 HTTP/2 + Brotli,效果叠加更明显。这套优化跑了一年,日 PV 100w 没出过性能问题。
—— 别看了 · 2026