前端开发··2 阅读·预计 8 分钟

Vite 构建产物的依赖治理:从分包策略到按需预加载的缓存命中优化

引言

Vite 开发阶段的秒级冷启动常让人忽略它的生产构建并不替你做缓存决策。rollupOptions 留给开发者的,是一张没人填的表。其结果很典型:改了一行事业务逻辑,却因为 vendor 文件哈希变化,让用户重新下载了整包 React。本文聚焦构建产物的依赖治理,用可复现的正反例,讲清分包、预加载、哈希稳定这三件事。

一、默认打包的问题:一个 chunk 装下整个世界

Vite 生产构建默认把所有依赖和业务代码打包进少量 chunk。看一个典型构建结果:

// vite.config.js —— 反例:不做任何分包,依赖与业务耦合
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      // 留空:所有 node_modules 与业务代码挤进同一个 chunk
    }
  }
});

后果是:index.js 可能高达几 MB,且因为混入了业务代码,项目每次发版它的哈希都会变。浏览器既无法利用长期缓存,首屏还要一次性解析整个包。

二、用 manualChunks 拆出稳定的 vendor

分包的核心理念:把「变化慢」的依赖与「变化快」的业务代码分离。依赖升级少,业务迭代频繁,二者哈希独立后,业务发版只让业务 chunk 失效。

// vite.config.js —— 正例:显式拆分 vendor,维持哈希稳定
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('react') || id.includes('react-dom')) {
              return 'react-vendor';
            }
            if (id.includes('lodash-es') || id.includes('ramda')) {
              return 'utils-vendor';
            }
            return 'vendor';
          }
        }
      }
    }
  }
});

这样 react-vendor 只有在 React 真正升级时才变,utils-vendor 亦然。业务代码单独成 chunk,发版成本被隔离到最小颗粒。

三、manualChunks 的两个反模式

手工分包不是拆得越细越好,也不是把依赖全塞进同一个 chunk。

// 反例一:把常用库全塞进一个巨型 vendor
// 结果:lodash 一个小版本升级,让 React、Vue Router 全部哈希失效
if (id.includes('node_modules')) return 'vendor-all';

// 反例二:按「每个包一个 chunk」过度拆分
// 结果:产生上百个小 chunk,HTTP/2 下请求数爆炸
return id.split('node_modules/')[1].split('/')[0];

两种极端都踩坑。前者缓存粒度过粗,后者请求粒度过碎。合理做法是按「更新频率」聚类:把「一起升级的库」放一组,「各自独立升级的库」拆开。

四、动态 import:预加载时机决定首屏体验

按需加载的本质是「把非首屏代码移出初始包」,但单纯拆开而不管理预加载时机,会在路由切换时出现白屏抖动。

// 反例:路由懒加载后,切换时才发现还缺 chunk,触发二次请求
const Profile = () => import('./views/Profile.vue');
// 正例:对即将进入的视图做预取,命中时零等待
const Profile = () => import('./views/Profile.vue');

// 在用户 hover 或进入相关区域时,提前预取(构建期标记为 prefetch)
function prefetchProfile() {
  import('./views/Profile.vue'); // 触发模块图加载,但不立即执行
}

Vite 会把动态导入标记为可预取资源,配合 <link rel="modulepreload"> 在空闲时下载。关键是让「预取的时机」贴近「用户真实的下一步」,而不是一次性预取所有路由——那又退回到首屏全量的老路。

五、Vite 专属的依赖预构建与产物治理

最后回到 Vite 的特性本身:开发期的 optimizeDeps 预构建,和生产期的产物策略,是两套需要分别治理的机制。

// vite.config.js —— 正例:把常见坑收敛到显式配置
export default defineConfig({
  optimizeDeps: {
    include: ['lodash-es', 'dayjs'], // 提前预构建,避免冷启动二次扫描
    exclude: ['your-local-pkg']       // 不改动的本地包跳过预构建
  },
  build: {
    target: 'es2018',                 // 收敛编译目标,避免引入过多 polyfill
    sourcemap: false,                 // 生产默认关闭,减小体积
    cssCodeSplit: true                // CSS 按 chunk 拆分,避免首屏加载全部样式
  }
});

optimizeDeps.include 把散落的 CJS 依赖提前打包成 ESM,减少开发冷启动的「重新发现依赖」耗时;target 收敛则控制 babel/polyfill 的体积膨胀。二者一处管开发,一处管生产,方向一致:让 V8 拿到的都是它最容易优化、也最少体积的产物。

结语

Vite 的性能优势在开发期明显,生产构建的收益却要自己挣。核心是一个朴素的缓存逻辑:让浏览器尽量复用上一次的下载,让变化只影响最小范围的 chunk。手段上,manualChunks 按更新频率聚类、动态 import 按真实意图预取、哈希靠「少变动」而非「多拆分」来稳定。当依赖治理从「构建配置的玄学」变成「可解释的缓存决策」,每一次发版才真正配得上 Vite 给人的那种快。

0 评论

评论区

登录 后参与评论