Vue 3 + Vite 全链路性能优化实战:从 2.8MB 到 1.1MB,首屏加载从 2.4s 到 0.9s

3 阅读7分钟

一、前置准备:先找到瓶颈再动手,别瞎优化

在动手之前,先说一个原则:优化之前必须先定位瓶颈。没有数据支撑的优化就是耍流氓。

1.1 安装打包分析工具

Vite 官方推荐 rollup-plugin-visualizer,可以在打包后生成一个可视化的 HTML 报告,直观地看到每个模块的大小。

npm install rollup-plugin-visualizer --save-dev

vite.config.js 中配置:

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    vue(),
    visualizer({
      filename: 'dist/stats.html',  // 报告输出路径
      open: true,                    // 打包完成后自动打开
      gzipSize: true,                // 显示 gzip 后大小
      brotliSize: true,              // 显示 brotli 后大小
    })
  ]
})

运行 npm run build 后,浏览器会自动打开一个交互式 treemap,你可以直观地看到哪个库占的体积最大。

1.2 Chrome DevTools 定位性能瓶颈

光看打包体积还不够,运行时的性能问题同样致命。

Network 面板:查看资源加载瀑布图,找出哪个请求拖慢了整体速度。

Performance 面板:录制页面加载过程,查看 FCP、LCP、TTI 等核心指标。

Lighthouse:直接给页面打分,它会给出具体的优化建议。

// 可以在开发环境下打印关键性能指标
// main.js
if (import.meta.env.DEV) {
  new PerformanceObserver((entryList) => {
    for (const entry of entryList.getEntries()) {
      console.log(`${entry.name}: ${entry.startTime}ms`)
    }
  }).observe({ entryTypes: ['navigation', 'paint', 'largest-contentful-paint'] })
}

二、构建打包优化:产物体积缩减 60%

分析报告出来后,通常会发现三个问题:UI 库太大第三方库未按需引入所有路由打包到一个文件。下面逐一解决。

2.1 Element Plus 按需引入

很多项目用 app.use(ElementPlus) 全量注册,这会把整个库都打进去,压缩后大概 300KB+。

修改 vite.config.js

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
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: [
    vue(),
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [
        ElementPlusResolver({
          importStyle: 'sass',  // 按需引入样式
        }),
      ],
    }),
  ],
  css: {
    preprocessorOptions: {
      scss: {
        // 可以在这里覆盖 Element Plus 主题变量
        additionalData: `@use "@/styles/element-variables.scss" as *;`,
      },
    },
  },
})

移除 main.js 中的全量注册:

// main.js - 删除这两行
// import ElementPlus from 'element-plus'
// import 'element-plus/dist/index.css'

// 改为按需自动导入,不需要手动注册

这样每个组件只在使用时才会被打包,Element Plus 体积从 ~300KB 降到 ~80KB。

2.2 路由懒加载

这是最容易被忽略的优化。把所有页面都放到一个 chunk 里,首屏加载时就要下载全部页面代码,非常不划算。

改造前(全量加载):

// router/index.js - ❌ 不推荐
import HomePage from '@/views/HomePage.vue'
import LoginPage from '@/views/LoginPage.vue'
import RegisterPage from '@/views/RegisterPage.vue'
import BillingPage from '@/views/BillingPage.vue'

const routes = [
  { path: '/', component: HomePage },
  { path: '/login', component: LoginPage },
  { path: '/register', component: RegisterPage },
  { path: '/billing', component: BillingPage },
]

改造后(懒加载):

// router/index.js - ✅ 推荐
const routes = [
  { 
    path: '/', 
    component: () => import('@/views/HomePage.vue') 
  },
  { 
    path: '/login', 
    component: () => import('@/views/LoginPage.vue') 
  },
  { 
    path: '/register', 
    component: () => import('@/views/RegisterPage.vue') 
  },
  { 
    path: '/billing', 
    component: () => import('@/views/BillingPage.vue') 
  },
  // 可以配合 webpackChunkName 给 chunk 命名
  { 
    path: '/dashboard', 
    component: () => import(/* webpackChunkName: "dashboard" */ '@/views/DashboardPage.vue')
  },
]

Vite 默认支持动态 import,不需要额外配置。每个页面会被打包成独立的 chunk,首屏只加载当前页面需要的代码。

效果:首屏 JS 从 2.8MB 降到 1.6MB,直接减少 1.2MB。

2.3 第三方库 CDN 外链

一些不常更新的库(如 Vue、Element Plus),可以用 CDN 引入,不打包到项目里。使用 vite-plugin-cdn-import 插件:

npm install vite-plugin-cdn-import --save-dev
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import cdnImport from 'vite-plugin-cdn-import'

export default defineConfig({
  plugins: [
    vue(),
    cdnImport({
      modules: [
        {
          name: 'vue',
          var: 'Vue',
          path: 'https://cdn.jsdelivr.net/npm/vue@3.4.0/dist/vue.global.prod.js',
        },
        {
          name: 'element-plus',
          var: 'ElementPlus',
          path: 'https://cdn.jsdelivr.net/npm/element-plus@2.4.0/dist/index.full.min.js',
        },
        {
          name: 'axios',
          var: 'axios',
          path: 'https://cdn.jsdelivr.net/npm/axios@1.6.0/dist/axios.min.js',
        },
        {
          name: 'vue-router',
          var: 'VueRouter',
          path: 'https://cdn.jsdelivr.net/npm/vue-router@4.2.0/dist/vue-router.global.prod.js',
        },
        {
          name: 'pinia',
          var: 'Pinia',
          path: 'https://cdn.jsdelivr.net/npm/pinia@2.1.0/dist/pinia.iife.prod.js',
        },
      ],
    }),
  ],
})

注意:CDN 外链适合生产环境,开发环境建议还是用本地 node_modules,方便调试。

效果:Element Plus + Vue 等框架从 800KB 降到 0,完全由 CDN 承载。

2.4 代码分割策略

除了路由懒加载,还可以手动配置 manualChunks,把大型第三方库单独拆分:

// vite.config.js
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // 把 vue 相关库拆到 vendor-vue
          'vendor-vue': ['vue', 'vue-router', 'pinia'],
          // 把 element-plus 单独拆分
          'vendor-element': ['element-plus'],
          // 把其他大型库拆分
          'vendor-utils': ['axios', 'lodash-es', 'dayjs'],
        },
      },
    },
  },
})

这样配置后,dist/assets/ 目录下会生成多个独立的 chunk 文件:

dist/assets/
├── index-xxxxxx.js        # 业务代码 ~200KB
├── vendor-vue-xxxxxx.js   # Vue 生态 ~150KB
├── vendor-element-xxxxxx.js # Element Plus ~200KB
└── vendor-utils-xxxxxx.js # 工具库 ~100KB

浏览器可以并行下载多个文件,比单个大文件加载更快。

三、运行时性能优化

3.1 虚拟列表处理大数据量渲染

如果你的页面有长列表(比如历史记录、数据看板),直接 v-for 渲染会导致 DOM 节点过多,页面卡顿。

使用 vue-virtual-scroller 实现虚拟列表,只渲染可视区域内的 DOM 节点:

npm install vue-virtual-scroller
<template>
  <div class="history-container">
    <RecycleScroller
      class="scroller"
      :items="historyList"
      :item-size="80"
      key-field="id"
      v-slot="{ item }"
    >
      <div class="history-item">
        <span class="review">{{ item.review.slice(0, 50) }}...</span>
        <span class="time">{{ formatTime(item.created_at) }}</span>
      </div>
    </RecycleScroller>
  </div>
</template>

<script setup>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'

const historyList = ref([])  // 即使有 10000 条数据,也只渲染 20 个 DOM

// 加载数据...
</script>

<style scoped>
.scroller {
  height: 500px;
}
.history-item {
  height: 80px;
  padding: 12px;
  border-bottom: 1px solid #eee;
  display: flex;
  justify-content: space-between;
  align-items: center;
}
</style>

3.2 防抖节流实战

搜索输入、滚动事件这类高频触发的操作,必须加防抖或节流。

// utils/performance.js
export function debounce(fn, delay = 300) {
  let timer = null
  return function (...args) {
    if (timer) clearTimeout(timer)
    timer = setTimeout(() => {
      fn.apply(this, args)
      timer = null
    }, delay)
  }
}

export function throttle(fn, interval = 200) {
  let lastTime = 0
  return function (...args) {
    const now = Date.now()
    if (now - lastTime >= interval) {
      fn.apply(this, args)
      lastTime = now
    }
  }
}

在组件中使用:

<template>
  <el-input 
    v-model="keyword" 
    @input="handleSearch" 
    placeholder="搜索..."
  />
</template>

<script setup>
import { debounce } from '@/utils/performance'

// 输入停止 300ms 后才触发搜索
const handleSearch = debounce((value) => {
  fetchSearchResults(value)
}, 300)
</script>

四、资源加载优化

4.1 图片懒加载

使用 @vueuse/coreuseIntersectionObserver 实现图片懒加载:

npm install @vueuse/core
<template>
  <img 
    ref="imgRef" 
    :src="placeholderImage" 
    :data-src="realImageUrl"
    alt="product image"
  />
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'

const imgRef = ref(null)
const realImageUrl = 'https://example.com/image.jpg'
const placeholderImage = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"/>'

onMounted(() => {
  const { stop } = useIntersectionObserver(
    imgRef,
    ([{ isIntersecting }]) => {
      if (isIntersecting) {
        const img = imgRef.value
        img.src = img.dataset.src
        stop()
      }
    },
    { threshold: 0.1 }
  )
})
</script>

4.2 预加载策略

prefetch 预加载:在浏览器空闲时加载未来可能用到的资源。

preload 预加载:高优先级加载当前页面关键资源。

<!-- index.html -->
<head>
  <!-- 预加载关键字体 -->
  <link rel="preload" href="/fonts/main-font.woff2" as="font" type="font/woff2" crossorigin>
  
  <!-- 预加载首屏关键图片 -->
  <link rel="preload" href="/images/hero.webp" as="image">
  
  <!-- 预取其他路由的 chunk -->
  <link rel="prefetch" href="/assets/dashboard-chunk.js">
</head>

在 Vite 中,可以通过 vite-plugin-prefetch 自动为所有 lazy-loaded chunks 添加 prefetch 链接。

五、优化数据对比

指标优化前优化后提升
打包体积2.8 MB1.1 MB↓ 60%
首屏加载时间2.4s0.9s↓ 62%
FCP (First Contentful Paint)1.8s0.7s↓ 61%
LCP (Largest Contentful Paint)2.6s1.2s↓ 54%
Lighthouse 评分7294↑ 22分
请求数量4528↓ 38%
DOM 节点数3820540↓ 86%

实测环境:Chrome 120,Network 模拟 Fast 3G,Devices 模拟 Moto G4

六、踩坑记录

坑 1:按需引入后组件样式丢失

unplugin-vue-components 虽然能自动导入组件,但样式可能不生效。

解决方案:确保 vite.config.jsimportStyle: 'sass' 配置正确,且项目安装了 sass 依赖。

坑 2:CDN 资源跨域

部分 CDN 资源可能因为跨域策略加载失败。

解决方案:使用 crossorigin 属性,或改用国内镜像源(如 cdnjs.cloudflare.com)。

坑 3:懒加载导致路由切换闪烁

动态加载组件需要时间,切换路由时可能白屏。

解决方案:配合 Suspensev-loading 显示加载状态:

<template>
  <Suspense>
    <template #default>
      <RouterView />
    </template>
    <template #fallback>
      <div class="loading">加载中...</div>
    </template>
  </Suspense>
</template>

七、总结

这篇文章覆盖了从构建优化运行时优化的完整链路,核心思路是:

  1. 先定位瓶颈再动手:用 visualizer + Lighthouse 找到真正的问题
  2. 按需加载是关键:UI 库按需 + 路由懒加载,体积直接减半
  3. 善用 CDN:框架和大型库走 CDN,不打包到项目里
  4. 虚拟列表 + 防抖节流:运行时性能提升显著
  5. 懒加载资源:图片、chunk 都懒加载,首屏只加载必要内容

💡 这篇文章里提到的优化方案,全部来自我 6 周前上线的 Review Reply Assistant 项目。如果你想看完整项目代码,欢迎来 GitHub 交流。

有问题随时交流,我的邮箱:postmaster@zswgxl.xyz