前端性能优化
前端性能直接影响用户体验和 SEO 排名。本文覆盖核心指标、加载优化、渲染优化和运行时优化。
核心指标(Web Vitals)
三大核心指标
| 指标 | 含义 | 目标 | 衡量方式 |
|---|---|---|---|
| LCP | 最大内容元素渲染完成时间 | < 2.5s | PerformanceObserver |
| INP | 交互到下一帧绘制的延迟(替代 FID) | < 200ms | PerformanceObserver |
| CLS | 页面生命周期内布局偏移总和 | < 0.1 | PerformanceObserver |
测量方式
js
// 使用 web-vitals 库(Web Vitals 是 Google 提出的用户体验指标库)
import { onLCP, onINP, onCLS } from 'web-vitals' // 引入三个核心指标的测量函数
onLCP(console.log) // 测量 LCP(最大内容绘制)并输出结果
onINP(console.log) // 测量 INP(交互到下一帧绘制延迟)并输出结果
onCLS(console.log) // 测量 CLS(累积布局偏移)并输出结果加载优化
路由懒加载
什么是路由懒加载?为什么要用?
默认情况下,所有页面的代码会打包成一个 JS 文件。用户访问首页时,需要下载整个文件(包含所有页面代码),导致首屏加载很慢。
路由懒加载把每个页面的代码分成独立的文件,只有访问该页面时才下载:
- 首屏只加载首页的代码,加载更快
- 其他页面按需加载,不浪费带宽
- 浏览器会缓存已下载的页面,下次访问更快
js
// ✅ 懒加载:访问时才加载(动态导入语法)
const Home = () => import('@/views/Home.vue') // 访问 /home 时才加载 Home.vue
const User = () => import('@/views/User.vue') // 访问 /user 时才加载 User.vue
// ❌ 同步加载:首屏加载全部页面(静态导入)
import Home from '@/views/Home.vue' // 首屏加载时就下载 Home.vue
import User from '@/views/User.vue' // 首屏加载时就下载 User.vue验证懒加载是否生效:
1. 打开浏览器 Network 面板
2. 访问首页,查看加载的 JS 文件
3. 点击路由切换到其他页面
4. 应该看到新的 JS 文件被单独加载(而不是一次性加载所有页面)组件异步加载
vue
<!-- Vue 3 script setup 中使用 defineAsyncComponent -->
<script setup>
import { defineAsyncComponent } from 'vue'
// 重量级组件延迟加载(弹窗、图表等)
const HeavyChart = defineAsyncComponent(() => import('@/components/HeavyChart.vue'))
const RichEditor = defineAsyncComponent(() => import('@/components/RichEditor.vue'))
</script>图片优化
为什么要优化图片?
图片通常占页面总体积的 60% 以上,是影响加载速度的主要因素。
三种优化方式:
- 懒加载:首屏外的图片不立即加载,等滚动到可视区域再加载,减少首屏请求数
- 响应式图片:手机加载小图,桌面加载大图,避免手机用户下载不必要的大图
- WebP 格式:比 JPEG 小 25-35%,肉眼几乎看不出区别
html
<!-- 懒加载:首屏外的图片不立即加载(loading="lazy" 是浏览器原生属性) -->
<img loading="lazy" src="photo.jpg" alt="">
<!-- 响应式图片:不同屏幕加载不同尺寸 -->
<!-- srcset 定义多张图片,sizes 定义显示条件,src 是默认图片(兼容旧浏览器) -->
<img
srcset="small.jpg 480w, medium.jpg 768w, large.jpg 1200w"
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1200px"
src="medium.jpg"
alt=""
>
<!-- WebP 格式:体积比 JPEG 小 25-35%(picture 元素提供多种格式选择) -->
<picture>
<source srcset="photo.webp" type="image/webp"> <!-- 优先使用 WebP 格式 -->
<img src="photo.jpg" alt=""> <!-- 不支持 WebP 时降级为 JPEG -->
</picture>验证图片优化是否生效:
1. 打开浏览器 Network 面板 → Img 过滤
2. 滚动页面,查看图片是否按需加载(懒加载生效)
3. 在不同设备宽度下,查看加载的图片尺寸是否正确(响应式生效)
4. 检查图片格式,WebP 格式应该优先加载预加载关键资源
html
<!-- 预加载:当前页面马上要用的关键资源 -->
<link rel="preload" href="/fonts/inter.woff2" as="font" crossorigin>
<link rel="preload" href="/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="/about.js">代码分割
什么是代码分割?为什么要手动分包?
默认情况下,Vite 会把所有代码打包成一个文件。但这样做有问题:
- 文件太大,加载慢
- 任何代码修改都会导致整个文件失效,用户要重新下载
手动分包的好处:
- 第三方库单独打包:Vue、Element Plus 等库更新频率低,单独打包后浏览器可以长期缓存
- 业务代码独立:业务代码频繁变动,单独打包不影响第三方库的缓存
- 并行加载:多个小文件可以并行下载,比一个大文件更快
js
// vite.config.ts — 手动分包(manualChunks 配置第三方库单独打包)
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: { // 手动配置代码分割策略
'vue-vendor': ['vue', 'vue-router', 'pinia'], // Vue 生态单独打包
'echarts': ['echarts'], // ECharts 单独打包(体积大)
'element-plus': ['element-plus'] // Element Plus 单独打包
}
}
}
}
})验证代码分割是否生效:
bash
# 1. 构建项目
yarn build
# 2. 查看 dist/assets/js 目录
ls dist/assets/js/
# 3. 应该看到多个独立的 JS 文件:
# - vue-vendor.xxxxx.js
# - echarts.xxxxx.js
# - element-plus.xxxxx.js
# - index.xxxxx.js(业务代码)Gzip / Brotli 压缩
什么是 Gzip/Brotli 压缩?为什么要用?
Gzip 和 Brotli 都是压缩算法,可以把 JS、CSS 等文件压缩成更小的体积,传输到浏览器后再解压。
Brotli 比 Gzip 好在哪?
- Brotli 压缩率比 Gzip 高 15-20%,文件更小
- 现代浏览器都支持 Brotli
- 缺点:压缩速度稍慢,但只在构建时压缩一次,不影响运行时
bash
npm install -D vite-plugin-compressionjs
// vite.config.ts
import compression from 'vite-plugin-compression'
export default defineConfig({
plugins: [
compression({ algorithm: 'gzip' }), // Gzip 压缩
compression({ algorithm: 'brotliCompress', ext: '.br' }) // Brotli 压缩
]
})CDN 加速
什么是 CDN?为什么要用 CDN 引入第三方库?
CDN(内容分发网络)在全球有很多节点,用户会从离自己最近的节点下载资源,速度更快。
用 CDN 引入 Vue、Axios 等库的好处:
- 加载更快:CDN 节点离用户近,网络延迟小
- 多项目共享缓存:用户访问过其他用 Vue 的网站后,Vue 的 JS 文件已经缓存,你的网站不用再下载
- 减小打包体积:这些库不打包到你的项目中,构建产物更小
js
// vite.config.ts — 外部化大依赖
export default defineConfig({
build: {
rollupOptions: {
external: ['vue', 'vue-router', 'axios', 'echarts'],
output: {
globals: {
vue: 'Vue',
'vue-router': 'VueRouter',
axios: 'axios',
echarts: 'echarts'
}
}
}
}
})html
<!-- index.html — CDN 引入(顺序需与 external 配置一致,确保全局变量在使用前定义) -->
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.prod.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-router@4/dist/vue-router.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>渲染优化
虚拟列表(大数据量)
bash
npm install @tanstack/vue-virtualvue
<script setup lang="ts">
import { ref } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
const parentRef = ref<HTMLElement>()
const items = Array.from({ length: 10000 }, (_, i) => `第 ${i} 条`)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.value!,
estimateSize: () => 35,
overscan: 5,
})
</script>
<template>
<div ref="parentRef" style="height: 400px; overflow: auto;">
<div :style="{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }">
<div
v-for="row in virtualizer.getVirtualItems()"
:key="row.key"
:style="{ position: 'absolute', top: `${row.start}px`, height: `${row.size}px`, width: '100%' }"
>
{{ items[row.index] }}
</div>
</div>
</div>
</template>验证虚拟列表是否生效:
1. 打开浏览器 Elements 面板
2. 查看列表容器中的 DOM 元素数量
3. 滚动列表,观察 DOM 元素数量是否保持稳定(不会随数据量增长)
4. 滚动应该流畅,无明显卡顿防抖与节流
js
// 防抖:连续触发只执行最后一次(搜索输入)
import { useDebounceFn } from '@vueuse/core'
const debouncedSearch = useDebounceFn((keyword) => {
fetchSearchResults(keyword)
}, 300)
// 节流:固定间隔执行一次(滚动监听)
import { useThrottleFn } from '@vueuse/core'
const throttledScroll = useThrottleFn(() => {
checkVisibility()
}, 100)v-if vs v-show
vue
<!-- v-if:条件为 false 时完全不渲染(适合切换频率低的场景) -->
<AdminPanel v-if="isAdmin" />
<!-- v-show:始终渲染,用 CSS display 控制(适合频繁切换) -->
<Tab v-show="activeTab === 'info'">信息</Tab>v-memo(Vue 3.2+)
vue
<!-- 只有 item.id 或 item.selected 变化时才重新渲染 -->
<div v-for="item in list" :key="item.id" v-memo="[item.id, item.selected]">
<p>{{ item.name }}</p>
<p>Selected: {{ item.selected }}</p>
</div>keep-alive 缓存
什么是 keep-alive?什么时候用?
默认情况下,Vue 组件在切换时会销毁再重建,这会丢失组件的状态(比如输入框的内容、滚动位置)。
keep-alive 可以缓存组件实例,切换时不会销毁,下次切换回来时直接复用。
典型场景:
- Tab 页切换:切换 Tab 时不希望丢失当前 Tab 的状态
- 列表→详情→返回:从列表进入详情页,返回时希望列表保持原来的滚动位置
- 表单填写:用户填写了表单但还没提交,切换页面后返回时希望保留已填内容
vue
<template>
<!-- 缓存组件实例,避免重复创建/销毁 -->
<router-view v-slot="{ Component }">
<keep-alive :include="cachedViews">
<component :is="Component" />
</keep-alive>
</router-view>
</template>
<script setup>
const cachedViews = ['Home', 'UserList'] // 只缓存指定页面
</script>运行时优化
computed 缓存
js
// ✅ computed 有缓存,依赖不变则不重新计算
const fullName = computed(() => {
console.log('computed 执行') // 只在 firstName 或 lastName 变化时执行
return firstName.value + ' ' + lastName.value
})
// ❌ 每次渲染都会执行
function getFullName() {
return firstName.value + ' ' + lastName.value
}shallowRef / shallowReactive
js
import { shallowRef, triggerRef } from 'vue'
// 只追踪引用变化,不深度追踪对象内部属性
// 适合大型只读数据(图表数据、表格数据)
const bigData = shallowRef({ /* 大量数据 */ })
// 修改时需要重新赋值(替换引用)
bigData.value = { ...newData }
// 或手动触发更新
bigData.value.name = 'new'
triggerRef(bigData)避免不必要的响应式
js
import { markRaw } from 'vue'
// markRaw:标记为非响应式(不会被 Vue 追踪)
// 适合不需要响应式的第三方实例
const chart = markRaw(new ECharts(dom))
const editor = markRaw(new MonacoEditor(dom))网络优化
HTTP 缓存
nginx
# Nginx 配置
# 强缓存:文件名带 hash 的资源(1 年)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# 协商缓存:index.html(每次都验证)
location = /index.html {
add_header Cache-Control "no-cache";
}接口请求优化
js
// 请求取消(切换页面时取消未完成的请求)
const controller = new AbortController()
fetch('/api/data', { signal: controller.signal })
// 页面卸载时取消
onUnmounted(() => controller.abort())
// 接口缓存(避免重复请求)
const cache = new Map()
async function fetchWithCache(url) {
if (cache.has(url)) return cache.get(url)
const data = await fetch(url).then(r => r.json())
cache.set(url, data)
return data
}验证优化效果
使用 Lighthouse 审计
1. 打开 Chrome DevTools → Lighthouse 面板
2. 选择设备:Mobile 或 Desktop
3. 选择类别:Performance
4. 点击 "Analyze page load"
5. 查看得分和优化建议使用 Performance 面板分析
1. 打开 Performance 面板
2. 点击 Record 按钮
3. 操作页面(滚动、点击、输入)
4. 点击 Stop 按钮
5. 分析火焰图,查找长任务(Long Task)验证 Web Vitals 指标
js
// 在代码中添加测量
import { onLCP, onINP, onCLS } from 'web-vitals'
onLCP(console.log) // 目标:< 2.5s
onINP(console.log) // 目标:< 200ms
onCLS(console.log) // 目标:< 0.1常见问题
首屏加载慢
可能原因及解决方案:
1. JS 文件过大 → 路由懒加载 + 代码分割
2. 图片未优化 → 使用 WebP 格式 + 懒加载
3. 第三方库过大 → CDN 引入 + 按需加载
4. 未启用压缩 → 配置 Gzip/Brotli 压缩页面卡顿
可能原因及解决方案:
1. 长任务阻塞主线程 → 拆分任务(requestIdleCallback)
2. 频繁 reflow/repaint → 批量读写 DOM
3. 大量数据渲染 → 虚拟列表
4. 频繁触发事件 → 防抖/节流内存泄漏
可能原因及解决方案:
1. 事件监听器未移除 → 在组件销毁时移除
2. 定时器未清除 → 在组件销毁时清除
3. 闭包引用大对象 → 不需要时置空引用
4. 未断开观察者 → 在组件销毁时 disconnect构建产物过大
可能原因及解决方案:
1. 未启用代码分割 → 配置 manualChunks
2. 未启用压缩 → 配置 Gzip/Brotli
3. 未启用 Tree Shaking → 确保使用 ES Module(`import/export`),避免 `require`;第三方库如 lodash 应替换为 `lodash-es`;UI 库使用按需引入(如 `unplugin-vue-components` + `ElementPlusResolver`)
4. 未排除外部依赖 → 配置 external + CDN