2715 字
约 9 分钟
17
S14-X16 · 构建工具链:Vite 的依赖预构建与产物优化

Vite 的快不是"打包更快",而是开发期根本不打包。开发服只对浏览器发原生 ESM,浏览器要哪个模块就请求哪个;生产期才切回 Rollup 做完整打包。两套流程各有一套坑:开发期的问题集中在依赖预构建与失效,生产期的问题集中在分包、缓存与路径。

按运行形态 → 预构建 → 别名与分包 → 产物优化 → 验证的顺序拆。


1. 两种运行形态与预构建的必要性

阶段 工具 输入 输出 特点
dev esbuild 预构建 + 原生 ESM 源码按需转换 每模块一个请求 启动快;请求数随模块数增长
build Rollup 整张模块图 少量 chunk 有 tree-shaking 与压缩

没有预构建,开发期会出两类问题:CommonJS 依赖(lodash 的 CJS 入口)不能被浏览器当 ESM 加载;依赖内部上百个小模块会让浏览器发起上百个请求。预构建用 esbuild 把每个依赖打成单个 ESM 并缓存到 node_modules/.vite,同时完成 CJS 转换。

// vite.config.ts
export default defineConfig({
  plugins: [react()],
  optimizeDeps: {
    include: ['lodash-es', 'echarts/core'],  // 扫描不到的依赖手动列为入口
    exclude: ['@my/ui'],                     // ESM 包或本地源码跳过
  },
});

判断"要不要 include"的标准:依赖是否被页面静态 import、是否 CJS、是否模块数很多。三个都不满足就不必加。


2. 预构建的失效场景

预构建缓存只在启动时计算一次,依赖集合变化后不会自动重算,于是出现"明明装了包却报 Failed to resolve import"。

场景 表现 处理
新装依赖未被发现 首次访问报解析失败,刷新后正常 重启开发服,或 --force 强制重算
扫描漏了动态 import 运行时才报错 加进 optimizeDeps.include
monorepo 里的本地包 预构建后改了源码不生效 放进 exclude,让源码走原生 ESM

清缓存用 rm -rf node_modules/.vite 或 vite --force,两者等价;要看扫描结果,跑 DEBUG=vite:deps vite,输出里能看到每个依赖被归到哪个 chunk、是否被 external——排查"某个包没被扫到"时这是唯一直接证据。


3. resolve.alias 与 tsconfig.paths 必须同步

别名有两套解析器:Vite 用 resolve.alias 在运行与构建期解析,TypeScript 用 compilerOptions.paths 做类型检查,不同步就会出现"编辑器红但能跑"或"能编译但运行时 404"。

// vite.config.ts
import { fileURLToPath, URL } from 'node:url';

export default defineConfig({
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
});
// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": { "@/*": ["src/*"] }
  }
}

两边都配的时候只看一个信号:编辑器红说明缺 paths,构建报 Could not resolve 说明缺 alias;vitest 也报解析错误,说明测试配置没复用同一份 alias。

import() 同样走 alias,但不能做字符串拼接:构建期无法静态分析,Rollup 会把它当外部依赖或直接丢掉。

// 打包不到
const mod = await import(`./views/${name}.tsx`);
// 打包得到:显式映射
const views = {
  home: () => import('./views/home.tsx'),
  about: () => import('./views/about.tsx'),
};
const mod = await views[name as keyof typeof views]();

4. 分包策略:manualChunks 与路由懒加载

默认分包只有一个入口 chunk + 少量异步 chunk,结果是"改一行业务代码,整个 vendor 的 hash 也变了",缓存全部失效。

策略 做法 收益 代价
按依赖分包 manualChunks 把 react、echarts 各自独立 业务改动不冲击三方缓存 依赖间若有交叉引用会重复打入
按路由懒加载 路由组件用 import() 首屏只加载当前路由 每次切路由多一次往返
公共依赖抽出 多个异步 chunk 共用的模块提为独立 chunk 避免重复下载 chunk 数变多,HTTP/1.1 下反而不利
export default defineConfig({
  build: { rollupOptions: { output: {
    manualChunks(id) {
      if (!id.includes('node_modules')) return;
      if (id.includes('echarts')) return 'vendor-charts';
      if (id.includes('react')) return 'vendor-react';
      return 'vendor';
    },
  } } },
});

判断分包是否合理看两点:改一行业务代码后失效的 chunk 是否只有业务那个;某个三方库是否在多个异步 chunk 里重复出现。


5. 产物的三类优化

目标 手段 副作用
体积 tree-shaking、按需引入、压缩、去掉 sourcemap 发布 过度拆分会让请求数上升
请求数 合理的 chunk 粒度、HTTP/2 多路复用、内联小资源 内联过多会拖慢首字节
缓存命中 内容 hash 文件名、vendor 与业务分离、长期缓存头 hash 与 CDN 缓存策略要一致

体积的收益顺序是先"少引"再"压缩":import { debounce } from 'lodash' 带进整包,lodash/debounce 只带一个模块;ECharts 用 echarts/core 按需注册比整包小一个量级(按 echarts 5 核对)。

请求数与体积反向:一个 2MB 的 chunk 拆成 20 个 100KB,总体积不变、请求数增加。HTTP/2 并发不受限,HTTP/1.1 超过 6 个并发就排队。

缓存命中取决于 hash 与 CDN 的 TTL 规则是否匹配。[hash] 是内容哈希,内容变了名字就变,可配长 TTL;index.html 必须短 TTL,否则用户拿到旧 HTML 引用已删除的 chunk。


6. Source Map 的选择

环境 build.sourcemap 用途 注意
开发 true(默认走 inline) 精确定位源码行 体积大,只用于本地
生产 'hidden' 错误监控还原堆栈 生成 .map 但不在 JS 末尾写注释
生产(对外发布) false 彻底不暴露 线上报错只剩压缩后的行列
export default defineConfig({
  build: {
    sourcemap: 'hidden',   // 生成 .map,不写 sourceMappingURL
  },
});

hidden 的用法:构建后把 .map 上传监控平台再从 dist 删掉,避免被猜路径下载源码;inline 把 map 塞进 JS,等于随包发布源码,只适合本地。


7. 环境变量的处理

构建期做静态替换:import.meta.env.VITE_API_BASE 会被换成字面量,不是运行时读取。

写法 是否暴露到客户端 说明
import.meta.env.VITE_X 是 只有 VITE_ 前缀会被注入
process.env.X 否(需 define 手动替换) 直接用会在浏览器报 process is not defined
.env.local 里的非 VITE_ 变量 否 只在 Node 侧可见,前端读不到

两条硬规则:凡被 import.meta.env 引用的值都会出现在产物里,前缀只是约定不是保护,密钥不进前端;按环境切换的配置走构建期替换或服务端下发,别在运行时读 .env。


8. 验证构建产物

$ vite build && vite preview --port 4173    # 生产产物起本地服务
$ npx source-map-explorer dist/assets/*.js  # 看包由哪些模块构成
$ npx vite-bundle-visualizer                # treemap 交互图

preview 与 dev 不是同一条链路,必须分别验证:preview 404 的常见原因是 base、资源引用方式或 public/ 处理。产物分析回答"包为什么这么大":整包引入的图表库、被打进多个 chunk 的公共依赖、本不该出现的服务端依赖。


9. base 路径与多环境构建

部署到子目录(https://example.com/app/)时,base 必须与部署路径一致,否则所有静态资源 404。

export default defineConfig(({ mode }) => ({
  base: mode === 'production' ? '/app/' : '/',
}));
// $ vite build --mode staging      → 读 .env.staging
// $ vite build --mode production   → 读 .env.production

base 定资源前缀,mode 决定加载哪个 .env.[mode],build.outDir 决定产物目录,server.proxy 只影响 dev。

base 写错的表现是 HTML 能打开、JS 与 CSS 全 404。反向代理改写了路径(/app/ → /)时,base 要按浏览器看到的路径配,不是按源站目录配。


10. 常见坑

坑 表现 根因 改法
只在生产出现的循环依赖 某模块在 build 后为 undefined dev 的原生 ESM 容忍部分循环,Rollup 求值顺序不同 消除循环,或把依赖改为函数内调用
动态 import 路径拼接 对应 chunk 缺失,运行时 404 构建期无法静态分析 改成显式映射表
CSS 顺序在生产被打乱 样式覆盖关系反了 异步 chunk 的 CSS 注入顺序与源码顺序不同 关键样式放同步入口,或提高选择器特异性
hash 文件名与 CDN 冲突 发版后部分用户白屏 HTML 缓存了旧引用,或 CDN 忽略 query index.html 短 TTL,chunk 长 TTL

11. 排错表

现象 优先核对 处理
Failed to resolve import 预构建缓存、alias、依赖是否安装 --force 重建,核对 alias 与 paths
dev 正常、preview 404 base、资源引用路径 按部署路径改 base
产物里有源码 sourcemap 设置 改 hidden 并删除 .map
某个库被打进多个 chunk manualChunks 的匹配规则 调整匹配顺序,先匹配具体库
环境变量读不到 前缀、.env 文件名、mode 用 VITE_ 前缀并核对 mode

12. 边界:什么时候该换工具或换策略

场景 更合适的选择 理由
需要极致的产物体积 Rollup/Rspack 直接配,或 SWC 压缩链 Vite 的默认压缩已够用,但细粒度控制不如手写
大量 CJS 依赖的老项目 webpack 或 Rspack 预构建的转换边界在复杂 CJS 上更易踩坑
库开发(要出 ESM + CJS 双格式) tsup / Rollup 库模式 Vite 面向应用,库模式配置有限

一句话收束:开发期靠"不打包",生产期靠"打包得对"。预构建管请求数与模块格式,分包管缓存粒度,sourcemap 与环境变量管可运维性。


13. 自测

  1. 开发期浏览器请求数暴涨,先怀疑预构建的哪一环?
  2. 新装了依赖,dev 报解析失败但刷新后正常,为什么?
  3. resolve.alias 与 tsconfig.paths 只配一个,分别会出什么症状?
  4. 按依赖分包与路由懒加载,各自优化体积还是缓存?
  5. 生产要能还原堆栈又不想公开源码,sourcemap 该选哪个值?
  6. 部署到子目录后 HTML 正常但 JS 全 404,改哪个配置?
S14-X16 · 构建工具链:Vite 的依赖预构建与产物优化
https://www.clxhxhhr.top/posts/5915/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。