性能优化之实战指南:让你的 Vue 应⽤跑得飞起
Vue 性能优化实战指南:让你的 Vue 应⽤跑得飞起
1. 列表项 key 属性:被你误解最深的 Vue 知识点
兄弟们,key 这个属性估计是 Vue 里被误解最多的东⻄了。很多同学以为随便给个 index 就完事了,结果性能炸裂还不知道为啥。
1.1 key 的作⽤到底是什么?
Vue 的虚拟 DOM diff 算法通过 key 来判断节点是否可以复用。没有 key 或者 key 重复,Vue 会强制复用 DOM,导致性能下降甚至状态混乱。
<!-- ❌ 错误:用 index 做 key -->
<template>
<div>
<div v-for="(item, index) in list" :key="index">
{{ item.name }}
<input v-model="item.value" />
</div>
</div>
</template>
<script>
export default {
data() {
return {
list: [
{ name: '张三', value: '' },
{ name: '李四', value: '' },
{ name: '王五', value: '' }
]
}
}
}
</script>
问题: 当你删除第一个元素时,Vue 会"以为"后面的元素只是变了位置,于是把第二个元素的 DOM 复用给第一个,第三个复用给第二个...结果输入框里的值全乱了!
<!-- ✅ 正确:用唯一标识做 key -->
<template>
<div>
<div v-for="item in list" :key="item.id">
{{ item.name }}
<input v-model="item.value" />
</div>
</div>
</template>
<script>
export default {
data() {
return {
list: [
{ id: 1, name: '张三', value: '' },
{ id: 2, name: '李四', value: '' },
{ id: 3, name: '王五', value: '' }
]
}
}
}
</script>
1.2 什么时候必须用 key?
<!-- 1. v-for 必须用 -->
<template>
<div v-for="item in list" :key="item.id">{{ item.name }}</div>
</template>
<!-- 2. 条件渲染多个元素时建议用 -->
<template>
<div v-if="showForm" :key="1">表单A</div>
<div v-else :key="2">表单B</div>
</template>
1.3 key 选择指南
// ✅ 好的 key
:key="item.id" // 唯一标识,最佳选择
:key="item.uuid" // 如果有 UUID 更好
:key="`${item.type}_${item.id}`" // 组合唯一标识
// ❌ 不好的 key
:key="index" // 列表会出问题
:key="Math.random()" // 每次都变,失去复用意义
:key="item.name" // 可能重复
1.4 小贴士
-
列表只有渲染,不会增删改查,用
index也问题不大 - 列表会动态变化,必须用唯一标识
-
表格、聊天、购物车这种场景,
key选错了会出大问题 - 调试时可以用 Vue DevTools 看 diff 结果,
key对不对一目了然
2. 架构级优化:从源头解决性能问题
前面讲的都是"术",现在讲"道"。架构级优化能让你的应用从根本上快起来。
2.1 代码分割:把大蛋糕切成小块
现代打包工具(Webpack、Vite)都支持代码分割,把代码拆成多个小块,按需加载。
2.1.1 路由级别代码分割
这是最常见的优化方式,每个路由一个 chunk。
// ❌ 一次性加载所有路由组件
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
import Profile from '@/views/Profile.vue'
import Settings from '@/views/Settings.vue'
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
{ path: '/profile', component: Profile },
{ path: '/settings', component: Settings }
]
// ✅ 路由懒加载
const routes = [
{
path: '/',
component: () => import('@/views/Home.vue')
},
{
path: '/about',
component: () => import('@/views/About.vue')
},
{
path: '/profile',
component: () => import('@/views/Profile.vue')
},
{
path: '/settings',
component: () => import('@/views/Settings.vue')
}
]
打包效果:
- 首屏只加载
home.js - 用户访问
/about时才加载about.js - 首屏体积从 2MB 降到 300KB,首屏时间缩短 60%+
2.1.2 组件级别代码分割
某些大型组件(如富文本编辑器、图表库)可以按需加载。
<template>
<div>
<button @click="showEditor = true">打开编辑器</button>
<!-- 条件加载大型组件 -->
<Editor v-if="showEditor" @close="showEditor = false" />
</div>
</template>
<script>
export default {
components: {
Editor: () => import('@/components/Editor.vue')
},
data() {
return {
showEditor: false
}
}
}
</script>
2.1.3 动态导入
更灵活的按需加载方式。
// 点击按钮时才加载某个模块
async function loadFeature() {
if (needsAdvancedFeatures) {
const { default: AdvancedModule } = await import('@/features/advanced')
AdvancedModule.init()
}
}
// 根据条件加载不同的实现
async function getChartLibrary() {
if (useECharts) {
const echarts = await import('echarts')
return echarts
} else {
const chartjs = await import('chart.js')
return chartjs
}
}
2.1.4 第三方库分割
某些第三方库可以单独打包。
// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 10
},
elementUI: {
test: /[\\/]node_modules[\\/]element-ui[\\/]/,
name: 'elementUI',
priority: 20
},
commons: {
name: 'commons',
minChunks: 2,
priority: 5,
reuseExistingChunk: true
}
}
}
}
}
2.2 路由级别优化
除了代码分割,路由本身也有优化空间。
2.2.1 路由懒加载 + 预加载
// 路由配置
const routes = [
{
path: '/',
component: () => import('@/views/Home.vue')
},
{
path: '/about',
component: () => import(/* webpackPrefetch: true */ '@/views/About.vue')
},
{
path: '/profile',
component: () => import(/* webpackPreload: true */ '@/views/Profile.vue')
}
]
区别:
-
webpackPrefetch:空闲时预加载,适合"可能访问"的路由 -
webpackPreload:立即预加载,适合"即将访问"的路由
2.2.2 路由组件缓存
使用 keep-alive 缓存路由组件,避免重复渲染。
<template>
<div id="app">
<!-- 缓存所有路由组件 -->
<keep-alive>
<router-view />
</keep-alive>
<!-- 或者只缓存特定路由 -->
<keep-alive :include="['Home', 'Profile']">
<router-view />
</keep-alive>
<!-- 排除某些路由 -->
<keep-alive :exclude="['Login', 'Register']">
<router-view />
</keep-alive>
</div>
</template>
// 组件内配合使用
export default {
name: 'Home', // 必须有 name 才能被 include/exclude 匹配
data() {
return {
list: []
}
},
activated() {
// 从缓存恢复时调用
console.log('组件被激活')
this.fetchData()
},
deactivated() {
// 组件被缓存时调用
console.log('组件被停用')
}
}
2.2.3 路由守卫优化
// ❌ 重复获取数据
router.beforeEach(async (to, from, next) => {
// 每次导航都获取用户信息
const user = await fetchUser()
next()
})
// ✅ 缓存用户信息
let cachedUser = null
let lastFetchTime = 0
const CACHE_DURATION = 5 * 60 * 1000 // 5分钟
router.beforeEach(async (to, from, next) => {
const now = Date.now()
if (!cachedUser || now - lastFetchTime > CACHE_DURATION) {
cachedUser = await fetchUser()
lastFetchTime = now
}
next()
})
2.3 状态管理优化
2.3.1 Vuex 模块化
// ❌ 所有的 state 都在一个大对象里
const store = new Vuex.Store({
state: {
user: {},
products: [],
cart: [],
orders: [],
settings: {},
// ... 越来越多
}
})
// ✅ 模块化管理
const user = {
namespaced: true,
state: () => ({ currentUser: null }),
mutations: { SET_USER(state, user) { state.currentUser = user } },
actions: { async fetchUser({ commit }) { /* ... */ } }
}
const products = {
namespaced: true,
state: () => ({ list: [] }),
mutations: { SET_PRODUCTS(state, list) { state.list = list } }
}
const store = new Vuex.Store({
modules: { user, products, cart, orders }
})
2.3.2 按需注册模块
// 动态注册模块
router.beforeEach(async (to, from, next) => {
if (to.matched.some(record => record.meta.requiresAdmin)) {
await store.registerModule('admin', adminModule)
}
next()
})
// 离开时卸载模块
router.afterEach((to, from) => {
if (!to.matched.some(record => record.meta.requiresAdmin)) {
if (store.hasModule('admin')) {
store.unregisterModule('admin')
}
}
})
2.4 组件设计原则
2.4.1 组件粒度
<!-- ❌ 组件太大,职责不清 -->
<template>
<div class="user-list">
<div v-for="user in users" :key="user.id">
<img :src="user.avatar">
<div>{{ user.name }}</div>
<div>{{ user.email }}</div>
<button @click="follow(user)">关注</button>
<button @click="block(user)">拉黑</button>
<button @click="sendMessage(user)">发消息</button>
</div>
</div>
</template>
<!-- ✅ 拆分成多个小组件 -->
<template>
<UserList :users="users">
<template #default="{ user }">
<UserCard :user="user">
<template #actions>
<UserActions :user="user" />
</template>
</UserCard>
</template>
</UserList>
</template>
<!-- UserCard.vue -->
<template>
<div class="user-card">
<Avatar :src="user.avatar" />
<UserInfo :name="user.name" :email="user.email" />
<slot name="actions" />
</div>
</template>
<!-- UserActions.vue -->
<template>
<div class="actions">
<button @click="$emit('follow')">关注</button>
<button @click="$emit('block')">拉黑</button>
<button @click="$emit('message')">发消息</button>
</div>
</template>
2.4.2 避免不必要的渲染
<template>
<div>
<!-- ❌ 每次父组件更新都会重新渲染 -->
<ExpensiveComponent :data="heavyData" />
<!-- ✅ 使用计算属性缓存 -->
<ExpensiveComponent :data="processedData" />
<!-- ✅ 使用 v-once 只渲染一次 -->
<div v-once>{{ staticContent }}</div>
<!-- ✅ 使用 shouldComponentUpdate(Vue 2)或 computed(Vue 3) -->
<ExpensiveComponent v-if="shouldRender" />
</div>
</template>
<script>
export default {
data() {
return {
heavyData: largeData,
someOtherData: []
}
},
computed: {
processedData() {
return this.heavyData.map(item => ({
...item,
formatted: this.format(item)
}))
},
shouldRender() {
return this.heavyData.length > 0
}
},
methods: {
format(item) {
// 昂贵的计算
return item.value.toFixed(2)
}
}
}
</script>
3. 服务端渲染 SSR:SEO 和首屏性能的双刃剑
SSR(Server-Side Rendering)能在服务器端渲染 Vue 组件,直接返回 HTML,对 SEO 和首屏加载都有巨大提升。
3.1 SSR vs CSR
| 对比项 | CSR(客户端渲染) | SSR(服务端渲染) |
|---|---|---|
| SEO | ❌ 搜索引擎爬虫难以抓取 | ✅ 直接返回 HTML,SEO 友好 |
| 首屏时间 | ⚠️ 需要加载 JS 后才能渲染 | ✅ 首屏直接显示 HTML |
| 服务器压力 | ✅ 低,只提供静态资源 | ⚠️ 高,需要渲染页面 |
| 开发复杂度 | ✅ 简单 | ⚠️ 复杂,需要考虑同构 |
| 交互响应 | ✅ 客户端即时响应 | ⚠️ 需要注水(hydration) |
3.2 Nuxt.js 快速上手
Nuxt.js 是 Vue 的 SSR 框架,开箱即用。
# 创建 Nuxt 项目
npx create-nuxt-app my-app
cd my-app
npm run dev
3.2.1 页面自动路由
pages/
├── index.vue # / 路由
├── about.vue # /about 路由
└── users/
├── index.vue # /users 路由
└── _id.vue # /users/:id 路由
3.2.2 数据获取
<!-- pages/index.vue -->
<template>
<div>
<div v-if="loading">加载中...</div>
<div v-else>
<h1>{{ post.title }}</h1>
<p>{{ post.content }}</p>
</div>
</div>
</template>
<script>
export default {
// 服务器端渲染前获取数据
async asyncData({ params, $axios }) {
const post = await $axios.$get(`/api/posts/${params.id}`)
return { post }
},
// 或者在客户端获取数据
async fetch({ store, $axios }) {
const posts = await $axios.$get('/api/posts')
store.commit('posts/SET_POSTS', posts)
},
data() {
return {
loading: false,
post: {}
}
}
}
</script>
3.2.3 SEO 优化
<template>
<div>
<h1>{{ post.title }}</h1>
</div>
</template>
<script>
export default {
async asyncData({ $axios, params }) {
const post = await $axios.$get(`/api/posts/${params.id}`)
return { post }
},
head() {
return {
title: this.post.title,
meta: [
{ hid: 'description', name: 'description', content: this.post.excerpt },
{ hid: 'og:title', property: 'og:title', content: this.post.title },
{ hid: 'og:image', property: 'og:image', content: this.post.image }
]
}
}
}
</script>
3.3 Vue SSR 手动配置
如果你不想用 Nuxt,可以手动配置 Vue SSR。
3.3.1 服务端入口
// server.js
const express = require('express')
const { createSSRApp } = require('vue')
const { renderToString } = require('@vue/server-renderer')
const server = express()
server.get('*', async (req, res) => {
const app = createSSRApp({
data: () => ({ url: req.url }),
template: `<div>访问的 URL 是:{{ url }}</div>`
})
const appContent = await renderToString(app)
const html = `
<!DOCTYPE html>
<html>
<head><title>Vue SSR</title></head>
<body>
<div id="app">${appContent}</div>
</body>
</html>
`
res.end(html)
})
server.listen(3000)
3.3.2 客户端入口
// client.js
import { createSSRApp } from 'vue'
import { createApp } from 'vue'
const app = createSSRApp({
data: () => ({ url: window.location.pathname }),
template: `<div>访问的 URL 是:{{ url }}</div>`
})
app.mount('#app')
3.4 静态站点生成(SSG)
如果你的内容是静态的,可以用静态站点生成,比 SSR 更简单。
// nuxt.config.js
export default {
// 启用静态生成
generate: {
routes: ['/post/1', '/post/2', '/post/3']
}
}
// 或者动态生成
export default {
generate: {
async routes() {
const posts = await fetchPosts()
return posts.map(post => `/post/${post.id}`)
}
}
}
3.5 SSR 性能优化
3.5.1 缓存渲染结果
const LRU = require('lru-cache')
const ssrCache = new LRU({
max: 1000,
maxAge: 1000 * 60 * 15 // 15分钟
})
async function renderPage(url) {
// 检查缓存
const cached = ssrCache.get(url)
if (cached) {
return cached
}
// 渲染页面
const html = await renderToString(app)
// 缓存结果
ssrCache.set(url, html)
return html
}
3.5.2 流式渲染
const { renderToStream } = require('@vue/server-renderer')
server.get('*', async (req, res) => {
const stream = renderToStream(app)
res.write('<!DOCTYPE html><html><head>...')
// 流式输出
stream.pipe(res, { end: false })
stream.on('end', () => {
res.end('</html>')
})
})
3.5.3 避免在服务端执行客户端代码
<template>
<div>
<!-- ❌ 服务端没有 window -->
<div>{{ window.innerWidth }}</div>
<!-- ✅ 使用 process.client 判断 -->
<div v-if="process.client">{{ window.innerWidth }}</div>
<div v-else>服务端渲染</div>
<!-- ✅ 或者在 mounted 中获取 -->
<div>{{ screenWidth }}</div>
</div>
</template>
<script>
export default {
data() {
return {
screenWidth: 0
}
},
mounted() {
// mounted 只在客户端执行
this.screenWidth = window.innerWidth
}
}
</script>
3.6 SSR 踩过的坑
3.6.1 状态同步问题
// ❌ 服务端和客户端状态不一致
export default {
async asyncData() {
// 服务端获取数据
const data = await fetchData()
return { data }
},
mounted() {
// 客户端又获取一次,可能导致冲突
this.fetchData()
}
}
// ✅ 统一状态管理
export default {
async asyncData({ store }) {
await store.dispatch('fetchData')
return { data: store.state.data }
},
computed: {
data() {
return this.$store.state.data
}
}
}
3.6.2 Cookie 处理
// ❌ 服务端访问不到 document.cookie
async function fetchUser() {
const cookie = document.cookie // 报错
}
// ✅ 通过上下文传递 cookie
async function fetchUser(context) {
const cookie = context.req.headers.cookie
// 使用 cookie 发送请求
}
3.6.3 异步组件处理
<template>
<div>
<!-- SSR 时异步组件不会渲染 -->
<AsyncComponent />
</div>
</template>
<script>
export default {
components: {
// ✅ 使用 SSR 友好的异步组件
AsyncComponent: defineAsyncComponent({
loader: () => import('./AsyncComponent.vue'),
loadingComponent: LoadingComponent,
errorComponent: ErrorComponent,
delay: 200,
timeout: 3000
})
}
}
</script>
3.7 是否需要 SSR?
需要 SSR 的情况:
- 内容需要 SEO(博客、新闻、电商)
- 首屏加载时间要求极高
- 社交媒体分享需要预览卡片
不需要 SSR 的情况:
- 内部管理系统
- 社交媒体应用(如 Twitter)
- 游戏或富交互应用
总结
Vue 性能优化是一个系统工程,需要从多个层面入手:
- key 属性要选对,用唯一标识,别用 index
- 代码分割是标配,路由懒加载、组件按需加载
- 架构设计要合理,模块化、职责单一、避免过度渲染
- SSR 看场景使用,SEO 和首屏是刚需就上,否则别自找麻烦
- 监控要跟上,用 Vue DevTools、Lighthouse、Web Vitals 持续优化
最后,如果你觉得这篇⽂章对你有帮助,点个赞呗!如果觉得有问题,评论区喷我,我抗揍。