跳到主内容

Vite 构建提速实录:预构建 + 分包策略,冷启动从 3s 压到 400ms 的完整优化路径 ​

接手一个运行了三年的中后台项目时,最直观的痛感不是线上性能,而是开发体验:vite dev 冷启动接近 3 秒,首次访问某个页面还要再卡 1 秒做按需预构建,热更新偶尔莫名其妙整页刷新。团队的共识是「Vite 已经够快了,忍忍就行」——直到某天联调高峰期,十几个人每天在启动等待上耗掉的分钟数加起来足够写半个模块,这件事才被正式立项优化。

本文记录这个项目的完整提速过程。实测环境先交代清楚:180+ npm 依赖的中后台单页应用,Windows 11 开发机 + NVMe 固态硬盘,Vite 6.x。结论先行:冷启动从约 3s 降到 400ms 左右,生产构建产物从 4.2MB 优化到 1.9MB。数字因项目而异,但优化路径是通用的。

开发期提速:读懂预构建才能治本 ​

Vite 开发期快的根源是「原生 ESM 按需编译」,但有个前提:第三方依赖必须先被转换成浏览器能直接消费的 ESM 格式,这就是 esbuild 预构建。Vite 在启动时扫描源码,发现「裸模块导入」(如 import { debounce } from 'lodash-es')就触发预构建,结果缓存在 node_modules/.vite 里。理解这个机制后,三个提速手段顺理成章:

第一,把「扫描不到」的依赖显式声明出来。动态 import 的依赖、仅在生产构建中出现的依赖、被插件转换后才现身的依赖,Vite 启动扫描时看不到,只能等首次请求时中断当前预构建、重新再来一轮,页面上表现为一次突兀的 full reload。在 optimizeDeps.include 里点名这些依赖,一次预热到位:

ts
// vite.config.ts:开发期预构建优化
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  optimizeDeps: {
    // 扫描器发现不了(动态导入/构建期生成)的依赖,显式声明让首启一次到位
    include: ['lodash-es', 'echarts', 'echarts/core', 'dayjs', 'axios'],
    // 内部 monorepo 源码包不需要预构建,保持源码形态热更新最及时
    exclude: ['@corp/shared-ui', '@corp/utils']
  },
  server: {
    // 提前预热高频访问页面,用户点击导航时模块已在内存里
    warmup: {
      clientFiles: ['./src/views/dashboard/**', './src/views/order/**']
    }
  }
})

第二,管好预构建缓存的失效条件。Vite 以 lockfile 哈希、vite 版本、配置中的 optimizeDeps 字段等作为缓存 key,任何一项变化都会触发全量重跑。团队里最常见的反模式是「感觉不对劲就 --force」:强制重建只是掩盖了问题,正确的做法是确认 pnpm-lock.yaml 是否被同事改了、node 版本是否换了。monorepo 场景还有个细节:内部包如果频繁改动且被 include 进预构建,等于每次改动都烧缓存,这类包应该放进 exclude 保持源码形态。

第三,冷启动后还有一个隐性收益点:server.warmup 让 Vite 在空闲时预先转换高频页面的模块。我们的 dashboard 首页路由很重,预热后首次进入页面的白屏时间肉眼可见地缩短,这一项配置约 5 行,收益却最容易被感知。

生产期优化:用产物分析代替拍脑袋 ​

生产构建走的是 Rollup 管线,与 esbuild 预构建是两套引擎。优化第一步永远是「看清产物里有什么」,而不是直接抄网上某份 manualChunks 配置:

ts
// vite.config.ts:生产分包与产物分析
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // 按依赖性质分桶:稳定的大体积库单独成 chunk,业务代码随路由粒度拆分
        manualChunks(id) {
          if (!id.includes('node_modules')) return            // 业务代码交给路由级动态导入
          if (id.includes('echarts')) return 'vendor-echarts' // 图表库独立,改业务不影响它的缓存
          if (id.includes('element-plus')) return 'vendor-ui' // UI 库独立
          return 'vendor'                                     // 其余小依赖合并,避免碎片请求
        }
      }
    }
  },
  plugins: [
    // 构建完成后生成 treemap 可视化报告,直接看哪个依赖占了最大矩形
    visualizer({ filename: 'dist/stats.html', gzipSize: true })
  ]
})

打开 stats.html 那一刻,问题一目了然:某个 date-fns 的 locale 文件被打进主包 300KB(全量 locale 被无差别引入)、echarts 整包 1MB 出头躺在首屏 chunk 里。处置手段分别是:改为 date-fns/locale/zh-CN 精确导入;echarts 从整包导入改为 echarts/core + 按需注册图表类型。这两处纯代码级修改就砍掉了约 1.1MB。

分包策略本身也有讲究。我们的经验是「三问」:这个 chunk 是否跨页面复用(是才有单独拆的必要)、是否频繁变更(频繁变更的与稳定库合在一起会互相污染缓存命中)、体积是否值得一次额外请求。路由级代码分割交给框架的动态 import 处理即可,manualChunks 只处理 node_modules 里的稳定大头,过度细分反而让 HTTP 请求数爆炸、加载瀑布变长。

别漏了第三条支线:生产构建自身的耗时 ​

优化视角容易只盯着「开发期启动」与「产物体积」,但生产构建本身的耗时同样在消耗团队效率。代码库膨胀后,vite build 的时长会随模块数线性增长,CI 排队与发布等待随之恶化。我们的处理有三板斧:把 build.target 显式声明为现代浏览器基线,让工具链跳过大量不必要的语法降级转换;体积敏感的 polyfill 从全量引入改为按需注入;构建分析定位最慢的插件环节(通常是体积统计类插件对大产物的重复遍历)。三项落地后,本项目构建时长回落到可接受区间,每次发版的等待焦虑同步消失——开发期体验与生产期效率,本就该在同一个优化视角里统一治理,而不是各修各的。

避坑总结 ​

  1. full reload 反复出现,先查动态依赖。页面加载中途被 Vite 发现新依赖会中断预构建并整页刷新,把动态 import 的依赖、monorepo 内部包、插件产物依赖逐个补进 optimizeDeps.include,而不是靠 --force 麻木重建。
  2. manualChunks 分出循环依赖会白屏。手动把互相引用的模块分进不同 chunk,可能造成初始化顺序错误(典型症状:Cannot access 'xxx' before initialization)。分桶按「依赖性质」而不是「业务模块」划,出现循环就先合并回去再排查引用关系。
  3. dev 正常、build 报错,多数是双引擎行为差异。开发期 esbuild 与生产期 Rollup 对 CommonJS 互操作、变量提升的处理不完全一致;合并前先跑一次 vite build + vite preview 本地验证,CI 里加构建门禁比事后救火便宜得多。
  4. 预构建缓存失效排查别只盯依赖。Node 版本切换、Vite 大版本升级、.env 中影响配置的变量、甚至 lockfile 的 diff 都会让缓存作废;团队约定 lockfile 变更必须知会,能把「今天怎么又变慢了」的疑问消掉大半。
  5. 产物优化先测量后动手。stats.html 的 treemap 直接暴露体积大头,优先处理「被整包导入的大库」(echarts、moment、lodash 类),改为按需导入往往一行 import 语句就省下数百 KB,比任何花哨的分包技巧收益都大。

写在最后 ​

这次优化全程没有引入任何新框架或插件体系,收益全部来自「读懂 Vite 的默认行为,再把偏差掰回来」:预构建机制决定了开发期的启动体感,产物分析决定了生产期的优化方向。工具的默认值是为通用场景设计的,你的项目越具体,偏离默认值的收益空间就越大。先测量、再动手、后固化成 CI 规则,是构建优化最短也最稳的路径。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。