问题是怎么发现的

官网首页 Lighthouse 评分 42,首屏加载 3.2 秒。用户反馈”白屏时间太长”。打开 Chrome DevTools Network 面板一看:JS bundle 2.4MB,一张 hero 背景图 800KB。

优化后:首屏 0.8 秒,Lighthouse 95。这篇把整个过程和具体配置写清楚。

优化全景图

1
2
3
4
5
6
3.2s → 0.8s 的路径:

构建产物瘦身 → 2.4MB → 800KB (路由懒加载 + Tree Shaking)
资源加载优化 → 800KB → 300KB (Gzip + CDN)
渲染加速 → 白屏消除 (SSR / 骨架屏)
运行时优化 → 交互流畅 (v-memo / 虚拟列表)

1. 构建产物瘦身

路由懒加载

1
2
3
4
5
6
7
// router/index.ts —— 之前:全量导入
import Home from '@/pages/Home.vue'
import About from '@/pages/About.vue'

// 之后:按需加载
const Home = () => import('@/pages/Home.vue')
const About = () => import('@/pages/About.vue')

效果:首屏 JS 从 2.4MB 降到 400KB。非首屏路由的代码只在访问时才加载。

组件级异步加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup lang="ts">
// 重组件(图表、编辑器、视频播放器)按需加载
const HeavyChart = defineAsyncComponent(() => import('@/components/HeavyChart.vue'))
const RichEditor = defineAsyncComponent(() => import('@/components/RichEditor.vue'))
</script>

<template>
<Suspense>
<HeavyChart :data="chartData" />
<template #fallback>
<div class="h-80 bg-muted animate-pulse rounded-lg" />
</template>
</Suspense>
</template>

第三方库按需引入

1
2
3
4
5
6
7
8
9
10
11
12
13
// 之前:全量 import
import lodash from 'lodash'

// 之后:按需
import debounce from 'lodash-es/debounce'

// ECharts 按需
import * as echarts from 'echarts/core'
import { BarChart } from 'echarts/charts'
import { GridComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'

echarts.use([BarChart, GridComponent, CanvasRenderer])

Vite 构建分析

1
2
3
4
5
6
7
8
9
10
11
# 安装分析工具
npm i -D rollup-plugin-visualizer

# vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

export default {
plugins: [
visualizer({ open: true, gzipSize: true })
]
}

一张图看清楚谁在占体积,优先砍大户。

2. 资源加载优化

图片:WebP + 响应式 + 懒加载

1
2
3
4
5
6
7
8
9
10
11
<template>
<picture>
<source srcset="/images/hero.webp" type="image/webp" />
<img
src="/images/hero.jpg"
alt="hero"
loading="lazy"
decoding="async"
/>
</picture>
</template>

WebP 比 JPEG 小 30-50%。loading="lazy" 让屏幕外的图片不加载。

Gzip / Brotli 压缩

1
2
3
4
5
6
7
8
9
10
# nginx.conf
gzip on;
gzip_types text/css application/javascript image/svg+xml;
gzip_min_length 1000;
gzip_comp_level 6;

# Brotli 效果更好(需要 ngx_brotli 模块)
brotli on;
brotli_types text/css application/javascript;
brotli_comp_level 6;

2.4MB 的 JS 压缩后大约 600KB。

CDN + 强缓存

1
2
3
4
5
6
7
8
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}

location /images/ {
expires 30d;
}

文件名带 hash(Vite 默认行为),内容变了自动更新,不需要担心缓存过期。

预加载关键资源

1
2
3
<!-- index.html -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font" crossorigin />
<link rel="preconnect" href="https://api.example.com" />

首屏需要的字体和 API 域名提前建立连接。

3. 渲染加速

SSR / SSG(Nuxt)

Nuxt 的 SSR 让首屏 HTML 直接包含内容,不依赖 JS 执行。SEO 和首屏体验质的飞跃:

1
2
3
4
5
6
7
8
9
10
// nuxt.config.ts
export default defineNuxtConfig({
ssr: true,
nitro: {
prerender: {
routes: ['/', '/about'],
crawlLinks: true,
}
}
})

骨架屏

不能用 SSR 的场景,至少给个骨架屏,减少用户感知的白屏:

1
2
3
4
5
6
7
8
9
10
<template>
<Suspense>
<ProductList />
<template #fallback>
<div v-for="i in 4" :key="i" class="animate-pulse">
<div class="h-48 bg-muted rounded-lg" />
</div>
</template>
</Suspense>
</template>

4. 运行时优化

v-memo:跳过不需要更新的子树

1
2
3
4
5
6
<template>
<!-- 只有 item.id 和 selected 变化时才重渲染 -->
<div v-for="item in list" :key="item.id" v-memo="[item.id, selected === item.id]">
<ExpensiveComponent :item="item" :active="selected === item.id" />
</div>
</template>

列表场景,v-memo 能砍掉 80% 的无效渲染。

虚拟列表

1000+ 条数据不要全部渲染 DOM:

1
npm install vue-virtual-scroller
1
2
3
4
5
6
7
8
9
10
<template>
<RecycleScroller
:items="list"
:item-size="80"
key-field="id"
v-slot="{ item }"
>
<ListItem :data="item" />
</RecycleScroller>
</template>

只渲染可视区域 + 上下缓冲区的 DOM 节点,列表再长也不卡。

shallowRef:大对象避免深度响应式

1
2
3
4
5
// 数据量大的对象用 shallowRef,只监听引用变化
const hugeList = shallowRef<Item[]>([])

// 更新时替换整个引用
hugeList.value = [...hugeList.value, newItem]

优化效果对照

指标 优化前 优化后 提升
首屏加载 3.2s 0.8s 75%
JS Bundle (Gzip) 640KB 180KB 72%
Lighthouse 42 95 +53
FCP 2.8s 0.6s 79%
LCP 4.1s 1.5s 63%

一句话

性能优化不是玄学——分析 → 砍体积 → 优化加载 → 加速渲染 → 减少运行时开销,每一步都有明确的工具和手段。