构建工具选型
构建工具平时安静地待在后台,一出问题却很要命:
- 本地开发一切正常,打包部署后页面白屏,控制台一排 404;
process.env.API_URL在 Vite 项目里报process is not defined;- 发布了新版本,用户刷新了好几次还是旧页面;
- 在自己的 Mac 上构建成功,到了 CI 上报“找不到模块”。
这些问题都和“构建工具到底做了什么”有关。这篇先讲原理,再讲选型,最后逐个修坑。
读完这篇你会
- 说清构建工具的工作,以及开发环境和生产环境的差别
- 根据项目类型选择 Vite、Webpack 或 Rollup
- 修复部署路径、环境变量、缓存、文件名大小写等常见构建问题
1. 原理:构建工具把“开发用的代码”变成“浏览器用的代码”
你写的代码浏览器并不能直接高效地运行:有 TypeScript、Vue 单文件组件、Sass,还有成百上千个小模块。构建工具要做这些事:
| 工作 | 说明 |
|---|---|
| 转译 | TypeScript、JSX、.vue 文件、新语法 → 浏览器能运行的 JS |
| 打包 | 把大量小模块合并成少量文件,减少请求 |
| 拆分 | 按路由、按需把代码切成多个块,首屏只加载需要的 |
| 优化 | 压缩代码、删除没用到的代码(tree-shaking) |
| 资源处理 | 图片、字体、CSS 的处理与引用 |
| 文件指纹 | 输出 index.3f9a1c.js 这样带内容哈希的文件名,方便长期缓存 |
开发和生产是两套不同的流程
以 Vite 为例:
- 开发时:不打包。浏览器请求哪个模块,Vite 就现场转译哪个,利用浏览器原生的 ES Module 加载。改一个文件只需重新处理这一个文件,所以启动和热更新都很快。
- 生产构建时:完整打包、拆分、压缩、加哈希,产出一套静态文件。
打个比方:开发环境像餐厅现点现做,你点哪道菜就做哪道;生产环境像中央厨房提前做好、分装好的套餐。两者用的食材一样,但流程完全不同。很多“开发环境好好的,上线就坏了”的问题,根源都在这个差别上。所以发布前一定要在本地用 vite build && vite preview 跑一次生产版本。
2. 选哪个工具
| 工具 | 特点 | 适合 |
|---|---|---|
| Vite | 开发服务器启动快、配置简单;生产构建使用 Rollup 系打包器(新版本逐步切换到 Rust 编写的 Rolldown) | 新的应用项目,Vue / React 都适用 |
| Webpack | 生态最成熟,插件最多,配置灵活但复杂 | 已有的 Webpack 项目、需要特殊插件的大型应用 |
| Rollup | 产物干净,支持输出多种模块格式 | 开发 npm 包、组件库、SDK |
| esbuild / Rspack 等 | 用 Go、Rust 编写,速度很快 | 作为其他工具的底层,或替换 Webpack 加速构建 |
大多数情况下,用框架官方脚手架生成的默认配置就够了。如果你发现自己在维护几百行的构建配置,先停下来想想是不是工具选错了,或者是不是在解决一个本不该存在的问题。
不要为了“用新工具”而迁移。从 Webpack 迁到 Vite 的收益(开发速度)要明确大于成本(插件替换、配置改写、回归测试)。
3. 坑一:部署到子路径后白屏,资源全部 404
现象:项目部署在 https://example.com/admin/ 下,打开后白屏,Network 面板里 https://example.com/assets/index.js 全部 404。
原因:构建工具默认假设应用部署在网站根路径 /,生成的资源地址都是 /assets/...,没有带上 /admin/ 前缀。
修复:配置部署的基础路径,路由也要使用同一个前缀:
js
// vite.config.js
export default defineConfig({
base: '/admin/'
})js
// router
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), // 与 base 保持一致
routes
})代码里引用 public 目录的资源时,也要拼上 import.meta.env.BASE_URL,而不是写死以 / 开头的路径。
4. 坑二:环境变量取不到,或者把密钥打包进了前端
现象一:在 Vite 项目里写 process.env.API_URL,浏览器报 process is not defined;改成 import.meta.env.API_URL,拿到的是 undefined。
原因:process.env 是 Node.js 的东西,浏览器里没有。Vite 通过 import.meta.env 暴露环境变量,并且只暴露以 VITE_ 开头的变量,防止你不小心把服务器的变量带进前端。
修复:
text
# .env.production
VITE_API_BASE=https://api.example.comjs
const baseURL = import.meta.env.VITE_API_BASE现象二:安全扫描发现,前端的 JS 文件里有第三方服务的密钥。
原因:有人把密钥写进了 VITE_ 开头的环境变量。构建时,这些变量会被直接替换成字符串写进 JS 文件,任何人打开浏览器开发者工具都能看到。
修复:前端代码里不能放任何密钥。需要用密钥调用的服务,由后端代理:前端请求自己的后端,后端带上密钥去调用第三方。已经泄露的密钥要立即作废、重新生成。
5. 坑三:发布了新版本,用户看到的还是旧页面
现象:发布后,部分用户反馈还是旧界面;还有用户点击某个菜单时页面报错,刷新后又好了。
原因:浏览器缓存了旧的 index.html。旧的 HTML 引用的是旧的 JS 文件,如果服务器上旧文件还在,用户看到旧版本;如果旧文件已经被删除,懒加载某个页面时就会 404。
修复:给不同的文件设置不同的缓存策略:
| 文件 | 缓存策略 | 原因 |
|---|---|---|
index.html | Cache-Control: no-cache(每次都向服务器确认) | 它是入口,必须及时拿到新版本 |
带哈希的 assets/*.js、*.css | Cache-Control: max-age=31536000, immutable | 内容变了文件名就会变,可以放心长期缓存 |
发布时保留上一个版本的资源文件一段时间,让还开着旧页面的用户也能继续加载。懒加载失败后的自动恢复,见 懒加载与发布恢复。
6. 坑四:Mac 上构建成功,CI 上报“找不到模块”
现象:本地开发和构建都正常,推送代码后,Linux 上的 CI 构建失败:Could not resolve "./components/UserCard.vue"。
原因:文件实际名叫 userCard.vue,导入语句写的是 UserCard.vue。macOS 和 Windows 的文件系统默认不区分大小写,所以本地能找到;Linux 区分大小写,就找不到了。
另一种常见情况是:在 Mac 上把文件名从 usercard.vue 改成 UserCard.vue,Git 默认没有记录这次改名,仓库里还是旧名字。
修复:
- 统一文件命名规范,并开启编辑器的导入路径自动补全,不手写路径;
- 改文件名大小写时用
git mv usercard.vue UserCard.vue; - 让 CI 在 Linux 上构建,尽早暴露这类问题。
7. 坑五:第三方依赖打成一个巨大的包
现象:每次发布,用户都要重新下载一个 1MB 的 vendor.js,哪怕只改了一行业务代码。
原因:所有第三方依赖被合并成一个文件。只要依赖有任何变化(甚至只是升级一个小工具包),整个文件的哈希就会变,缓存全部失效。
修复:按“变化频率”拆分。很少变化的框架核心单独一个块,可以长期缓存;大型但只在部分页面使用的库,跟着页面懒加载:
js
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
framework: ['vue', 'vue-router', 'pinia'] // 很少变化,单独缓存
}
}
}
}
})拆得太碎也不好,请求数会变多。用体积分析工具看清楚构成后再决定。
排查思路
- 在本地复现生产环境:
vite build && vite preview(Webpack 项目同理,构建后用静态服务器打开dist)。大部分“只在线上出问题”的情况都能在这一步复现。 - 看 Network 面板的 404:资源的请求地址错在哪里——是少了路径前缀,还是请求了旧版本的文件名。
- 开发环境出现奇怪的依赖报错(尤其是刚升级或安装依赖后):Vite 会预构建依赖并缓存,删除
node_modules/.vite或用vite --force启动,强制重新预构建。 - 体积问题:用
rollup-plugin-visualizer等工具生成体积报告,先处理最大的几块。 - CI 失败而本地成功:对比 Node 版本、包管理器版本、锁文件是否提交,以及文件名大小写。
检查清单
- [ ] 发布前在本地运行过生产构建和预览
- [ ] 部署在子路径时配置了
base,路由使用相同前缀 - [ ] 前端环境变量里没有任何密钥
- [ ]
index.html不长期缓存,带哈希的资源长期缓存 - [ ] 文件名大小写和导入路径一致,CI 在 Linux 上构建
- [ ] 依赖拆分考虑了缓存,定期检查产物体积
- [ ] 锁文件已提交,CI 与本地使用相同的 Node 和包管理器版本