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 性能优化是一个系统工程,需要从多个层面入手:

  1. key 属性要选对,用唯一标识,别用 index
  2. 代码分割是标配,路由懒加载、组件按需加载
  3. 架构设计要合理,模块化、职责单一、避免过度渲染
  4. SSR 看场景使用,SEO 和首屏是刚需就上,否则别自找麻烦
  5. 监控要跟上,用 Vue DevTools、Lighthouse、Web Vitals 持续优化

最后,如果你觉得这篇⽂章对你有帮助,点个赞呗!如果觉得有问题,评论区喷我,我抗揍。

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:alixiixcom@163.com