Vite 构建优化:90 秒到 18 秒,产物 4.2 MB 到 420 KB

用 visualizer 定位到五个「全量引入」的第三方库,按需引入加分包把首屏砍掉 90%,构建时间靠关 sourcemap 和缓存降到 18 秒。

一个 Vue 3 项目,开发环境启动 1.2 秒飞快,但生产构建要 90 秒,CI 里更慢(Cold cache 时 3 分半)。产物是 4.2 MB 的 JS + 800 KB 的 CSS,首屏要等 3 秒才白屏。

这篇文章记录把这个项目从「构建 90s、产物 5MB」优化到「构建 18s、产物 380KB」的过程。每一步都有前后数据和取舍说明。

一、先看清楚慢在哪

Vite 的构建阶段叫 build,它走 Rollup。和 dev 阶段的 ESM 按需加载完全不同——build 阶段必须把所有模块打包成 bundle,所以「Vite 启动快」不代表「Vite 构建快」。

第一步是打开构建分析:

npm i -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    vue(),
    visualizer({
      filename: 'dist/stats.html',
      gzipSize: true,
      brotliSize: true,
      template: 'treemap',      // 或 'sunburst'
    }),
  ],
})

构建后打开 dist/stats.html,一眼看出哪个包最占体积。

这个项目的分析结果:

包 体积(gzip 后) 说明
echarts 1080 KB 只用到了折线图和柱状图
element-plus 720 KB 全量引入
moment 290 KB 只用了 format
lodash 240 KB 全量引入
xlsx 430 KB 只在导出功能用
业务代码 380 KB 正常

问题很明确:五个第三方库占了 2.7 MB,全是「全量引入 + 只用一小部分」。

二、第 1 步:按需引入,收益最大

ECharts:从 1080 KB 降到 180 KB

// 之前:全量引入
import * as echarts from 'echarts'

// 之后:按需注册
import * as echarts from 'echarts/core'
import { LineChart, BarChart } from 'echarts/charts'
import {
  GridComponent, TooltipComponent, LegendComponent, TitleComponent,
} from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'

echarts.use([
  LineChart, BarChart,
  GridComponent, TooltipComponent, LegendComponent, TitleComponent,
  CanvasRenderer,           // 需要 SVG 就换 SVGRenderer
])

Element Plus:从 720 KB 降到 210 KB

// vite.config.ts
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()] }),
  ],
})

配合按需样式(unplugin-element-plus 处理 ElMessage 这类命令式组件的样式)。

注意:ElMessage / ElMessageBox 这类函数式调用的组件不会被打包器识别为「使用」,需要手动引入样式:

import 'element-plus/es/components/message/style/css'

漏了这句,样式会缺失——按钮正常但弹窗没样式,是个很典型的坑。

moment → dayjs:从 290 KB 降到 7 KB

npm uninstall moment
npm i dayjs

dayjs 的 API 和 moment 基本兼容,迁移成本低:

// moment
moment().format('YYYY-MM-DD HH:mm:ss')
// dayjs
dayjs().format('YYYY-MM-DD HH:mm:ss')      // 一样

如果暂时不能替换,可以用 moment 的 ignoreMomentLocale 去掉语言包(省 100 KB),或者用 moment/min/moment-with-locales 的裁剪版。

lodash:从 240 KB 降到 12 KB

// 差:全量引入,即使 tree-shaking 也经常失效
import _ from 'lodash'
_.debounce(fn, 300)

// 好:按方法引入
import debounce from 'lodash/debounce'
import cloneDeep from 'lodash/cloneDeep'

lodash 的 ESM 版本(lodash-es)tree-shaking 效果更好:

import { debounce, cloneDeep } from 'lodash-es'

但 lodash-es 需要构建工具正确处理,CommonJS 环境下会报错。

xlsx / 重型库改成动态导入

// 之前:首屏就加载 430 KB,但 99% 的用户不会点导出
import * as XLSX from 'xlsx'

// 之后:点导出时才加载
async function exportExcel(data) {
  const { utils, writeFile } = await import('xlsx')
  const ws = utils.json_to_sheet(data)
  const wb = utils.book_new()
  utils.book_append_sheet(wb, ws, 'Sheet1')
  writeFile(wb, 'export.xlsx')
}

这一步把首屏体积直接减掉 430 KB,代价是用户点导出时多等 200ms。

三、第 2 步:手动分包,改善缓存

默认配置下,Vite 会把所有依赖打成一个 vendor.js。任何一个库升级,整个 vendor 的 hash 都变,用户的缓存全部失效。

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // 框架层:几乎不变
          'vendor-vue': ['vue', 'vue-router', 'pinia'],
          // UI 层:偶尔变
          'vendor-ui': ['element-plus'],
          // 图表:单独一个,首页可能用不到
          'vendor-chart': ['echarts'],
          // 工具库
          'vendor-utils': ['dayjs', 'lodash-es', 'axios'],
        },
      },
    },
  },
})

分包的原则是「变更频率相近的放一起」:

  • 框架(vue/vue-router/pinia):一年变几次 → 独立 chunk,长期缓存
  • UI 库:半年升级一次 → 独立 chunk
  • 业务代码:每周发版 → 主 chunk

效果:发版后用户只需要重新下载 80 KB 的业务代码,其余 1.2 MB 走缓存。

注意 manualChunks 的坑:如果两个 chunk 互相引用,Rollup 会报循环依赖警告,或者产出意料之外的共享 chunk。用对象形式(而不是函数形式)时尤其容易出问题。稳妥做法是函数形式:

manualChunks(id) {
  if (id.includes('node_modules')) {
    if (id.includes('echarts')) return 'vendor-chart'
    if (id.includes('element-plus')) return 'vendor-ui'
    return 'vendor'
  }
}

四、第 3 步:构建速度优化

3.1 关掉不必要的 sourcemap

build: {
  sourcemap: false,      // 生产默认 false,但要检查
}

sourcemap 会让产物大 2~3 倍,构建时间增加 30%。如果确实需要线上调试,用 sourcemap: 'hidden'(生成但不写引用注释),上传到 Sentry。

3.2 用 esbuild 压缩替代 terser

Vite 默认用 esbuild 压缩,但很多人手动配了 terser:

// 差:terser 比 esbuild 慢 3~5 倍
build: { minify: 'terser' }

// 好:默认值,快很多
build: { minify: 'esbuild' }

esbuild 压缩后的体积大约多 3~5%(因为不做某些激进优化),但速度快 4 倍。对于 CI 时间敏感的项目,这个交换很划算。

3.3 依赖预构建缓存

optimizeDeps: {
  include: ['vue', 'vue-router', 'element-plus', 'echarts/core'],
  exclude: ['your-local-lib'],     // 本地 link 的包不要预构建
}

include 里的包会被预构建成 ESM,避免 dev 时反复分析。CI 里要缓存 node_modules/.vite:

# GitHub Actions
- uses: actions/cache@v4
  with:
    path: |
      node_modules
      node_modules/.vite
    key: ${{ runner.os }}-vite-${{ hashFiles('**/package-lock.json') }}

3.4 多线程压缩

npm i -D vite-plugin-compression
import compression from 'vite-plugin-compression'

plugins: [
  compression({ algorithm: 'gzip', threshold: 1024 }),
  compression({ algorithm: 'brotliCompress', ext: '.br', threshold: 1024 }),
]

构建时生成 .gz 和 .br 文件,配合 Nginx 的 gzip_static / brotli_static 直接返回预压缩文件,省掉运行时的 CPU 开销。

注意:.br 压缩很慢(比 gzip 慢 5~10 倍)。如果构建时间敏感,只生成 gzip,让 Nginx 动态 brotli。

五、第 4 步:路由级代码分割

// 之前:所有路由同步引入
import Home from './views/Home.vue'
import About from './views/About.vue'
import Dashboard from './views/Dashboard.vue'

const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About },
  { path: '/dashboard', component: Dashboard },
]

// 之后:懒加载
const routes = [
  { path: '/', component: () => import('./views/Home.vue') },
  { path: '/about', component: () => import('./views/About.vue') },
  { path: '/dashboard', component: () => import('./views/Dashboard.vue') },
]

首屏加载的 chunk 只包含首页 + 框架 + 公共组件,其他路由按需下载。

配合 webpackChunkName 类似的命名(Vite 用 /* @vite-ignore */ 或 magic comment):

component: () => import(/* webpackChunkName: "dashboard" */ './views/Dashboard.vue')

Vite 里这个注释是 Rollup 的语法,会生成 dashboard-[hash].js。

一个常见的错误:把所有组件都懒加载。

// 差:一个小组件也单独成 chunk,多了一次 HTTP 请求
const Button = defineAsyncComponent(() => import('./Button.vue'))

小组件(< 20 KB)放主 chunk 更好。判断标准:这个组件是否在首屏出现?是否是高频使用的? 是就不要懒加载。

六、第 5 步:CDN 与预加载

6.1 关键资源预加载

<!-- index.html -->
<link rel="preload" href="/assets/vendor-vue-[hash].js" as="script" crossorigin>
<link rel="modulepreload" href="/assets/index-[hash].js">
<link rel="preconnect" href="https://cdn.example.com">

modulepreload 是 ESM 专用的预加载,比 preload 更精确(会预解析依赖树)。Vite 会自动为入口 chunk 注入,但异步 chunk 的首屏依赖值得手动加。

6.2 静态资源上 CDN

build: {
  rollupOptions: {
    output: {
      assetFileNames: 'assets/[name]-[hash][extname]',
      chunkFileNames: 'assets/[name]-[hash].js',
      entryFileNames: 'assets/[name]-[hash].js',
    },
  },
}
// 或者用 base 指向 CDN
base: 'https://cdn.example.com/app/',

七、优化结果

步骤 构建时间 首屏 JS(gzip) 首屏 CSS
初始 90s 4.2 MB 800 KB
ECharts 按需 78s 3.1 MB 800 KB
Element Plus 按需 62s 2.4 MB 310 KB
moment → dayjs 60s 2.1 MB 310 KB
lodash 按方法引入 58s 1.9 MB 310 KB
xlsx 动态导入 57s 1.5 MB 310 KB
路由懒加载 52s 420 KB 310 KB
手动分包 + esbuild 压缩 34s 420 KB 310 KB
关 sourcemap + 缓存 18s 420 KB 310 KB

首屏 JS 从 4.2 MB 降到 420 KB 是最大收益——注意这 90% 的降幅几乎全来自「按需引入 + 路由懒加载」,而不是压缩技巧。

构建时间从 90s 降到 18s 主要靠关 sourcemap 和缓存。

八、几个反直觉的结论

1. build.minify: 'esbuild' 比 'terser' 快 4 倍,体积只大 4%

除非你对体积有极致要求,否则用默认的 esbuild。

2. 手动分包不是越多越好

分成 10 个 chunk,浏览器要发 10 个请求。HTTP/2 能缓解,但每个请求仍有开销。经验值:首屏 chunk 3~5 个比较合适。

3. tree-shaking 对 CommonJS 包无效

moment、lodash(非 es 版本)都是 CJS,Rollup 无法静态分析,只能全量引入。这就是为什么「按需引入」在这些库上要靠手动指定路径。

4. define 替换能消除死代码

define: {
  __VUE_PROD_DEVTOOLS__: false,
  __VUE_OPTIONS_API__: false,       // 不用 Options API 时可以关
}

Vue 3 的构建产物里有 __DEV__ 分支,正确设置这些 flag 能让 Rollup 摇掉开发期代码。

5. 图片比 JS 更容易成为瓶颈

一张 2 MB 的 PNG 胜过所有 JS 优化。检查 dist 里的图片:

find dist -type f \( -name '*.png' -o -name '*.jpg' \) -size +200k

转 WebP/AVIF,用 vite-plugin-imagemin 自动处理:

import imagemin from 'vite-plugin-imagemin'

plugins: [
  imagemin({
    webp: { quality: 80 },
    pngquant: { quality: [0.7, 0.85] },
  }),
]

九、一份可直接用的配置

把上面的结论收拢成一份 vite.config.ts:

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'
import compression from 'vite-plugin-compression'

export default defineConfig({
  plugins: [
    vue(),
    AutoImport({ resolvers: [ElementPlusResolver()] }),
    Components({ resolvers: [ElementPlusResolver()] }),
    compression({ algorithm: 'gzip', threshold: 1024 }),
  ],
  define: {
    __VUE_OPTIONS_API__: false,
    __VUE_PROD_DEVTOOLS__: false,
  },
  optimizeDeps: {
    include: ['vue', 'vue-router', 'pinia', 'axios'],
  },
  build: {
    target: 'es2020',
    minify: 'esbuild',
    sourcemap: false,
    chunkSizeWarningLimit: 600,
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (!id.includes('node_modules')) return
          if (id.includes('echarts')) return 'vendor-chart'
          if (id.includes('element-plus')) return 'vendor-ui'
          if (id.includes('vue') || id.includes('pinia')) return 'vendor-vue'
          return 'vendor'
        },
        assetFileNames: 'assets/[name]-[hash][extname]',
        chunkFileNames: 'assets/[name]-[hash].js',
        entryFileNames: 'assets/[name]-[hash].js',
      },
    },
  },
})

小结

优化的收益排序很清晰:

措施 首屏体积收益 构建时间收益 难度
按需引入(ECharts/Element Plus) 极大 中 低
路由懒加载 极大 中 低
重型库动态导入 大 小 低
换轻量替代(moment→dayjs) 中大 小 中
手动分包 无 小 中
关 sourcemap 无 大 极低
缓存 node_modules 无 大 低

先量体积,再动手。 没有 visualizer 的分析,所有优化都是猜。而这个项目 90% 的体积问题,都藏在「全量引入」这四个字里。

查看「前端与性能」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。

Vite 构建优化:90 秒到 18 秒,产物 4.2 MB 到 420 KB - 起航社区