构建工具解决的根本问题是:浏览器认识的东西,和我们写的东西之间有一道鸿沟——模块化、语法降级、资源处理、体积压缩、开发体验(热更新)。
本文只讲模型差异和判断依据,具体配置见 webpack / vite。
webpack(bundle-based)
入口 → 递归解析依赖 → 得到一个完整的依赖图 → 打包成 bundle → 交给浏览器
vite(dev: ESM 原生 · prod: rollup)
开发时:浏览器直接请求 ESM 模块 → 服务端按需编译单个文件返回
生产时:Rollup 打包(因为生产环境仍需要更少的请求和更小的体积)
关键差异在什么时候做编译:webpack 在启动时把你所有的代码都编译一遍,vite 在浏览器请求到某个文件时才编译它。
node_modules 里的 CJS/UMD 包用 esbuild 打成 ESM),业务代码零编译启动。代价:首屏模块请求数变多(几十上百个 ESM 请求)。HTTP/2 多路复用缓解了这个问题,但项目极深时首屏仍可能比 webpack 慢。生产构建 vite 用 Rollup,所以"开发快"不等于"打包快"。
| 概念 | 作用 |
|---|---|
| entry | 依赖图的起点 |
| module / loader | 把非 JS 资源(css、图片、ts)转成 webpack 能理解的模块 |
| plugin | 在构建生命周期的钩子上做扩展(如 HtmlWebpackPlugin) |
| chunk | 产物分组单位,决定最终生成几个文件 |
| asset | 最终写盘的文件 |
构建流程:初始化参数 → 实例化 Compiler → 执行 run → 从 entry 递归构建模块(make)→ 优化(seal:tree shaking、拆分)→ 输出文件(emit)。
HMR 原理:dev-server 通过 WebSocket 通知浏览器「哪个模块变了」→ 浏览器向服务端请求新的模块 → 用新模块替换旧模块并执行 accept 回调。状态得以保留,是因为只替换了模块而没有刷新页面。
缓存:webpack 5 的 cache: { type: 'filesystem' } 把模块编译结果持久化到磁盘,二次启动只编译变化的模块。
Tree shaking(无用代码消除)依赖静态可分析性,四个前提缺一不可:
import/export)。CJS 的 require 是运行时求值,无法静态判定。sideEffects 声明正确。package.json 里 "sideEffects": false 告诉打包器"这个包没有副作用,可以整块删"。如果包里有副作用(如 polyfill、全局样式注入)却声明 false,会被误删。export const a = init() 这种顶层调用会被保守保留。常见失效场景:
// ✗ 整个 lodash 被打进来:CJS 无法 tree shake
import _ from "lodash";
_.debounce(fn, 300);
// ✓ 具名导入,配合 ESM 版本
import { debounce } from "lodash-es";
按成本从低到高,别一上来就改配置:
webpack-bundle-analyzer / rollup-plugin-visualizer,确认到底是谁占体积。npm ls lodash),用 resolve.alias 或 overrides 收敛。import xxx from 'antd' 改为按需;moment 换 dayjs。splitChunks / manualChunks 把稳定依赖拆成独立 chunk,提升缓存命中率(体积没变,但二次访问更快)。| 场景 | 建议 | 理由 |
|---|---|---|
| 新项目、纯前端应用 | vite | 启动快、配置少、生态已成熟 |
| 大型存量项目 | 保持 webpack | 迁移成本和自定义 loader 生态 |
| 需要极致自定义构建流程 | webpack / rollup | 钩子最全 |
| 库开发 | rollup / tsup | 产物干净,多格式输出(esm/cjs/dts) |
| Monorepo | turborepo + vite | 任务缓存 + 增量构建 |
判断标准不是"谁更快",而是"你的瓶颈在哪":如果痛点是启动和热更新,选 vite;如果痛点是产物体积与分包策略的可控性,webpack 的 splitChunks 仍然更成熟。
contenthash?(内容不变则文件名不变,最大化浏览器缓存)eval-cheap-module-source-map 开发,hidden-source-map 生产上报)