学习目标: 彻底掌握现代前端打包工具的核心原理,从 Webpack 的底层机制到 Vite 的革命性设计,再到 Rollup/esbuild 的各自定位,建立完整的工程化认知体系。
一、Webpack 核心原理
1.1 整体工作流程(5个阶段详解)
Webpack 的构建过程可以分为 5 个主要阶段,每个阶段都有对应的钩子(Hook)供插件介入。
阶段一:初始化(Initialization)
webpack.config.js
↓
读取配置(merge 默认配置 + 用户配置 + CLI 参数)
↓
创建 Compiler 对象(核心编译器实例,全局唯一)
↓
注册所有内置 Plugin(如 HtmlWebpackPlugin、DefinePlugin)
↓
调用 compiler.hooks.initialize
核心操作:
// Webpack 内部伪代码
function webpack(config) {
// 1. 合并配置(Shell 参数 > 用户配置 > 默认配置)
const mergedConfig = mergeConfig(defaultConfig, config, shellArgs);
// 2. 创建 Compiler 对象(继承自 Tapable,拥有完整的 hooks 系统)
const compiler = new Compiler(mergedConfig.context, mergedConfig);
// 3. 注册所有插件(调用每个 plugin 的 apply 方法)
mergedConfig.plugins.forEach(plugin => plugin.apply(compiler));
// 4. 初始化完毕,返回 compiler
return compiler;
}
Compiler 对象职责:
- 保存完整的 webpack 配置
- 管理文件系统(inputFileSystem / outputFileSystem)
- 触发各阶段 hooks(beforeRun → run → beforeCompile → compile → make → finishMake → afterCompile → emit → done)
- 负责文件监听(watch 模式下的 watchRun)
阶段二:编译(Compilation)
这是 Webpack 最核心的阶段,从入口文件出发,递归构建完整的依赖图。
compiler.hooks.make.callAsync()
↓
创建 Compilation 对象(当次编译的快照,包含 modules/chunks/assets)
↓
从 entry 配置中确定入口模块
↓
递归构建依赖图(Dependency Graph)
↓
对每个模块:解析 → 加载(Loader)→ 构建 → 分析依赖
// 从 entry 开始递归构建的伪代码
class Compilation {
buildModule(module, callback) {
// 1. 读取文件内容
const source = this.readFile(module.resource);
// 2. 依次执行 Loader 链,转换源码
const transformedSource = runLoaders(this.loaders, source);
// 3. 用 acorn 解析 AST,找出所有 import/require
const ast = acorn.parse(transformedSource);
const dependencies = extractDependencies(ast);
// 4. 递归处理每个依赖
dependencies.forEach(dep => {
this.buildModule(dep);
});
}
}
阶段三:模块解析(Module Resolution + Loader 链)
每个模块的构建过程:
import './foo.css'
↓
Resolver(解析模块路径:相对/绝对/node_modules 三种策略)
↓
匹配 module.rules(确定使用哪些 Loader)
↓
Loader 链(从右到左执行 pitch,从左到右执行 normal)
↓
返回 JavaScript 字符串(Webpack 只认识 JS/JSON)
↓
Parser(acorn 解析 AST,找依赖)
阶段四:生成(Seal / Emit)
所有模块构建完毕
↓
seal:冻结 Compilation,不再接受新模块
↓
分组:按 entry + dynamic import 分割成 Chunk
↓
优化:Tree Shaking / splitChunks / minification
↓
template:将 Chunk 渲染成最终 Bundle 字符串
↓
生成 assets(key = 文件名, value = 文件内容)
阶段五:写入磁盘(Emit)
compiler.hooks.emit.callAsync(compilation)
↓
遍历 compilation.assets,通过 outputFileSystem 写文件
↓
compiler.hooks.afterEmit
↓
compiler.hooks.done(构建完成)
完整 hooks 时序图:
beforeRun → run → normalModuleFactory(工厂创建)
→ beforeCompile → compile → make(递归构建)
→ finishMake → afterCompile → shouldEmit
→ emit → afterEmit → done
1.2 核心概念深度
Module / Chunk / Bundle 三者关系
这是 Webpack 最容易混淆的三个概念:
| 概念 | 含义 | 对应文件 | 生成时机 |
|---|---|---|---|
| Module | 每一个被解析的文件 | 任意格式(JS/CSS/图片…) | 编译阶段(make) |
| Chunk | 一组 Module 的集合 | 逻辑上的代码块 | Seal 阶段分组 |
| Bundle | 最终输出的文件 | dist/*.js | Emit 阶段写磁盘 |
关系示意:
源码文件(Module)
app.js (Module)
├── utils.js (Module)
├── lodash (Module × N 个子模块)
└── route-a.js (dynamic import → 独立 Chunk)
↓ Seal 阶段
Chunk 分组:
main-chunk = [app.js + utils.js + lodash]
route-a-chunk = [route-a.js]
↓ Emit 阶段
Bundle 输出:
main.bundle.js
route-a.bundle.js
关键规则:
- 一个 Entry 至少产生一个 initial Chunk
dynamic import()产生 async Chunk- 一个 Chunk 可以包含多个 Module
- 一个 Module 可以属于多个 Chunk(splitChunks 提取公共模块时)
Dependency Graph(依赖图)构建过程
Webpack 通过 AST 静态分析构建有向无环图(DAG):
1. 入口文件 → 添加到待处理队列
2. 取出队首模块 → 读取文件 → Loader 转换
3. 用 acorn 解析 AST → 遍历所有 ImportDeclaration / require()
4. 对每个依赖:
a. 解析路径(Resolver)
b. 如果未处理过 → 加入队列
c. 如果已处理 → 直接引用(避免循环依赖死循环)
5. 为当前模块记录依赖关系(parentModule → childModule)
6. 重复 2-5 直到队列为空
循环依赖处理:
// a.js
import { foo } from './b.js';
export const bar = 'bar';
// b.js
import { bar } from './a.js'; // 循环!
export const foo = 'foo';
Webpack 会正常构建,但运行时 bar 在 b.js 首次执行时为 undefined(因为 a.js 还没执行完)。这是 ES Module 的"活绑定"(live binding)特性决定的。
Tree Shaking 原理(为何 CJS 不支持)
ESM 静态分析 vs CJS 动态特性:
// ✅ ESM - 静态结构,可静态分析
import { add } from './math'; // 编译时确定,import 的是哪个具体绑定
// ❌ CJS - 动态结构,无法静态分析
const { add } = require('./math'); // 运行时才知道取哪个属性
const method = 'add';
require('./math')[method](); // 完全动态,无法预知
Tree Shaking 实现原理(三步走):
Step 1: 标记(Mark)
Webpack 从 entry 出发,遍历所有 ESM 模块
对每个 export,标记它是否被实际使用(used / unused)
Step 2: 分析副作用(Side Effects)
package.json sideEffects: false → 告知所有模块无副作用,可安全删除
sideEffects: ["*.css"] → CSS 文件有副作用(改全局样式),保留
Step 3: 清除(Shake)
production 模式下,Terser/esbuild 进行 DCE
删除所有 unused export 的代码
为什么 CJS 不支持:
require()是函数调用,可以动态传参module.exports可以在任何地方被赋值- 无法在编译时确定哪些导出会被使用
- 必须在运行时才能确定完整的导出对象
ESM 为什么支持:
import是语法关键字,不是函数调用- 导入绑定是静态的(编译时确定)
export声明必须在模块顶层- 工具链可以在不运行代码的情况下分析出哪些导出被使用
Code Splitting(splitChunks 配置详解 + dynamic import)
两种分割方式:
方式1:Dynamic Import(动态导入)
// 点击按钮时才加载路由组件
button.addEventListener('click', async () => {
const { default: RouteA } = await import('./route-a');
// import() 返回 Promise<Module>
// Webpack 会自动将 route-a 拆分成独立 Chunk
});
// React 中的应用
const LazyComp = React.lazy(() => import('./HeavyComponent'));
Webpack 处理 dynamic import 的原理:
// 原始代码
const mod = await import('./foo');
// Webpack 编译后(简化版)
// 1. 将 foo.js 编译成独立 chunk(foo.bundle.js)
// 2. 运行时通过 JSONP/import() 加载
const mod = await __webpack_require__.e(/* chunkId */ "foo")
.then(__webpack_require__.bind(__webpack_require__, "./foo.js"));
方式2:SplitChunksPlugin(自动分割)
// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
// all:对所有 chunks 生效(推荐)
// initial:只对同步 chunks
// async:只对异步 chunks(默认)
chunks: 'all',
// 最小文件大小(字节),小于此值不分割
minSize: 20000,
// 最大文件大小,超过会继续分割
maxSize: 0,
// 最少被几个 chunk 引用才分割(默认1)
minChunks: 1,
// 最大并发请求数(HTTP/1.1 时代有意义)
maxAsyncRequests: 30,
maxInitialRequests: 30,
// 分割出来的 chunk 名称分隔符
automaticNameDelimiter: '~',
// 缓存组(精细控制)
cacheGroups: {
// 将 node_modules 的代码单独打包
vendors: {
test: /[\/]node_modules[\/]/,
priority: -10, // 优先级(数字越大越优先)
reuseExistingChunk: true, // 如果已有相同内容的 chunk,复用
name: 'vendors',
filename: 'js/[name].[contenthash:8].js',
},
// 公共模块(被多处引用的)
common: {
minChunks: 2, // 至少被 2 个 chunk 引用
priority: -20,
reuseExistingChunk: true,
name: 'common',
},
// React 单独打包(长期缓存)
react: {
test: /[\/]node_modules[\/](react|react-dom|scheduler)[\/]/,
name: 'react-vendor',
priority: 10,
chunks: 'initial',
},
},
},
},
};
HMR 热更新原理(WebSocket + module.hot.accept 全流程)
HMR 是 Webpack 开发体验的核心,整个流程如下:
文件修改(保存)
↓
1. Webpack 监听到文件变化(watch 模式)
↓
2. 重新编译变化的模块(增量编译,非全量)
↓
3. 生成两个文件:
- [hash].hot-update.json(描述哪些模块更新了)
- [chunkId].[hash].hot-update.js(更新的模块代码)
↓
4. webpack-dev-server 通过 WebSocket 通知浏览器
消息格式:{ type: 'hash', data: 'abc123' }
{ type: 'ok' }
↓
5. 浏览器端 HMR Runtime 收到通知
↓
6. 通过 JSONP 请求拉取 hot-update.js
↓
7. 执行 module.hot.accept 回调(模块自我更新)
↓
8. 如果模块没有注册 accept → 向上冒泡
如果冒泡到顶层还没有 accept → 全页面刷新(fallback)
module.hot.accept 使用:
// React Fast Refresh 的简化原理
if (module.hot) {
// 接受自身更新
module.hot.accept();
// 接受某个依赖更新,并提供回调
module.hot.accept('./store', () => {
// 当 store 模块更新时,重新渲染根组件
const newStore = require('./store').default;
ReactDOM.render(<App store={newStore} />, document.getElementById('root'));
});
// 模块销毁时清理(避免内存泄漏)
module.hot.dispose((data) => {
clearInterval(timer);
data.lastValue = someState; // 传递给下一个版本的模块
});
}
WebSocket 通信协议:
// webpack-dev-server 发送的消息类型
{ type: 'hash', data: 'newHash' } // 新的编译 hash
{ type: 'ok' } // 编译成功
{ type: 'errors', data: [...] } // 编译错误
{ type: 'warnings', data: [...] } // 编译警告
{ type: 'close' } // 服务器关闭
Source Map 生成原理(VLQ 编码 + 7种 devtool 选项对比)
Source Map 本质: 一个 JSON 文件,记录了"编译后代码的某个位置"→"源码的某个位置"的映射关系。
VLQ 编码(Variable-Length Quantity):
原理:用 Base64 字符表示可变长度整数,压缩映射数据
每个段由 4-5 个 VLQ 数字组成:
[生成文件列偏移, 源文件索引, 源文件行偏移, 源文件列偏移, 名称索引]
示例:mappings 字段 "AAAA;AACA"
AAAA → 第一行第一个映射(0,0,0,0 四个都是0)
; → 换行
AACA → 第二行第一个映射
7种 devtool 选项对比:
| devtool 值 | 构建速度 | 重构建速度 | 质量 | 适用场景 |
|---|---|---|---|---|
false / 无 | 最快 ⚡⚡⚡ | 最快 ⚡⚡⚡ | 无映射 | 生产环境(配合独立 map 文件上传监控) |
eval | 快 ⚡⚡ | 最快 ⚡⚡⚡ | 低(转换后代码) | 开发初期,追求速度 |
eval-source-map | 慢 | 快 ⚡⚡ | 高(原始代码) | 开发推荐 ✅ |
eval-cheap-source-map | 较快 ⚡⚡ | 快 ⚡⚡ | 中(无列信息) | 开发,稍差质量换速度 |
eval-cheap-module-source-map | 慢 | 中 ⚡ | 高(Loader 转换前) | 开发推荐(含 Babel 前源码)✅ |
source-map | 最慢 | 慢 | 最高 | 生产环境(需要调试) |
hidden-source-map | 最慢 | 慢 | 最高(不暴露) | 生产环境(Sentry 错误监控)✅ |
nosources-source-map | 最慢 | 慢 | 只有位置 | 生产环境(保护源码安全) |
最佳实践:
// 开发环境
devtool: 'eval-cheap-module-source-map',
// 生产环境(上传到 Sentry,不暴露给用户)
devtool: 'hidden-source-map',
// 同时配置 webpack.SourceMapDevToolPlugin 上传到错误监控平台
1.3 Loader 机制
Loader 本质
Loader 是一个纯函数:
// 最简单的 Loader
module.exports = function(source, sourceMap, meta) {
// source: 前一个 Loader 传来的字符串或 Buffer
// sourceMap: 上一个 Loader 传来的 source map
// meta: 元数据
// 处理 source...
const result = transform(source);
// 同步返回
return result;
// 或者异步返回
// const callback = this.async();
// callback(null, result, sourceMap, meta);
};
// Loader 上下文(this)提供了大量工具方法
// this.getOptions() 获取 Loader 配置
// this.async() 异步模式
// this.emitFile() 输出文件
// this.addDependency() 添加文件依赖(watch 时监听)
// this.cacheable(false) 关闭缓存
// this.resourcePath 当前处理文件的绝对路径
// this.rootContext 项目根目录
pitch 阶段 vs normal 阶段
假设配置了 use: ['a-loader', 'b-loader', 'c-loader']:
执行顺序:
pitch 阶段(从左到右):
a-loader.pitch → b-loader.pitch → c-loader.pitch
normal 阶段(从右到左):
c-loader → b-loader → a-loader
完整流程:
a.pitch → b.pitch → c.pitch → 读文件 → c → b → a
pitch 中断机制:
// b-loader.js(pitch 阶段)
module.exports.pitch = function(remainingRequest, precedingRequest, data) {
// 如果 pitch 返回了值,就中断后续 loader 的 pitch 和 normal 阶段
// 直接将返回值交给前一个 loader 的 normal 阶段处理
if (someCondition) {
return `module.exports = 'cached result'`;
// 返回后:只有 a.normal 还会执行,b/c 的 normal 和 c 的 pitch 都跳过
}
};
pitch 的实际用途(style-loader 经典案例):
// style-loader 通过 pitch 中断,将 css-loader 的结果注入 <style>
module.exports.pitch = function(remainingRequest) {
// 返回一段 JS 代码,运行时动态加载 CSS
return `
var content = require(${loaderUtils.stringifyRequest(this, '!!' + remainingRequest)});
require(${loaderUtils.stringifyRequest(this, require.resolve('./addStyles'))});
// ...
`;
// pitch 返回后,css-loader 的 normal 不再执行
// 而是在运行时通过 require 动态调用
};
常用 Loader 原理解析
babel-loader:
// 本质:调用 @babel/core.transform()
module.exports = function(source) {
const options = this.getOptions(); // 读取 .babelrc 或 babel.config.js
const { code, map } = babel.transformSync(source, {
...options,
filename: this.resourcePath,
inputSourceMap: this.sourceMap,
});
this.callback(null, code, map);
};
css-loader:
- 解析 CSS 中的
@import和url()依赖 - 将 CSS 转换为 JS 模块(导出 CSS 字符串 + 依赖列表)
- 支持 CSS Modules(将类名替换为哈希值)
// css-loader 处理后的输出(简化)
// 原始:.foo { color: red; }
// 输出 JS 模块:
module.exports = [
[module.id, '.foo { color: red; }', '']
];
module.exports.locals = {}; // CSS Modules 类名映射
style-loader:
- 将 css-loader 的输出注入到
<style>标签 - 支持 HMR(通过
module.hot.accept动态更新样式) - 只适合开发环境,生产环境用 MiniCssExtractPlugin
// style-loader 运行时注入
function insertStyleElement(options) {
const style = document.createElement('style');
const target = document.querySelector(options.target) || document.head;
target.appendChild(style);
return style;
}
file-loader vs url-loader:
// file-loader:将文件输出到 output 目录,返回文件路径
module.exports = function(source) {
const url = loaderUtils.interpolateName(this, '[contenthash].[ext]', { content: source });
this.emitFile(url, source);
return `module.exports = ${JSON.stringify(url)}`;
};
// url-loader:小于 limit 时转成 base64 Data URL,大于 limit 时降级到 file-loader
module.exports = function(source) {
const limit = this.getOptions().limit || 8192; // 默认 8KB
if (source.length < limit) {
const base64 = source.toString('base64');
const mimeType = mime.getType(this.resourcePath);
return `module.exports = "data:${mimeType};base64,${base64}"`;
}
// fallback 到 file-loader
return fileLoader.call(this, source);
};
手写一个注释剥离 Loader
// strip-comment-loader.js
/**
* 功能:移除 JS 文件中的所有注释(单行注释、多行注释)
* 注意:正则方案简单但不完美(字符串中的 // 也会被误删)
* 生产级别应该用 AST(如 babel 的 removeComments 选项)
*/
const { validate } = require('schema-utils');
// 定义选项 schema
const schema = {
type: 'object',
properties: {
preserveLicense: {
type: 'boolean',
description: '是否保留 License 注释(/*!...*/)',
},
stripStrings: {
type: 'boolean',
description: '是否连字符串中的注释也一起删除(默认 false)',
},
},
additionalProperties: false,
};
module.exports = function stripCommentLoader(source) {
// 获取并校验配置
const options = this.getOptions() || {};
validate(schema, options, { name: 'strip-comment-loader' });
const { preserveLicense = true, stripStrings = false } = options;
let result = source;
if (stripStrings) {
// 简单正则方案(会误删字符串中的注释,谨慎使用)
result = result
.replace(//*[\s\S]*?*//g, (match) => {
// 如果保留 License 注释(以 /*! 开头)
if (preserveLicense && match.startsWith('/*!')) return match;
return '';
})
.replace(///.*/g, '');
} else {
// 更安全的方案:用状态机跳过字符串和模板字符串
result = stripCommentsSafe(source, { preserveLicense });
}
// 清理多余空白行
result = result.replace(/\n{3,}/g, '\n\n');
return result;
};
/**
* 状态机版本:安全地移除注释(不影响字符串内容)
*/
function stripCommentsSafe(source, { preserveLicense }) {
let output = '';
let i = 0;
const len = source.length;
while (i < len) {
const ch = source[i];
const next = source[i + 1];
// 跳过单引号字符串
if (ch === "'" || ch === '"') {
const quote = ch;
output += ch;
i++;
while (i < len && source[i] !== quote) {
if (source[i] === '\') { output += source[i++]; } // 转义
output += source[i++];
}
output += source[i++] || '';
continue;
}
// 跳过模板字符串
if (ch === '`') {
output += ch;
i++;
while (i < len && source[i] !== '`') {
if (source[i] === '\') { output += source[i++]; }
output += source[i++];
}
output += source[i++] || '';
continue;
}
// 单行注释 //
if (ch === '/' && next === '/') {
while (i < len && source[i] !== '\n') i++;
continue; // 吃掉整行注释
}
// 多行注释 /* */
if (ch === '/' && next === '*') {
const commentStart = i;
i += 2;
while (i < len - 1 && !(source[i] === '*' && source[i + 1] ==='/' )) { i++; }
i += 2; // 跳过 */
const comment = source.slice(commentStart, i);
// License 注释(/*!)保留
if (preserveLicense && comment.startsWith('/*!')) {
output += comment;
}
continue;
}
output += ch;
i++;
}
return output;
}
// 使用示例(webpack.config.js)
// {
// test: /.js$/,
// use: [
// 'babel-loader',
// {
// loader: path.resolve('./loaders/strip-comment-loader'),
// options: { preserveLicense: true }
// }
// ]
// }
1.4 Plugin 机制
Tapable 事件系统
Webpack 的整个插件系统建立在 tapable 库之上,本质是一个发布-订阅模式。
核心 Hook 类型:
const {
SyncHook, // 同步,按注册顺序执行
SyncBailHook, // 同步,返回非 undefined 则停止
SyncWaterfallHook, // 同步,上一个返回值传给下一个
SyncLoopHook, // 同步,返回非 undefined 则重新执行
AsyncSeriesHook, // 异步串行,依次执行
AsyncSeriesBailHook, // 异步串行,某个返回值则停止
AsyncSeriesWaterfallHook, // 异步串行瀑布
AsyncParallelHook, // 异步并行,同时执行所有
AsyncParallelBailHook, // 异步并行,某个有值则停止
} = require('tapable');
// 使用示例
const hook = new AsyncSeriesHook(['compiler', 'options']);
// 注册(订阅)
hook.tapAsync('MyPlugin', (compiler, options, callback) => {
doSomethingAsync(() => callback()); // 完成后调用 callback
});
hook.tapPromise('AnotherPlugin', async (compiler, options) => {
await doAsync();
// 返回 Promise 即可
});
// 触发(发布)
hook.callAsync(compiler, options, () => {
console.log('所有监听者执行完毕');
});
tap / tapAsync / tapPromise 区别:
// 同步注册(只能用于 SyncHook)
hook.tap('Plugin', (arg1, arg2) => { /* 同步 */ });
// 异步注册(回调方式)
hook.tapAsync('Plugin', (arg1, arg2, callback) => {
setTimeout(() => callback(), 100);
});
// 异步注册(Promise 方式)
hook.tapPromise('Plugin', (arg1, arg2) => {
return new Promise(resolve => setTimeout(resolve, 100));
});
Compiler vs Compilation 对象
| 维度 | Compiler | Compilation |
|---|---|---|
| 生命周期 | 整个 webpack 进程 | 每次编译(watch 模式每次文件变更) |
| 实例数量 | 唯一(单例) | 每次构建创建一个新的 |
| 职责 | 全局配置、文件系统、Plugin 注册 | 模块构建、依赖图、Chunk 分割、资源生成 |
| 访问方式 | plugin.apply(compiler) | compiler.hooks.make → compilation |
关键 hooks 详解
class MyPlugin {
apply(compiler) {
// beforeRun: webpack 首次启动前(watch 模式不触发)
compiler.hooks.beforeRun.tapAsync('MyPlugin', (compiler, callback) => {
console.log('即将开始构建');
callback();
});
// run: 开始读取 records(序列化构建状态)
compiler.hooks.run.tapAsync('MyPlugin', (compiler, callback) => {
callback();
});
// emit: 生成文件到 output 目录前(可以在这里修改 assets)
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
// 修改输出文件
Object.keys(compilation.assets).forEach(filename => {
if (filename.endsWith('.js')) {
const content = compilation.assets[filename].source();
// 可以修改 content...
compilation.assets[filename] = {
source: () => content,
size: () => content.length,
};
}
});
callback();
});
// done: 构建完成(包含成功和失败)
compiler.hooks.done.tap('MyPlugin', (stats) => {
if (stats.hasErrors()) {
console.error('构建失败');
} else {
console.log('构建成功!耗时:', stats.endTime - stats.startTime, 'ms');
}
});
// watchRun: watch 模式,每次文件变更触发
compiler.hooks.watchRun.tapAsync('MyPlugin', (compiler, callback) => {
const changedFiles = compiler.modifiedFiles; // Set<string>
console.log('变化的文件:', [...changedFiles]);
callback();
});
}
}
手写一个生成 filelist.md 文件的 Plugin
// FileListPlugin.js
class FileListPlugin {
constructor(options = {}) {
this.options = {
filename: 'filelist.md', // 默认输出文件名
...options,
};
}
apply(compiler) {
compiler.hooks.emit.tapAsync('FileListPlugin', (compilation, callback) => {
const assets = compilation.assets;
// 生成文件列表 Markdown
let content = '# 构建产物清单\n\n';
content += `> 构建时间:${new Date().toLocaleString()}\n\n`;
content += '| 文件名 | 大小 |\n';
content += '|--------|------|\n';
// 按文件大小排序
const sortedAssets = Object.entries(assets)
.sort(([, a], [, b]) => b.size() - a.size());
let totalSize = 0;
sortedAssets.forEach(([filename, asset]) => {
const size = asset.size();
totalSize += size;
content += `| ${filename} | ${formatSize(size)} |\n`;
});
content += `\n**总计:${sortedAssets.length} 个文件,${formatSize(totalSize)}**\n`;
// 将文件添加到 assets(会被写入 output 目录)
compilation.assets[this.options.filename] = {
source: () => content,
size: () => Buffer.byteLength(content),
};
callback();
});
}
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
module.exports = FileListPlugin;
// 使用
// new FileListPlugin({ filename: 'assets-report.md' })
常用插件原理解析
HtmlWebpackPlugin:
compiler.hooks.emit阶段,读取 HTML 模板- 分析 compilation.assets 中的 JS/CSS 文件
- 自动注入
<script>和<link>标签(带 contenthash) - 支持 EJS 模板语法,可传入自定义变量
MiniCssExtractPlugin:
- 在 Loader 阶段:将 CSS 内容从 JS 模块中"抽离",记录到 compilation 的 CSS 模块图
- 在 Plugin 阶段(compiler.hooks.emit):将收集的 CSS 合并,生成独立 .css 文件
- 与 style-loader 互斥(一个运行时注入,一个编译时提取)
DefinePlugin:
// 原理:在编译时做字符串替换(不是真正的全局变量注入)
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify('production'),
'__DEV__': JSON.stringify(false),
});
// 编译后:if (false) { /* dead code,会被 Tree Shaking 删除 */ }
// if (__DEV__) { ... } → if (false) { ... }
BannerPlugin:
// 在每个 Chunk 文件头部添加注释
new webpack.BannerPlugin({
banner: '/*! My App v1.0.0 | MIT License */\n',
raw: true, // true: 直接插入(不包裹 /* */)
entryOnly: false, // false: 所有 chunk 都添加
});
1.5 代码注释/无用代码去除(重点!)
Dead Code Elimination(DCE)原理
DCE 是编译器优化技术,分为两类:
1. 语义级 DCE(Tree Shaking):
- 依赖 ESM 静态结构
- 找出"永远不会被调用的 export"
- 由打包工具(Webpack/Rollup)在 bundle 阶段完成
2. 代码级 DCE(Minifier DCE):
- 处理 if(false)/三元运算/永远为真的条件
- 删除不可达代码(unreachable code)
- 由压缩工具(Terser/UglifyJS/esbuild)完成
// 典型 DCE 场景
if (process.env.NODE_ENV === 'production') {
console.log('生产环境代码');
} else {
console.log('开发环境代码'); // 生产构建中会被删除
}
// DefinePlugin 替换后:
if ('production' === 'production') { // 常量折叠
console.log('生产环境代码');
} else {
console.log('开发环境代码'); // 不可达代码,DCE 删除
}
// Terser 最终输出:
console.log('生产环境代码');
Terser 压缩:如何识别并删除注释
Terser 是 Webpack 5 内置的 JS 压缩工具(替代 UglifyJS)。
注释处理相关配置:
// webpack.config.js
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
optimization: {
minimizer: [
new TerserPlugin({
// 是否将 License 注释提取到独立文件
extractComments: false, // true: 提取到 xxx.LICENSE.txt
// extractComments: /^**!|@preserve|@license|@cc_on/i,
terserOptions: {
format: {
// 注释处理策略(核心配置)
comments: false,
// comments: 'all' // 保留所有注释
// comments: 'some' // 保留特殊注释(默认)
// comments: false // 删除所有注释 ✅ 推荐生产
// comments: /特定正则/ // 匹配正则的注释保留
// comments: (node, comment) => {
// return comment.value.includes('@preserve');
// }
},
compress: {
// 删除不可达代码(if(false){})
dead_code: true,
// 删除 console.xxx 调用
drop_console: true, // ['log', 'warn'] 可以精细控制
// 删除 debugger 语句
drop_debugger: true,
// 常量折叠
evaluate: true,
// 删除无用变量赋值
unused: true,
},
mangle: {
// 变量名混淆
toplevel: true, // 顶层变量名也混淆
keep_classnames: false,
keep_fnames: false,
},
},
}),
],
},
};
保留 License 注释 vs 删除所有注释
场景1:开源库 + 法律合规(必须保留 License)
new TerserPlugin({
extractComments: {
// 匹配需要提取的注释(License 类型)
condition: /^**!|@preserve|@license|@cc_on/i,
filename: (fileData) => {
return `${fileData.filename}.LICENSE.txt`;
},
banner: (licenseFile) => {
return `License information can be found in ${licenseFile}`;
},
},
terserOptions: {
format: {
comments: false, // 内联注释全删,License 已提取到单独文件
},
},
});
场景2:内部项目(全部删除)
new TerserPlugin({
extractComments: false, // 不生成 LICENSE 文件
terserOptions: {
format: { comments: false }, // 删除所有内联注释
},
});
UglifyJS vs Terser vs esbuild 压缩能力对比
| 维度 | UglifyJS | Terser | esbuild |
|---|---|---|---|
| 语言支持 | ES5(不支持 ES6+) | ES2020+ ✅ | ES2022+ ✅ |
| 压缩率 | 高 | 高 | 中(稍逊于 Terser) |
| 速度 | 慢 | 慢(JS 实现) | 极快(Go 实现)⚡⚡⚡ |
| 注释处理 | 支持 | 支持(更细粒度)✅ | 支持(较简单) |
| Mangle | 支持 | 支持(更多选项)✅ | 支持 |
| Source Map | 支持 | 支持 | 支持 |
| 维护状态 | 已停更 ❌ | 活跃维护 ✅ | 活跃维护 ✅ |
| Webpack 集成 | uglifyjs-webpack-plugin(废弃) | 内置 TerserPlugin ✅ | ESBuildMinifyPlugin |
速度比较(典型项目,单位 ms):
Terser: 800ms ~ 2000ms (纯 JS 实现,单线程)
esbuild: 50ms ~ 200ms (Go 实现,多线程,快 10~40x)
SWC: 100ms ~ 400ms (Rust 实现,居中)
代码混淆:变量名替换、属性名缩短
// 原始代码
function calculateUserDiscount(userLevel, purchaseAmount) {
const discountRate = userLevel === 'premium' ? 0.2 : 0.1;
return purchaseAmount * discountRate;
}
// Terser mangle 后(变量名单字母化)
function a(b, c) {
const d = b === 'premium' ? 0.2 : 0.1;
return c * d;
}
// Terser mangle.properties 属性名混淆(激进,慎用!)
// 注意:会混淆所有属性名,可能破坏与外部 API 的交互
属性名混淆注意事项:
terserOptions: {
mangle: {
properties: {
// 只混淆以 _ 开头的属性(私有约定)
regex: /^_/,
// 保留特定属性名
reserved: ['__esModule', '__webpack_require__'],
}
}
}
Pure annotation(/*#__PURE__*/)与 Tree Shaking 配合
/*#__PURE__*/ 是一个特殊注释,告诉打包工具:"这个函数调用没有副作用,如果结果不被使用,可以安全删除。"
// 问题:Webpack 无法判断 React.createElement 是否有副作用
// 所以默认不删除,即使 Button 未被使用
const Button = React.createElement(BaseButton, { type: 'button' });
// 解决:加上 /*#__PURE__*/ 注解
const Button = /*#__PURE__*/ React.createElement(BaseButton, { type: 'button' });
// 现在 Webpack 知道:如果 Button 没被使用,可以整个删掉
// Babel 会自动为 JSX 添加 #__PURE__ 注解
const element = <Button />
// 编译后:
const element = /*#__PURE__*/ React.createElement(Button, null);
// 类方法装饰器也需要
class MyClass {
@memoize
getValue() { return 42; }
}
// 编译后,Babel 会加注解确保 Tree Shaking 有效
实际效果验证:
// utils.js(有副作用的库)
console.log('模块加载时执行'); // 副作用!即使不使用也会执行
export const expensiveOp = /*#__PURE__*/ createHeavyObject();
// ^^^^^^^^^^^ 告诉 Webpack:createHeavyObject() 可安全省略
// main.js
import { expensiveOp } from './utils'; // 如果 expensiveOp 未使用
// 无注解:两者都保留在 bundle 中
// 有注解:expensiveOp 被删除,但 console.log 因有副作用仍保留
二、Vite 核心原理
2.1 开发模式(No-bundle 革命)
No-bundle 理念:为什么不预打包
传统 Webpack Dev Server 的问题:
项目启动时:
1. 从 entry 开始,解析所有模块(可能有 1000+ 个)
2. 每个模块都要经过 Loader 转换
3. 生成完整的 bundle.js
时间:中型项目 30s ~ 2min
文件修改时:
1. 找出受影响的模块(依赖链条很长)
2. 重新打包受影响的 chunk
时间:3s ~ 20s(热更新)
Vite 的方案(利用原生 ESM):
项目启动时:
1. 只做依赖预构建(esbuild 处理 node_modules,快!)
2. 启动 dev server(Koa HTTP 服务 + WebSocket HMR)
时间:< 500ms(超快!)
文件修改时:
1. 只有被请求的模块才编译
2. 模块粒度的 HMR(精确更新)
时间:< 100ms
基于原生 ESM 的模块加载
<!-- Vite dev 模式下,index.html 中的 script -->
<script type="module" src="/src/main.ts"></script>
// 浏览器发起请求:GET /src/main.ts
// Vite Dev Server 接收,实时编译,返回 JS
// 原始 main.ts
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
// Vite 编译后(简化)返回给浏览器:
import { createApp } from '/@fs/.../node_modules/vue/dist/vue.esm-bundler.js'
import App from '/src/App.vue'
createApp(App).mount('#app')
// 浏览器再根据这些 import 发起后续请求
关键:浏览器原生 ESM 是按需加载的,只有被 import 的模块才会发起请求。
依赖预构建(esbuild 预打包 node_modules)
为什么需要预构建:
- CJS/UMD → ESM 转换: 大量 npm 包只有 CommonJS 版本,浏览器不支持
require() - 模块合并:
lodash-es有 600+ 个小模块,每个 import 都是一个 HTTP 请求,太慢 - 深层依赖树: 某些包有数百个内部
require(),需要合并成单文件
// 预构建过程(简化)
import esbuild from 'esbuild';
// 分析 package.json 和入口文件,找出所有依赖
const deps = scanDependencies('./src/main.ts');
// 用 esbuild 批量预构建
await esbuild.build({
entryPoints: Object.keys(deps),
bundle: true,
format: 'esm', // 输出 ESM 格式
outdir: './node_modules/.vite/deps', // 缓存目录
splitting: true, // 代码分割(共享依赖提取)
});
// 预构建结果被缓存,重启不需要重新构建(除非 node_modules 变化)
预构建缓存策略:
- 缓存目录:
node_modules/.vite/deps/ - 缓存有效期:基于 lock file、node_modules 时间戳、vite.config 内容的 hash
- 手动清除:
vite --force或删除.vite目录
按需编译:只有请求到的模块才编译
用户访问 http://localhost:5173
↓
Vite Dev Server(Koa 中间件)
↓
请求 /src/main.ts
↓ (首次请求才编译)
ts → js(esbuild transform)
↓
返回编译后的 JS
↓
浏览器解析 import,发起新请求
↓
请求 /src/App.vue
↓ (首次请求才编译)
.vue → js(vite:vue 插件处理)
↓
只有用户实际访问到的路由/组件才会被编译
HMR 原理(Vite vs Webpack,更快的原因)
Webpack HMR 局限:
文件修改 → 找出受影响的所有模块 → 重新生成受影响的 chunk → 推送给浏览器
问题:依赖链很深时,可能一个小修改导致重新处理很多模块
Vite HMR 优势:
文件修改(精确到模块)
↓
查找 HMR 边界(向上找最近的 accept())
↓
只有边界内的模块需要重新请求
↓
通过 WebSocket 推送精确的 update 消息:
{ type: 'update', updates: [{ path: '/src/Counter.vue' }] }
↓
浏览器直接 import() 新版本模块
↓
Vue/React Fast Refresh 框架层面执行更新
Vite 更快的核心原因:
- 原生 ESM 粒度更细:每个文件是独立模块,HMR 边界更小
- 无需重新打包 chunk:不需要重新生成任何 bundle
- 浏览器缓存:未修改的模块有 HTTP 304 缓存
- esbuild 编译:即使需要重新编译,esbuild 比 babel 快 20~100x
2.2 生产模式(Rollup 打包)
为何生产仍用 Rollup 打包
直接用 ESM + 浏览器加载的问题:
- HTTP 请求过多(数百个模块 = 数百个请求,即使 HTTP/2 也有性能损耗)
- 无法合并小模块,代码重复
- Tree Shaking 效果不理想(浏览器不执行 Tree Shaking)
- 无法进行 code splitting 最优化
- CSS 处理复杂(需要合并和 code splitting 对应)
Rollup 的优势(适合生产打包):
- ESM 输出最纯净(无运行时 runtime 代码)
- Tree Shaking 最彻底(静态分析能力强)
- Scope Hoisting(内联模块,减少闭包开销)
- 成熟的 code splitting(dynamic import)
Rolldown 替换 Rollup(Vite 8+)
Rolldown = Rust 重写的 Rollup 兼容实现
性能提升:
Rollup(JS) → 构建 1000 个模块:~800ms
Rolldown(Rust)→ 构建 1000 个模块:~100ms
提速:5~10x(甚至更高)
兼容性:
- 完全兼容 Rollup 插件 API
- 输出格式相同
- 正在逐步替换(Vite 8 中 Rolldown GA)
构建流程:analyze → bundle → optimize → emit
// Vite 生产构建(vite build)内部流程
// Phase 1: Analyze(分析入口)
const bundle = await rollup.rollup({
input: resolveEntry(viteConfig),
plugins: [
...vitePlugins,
...rollupPlugins,
],
// Rollup 分析依赖图
});
// Phase 2: Bundle(代码分割 + 合并)
const { output } = await bundle.generate({
format: 'es',
chunkFileNames: 'assets/[name]-[hash].js',
entryFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]',
manualChunks: viteConfig.build.rollupOptions?.output?.manualChunks,
});
// Phase 3: Optimize(压缩 + 内联)
for (const chunk of output) {
if (chunk.type === 'chunk') {
chunk.code = await minify(chunk.code); // esbuild/terser 压缩
}
}
// Phase 4: Emit(写入磁盘)
await writeOutputFiles(output, viteConfig.build.outDir);
2.3 Vite 插件系统
Vite 插件 = Rollup 插件超集
// Rollup 插件(可直接在 Vite 中使用)
const myRollupPlugin = {
name: 'my-rollup-plugin',
// Rollup 标准 hooks(dev + build 都执行)
resolveId(id, importer) { /* 解析模块路径 */ },
load(id) { /* 加载模块内容 */ },
transform(code, id) { /* 转换模块代码 */ },
buildStart(options) { /* 构建开始 */ },
buildEnd(error) { /* 构建结束 */ },
generateBundle(options, bundle) { /* 生成 bundle */ },
};
// Vite 特有 hooks(仅 dev 模式)
const myVitePlugin = {
name: 'my-vite-plugin',
// 访问解析后的 Vite 配置
configResolved(config) {
console.log('当前模式:', config.command); // 'serve' | 'build'
},
// 修改 index.html(注入脚本/样式/meta)
transformIndexHtml(html) {
return html.replace(
'<head>',
`<head><meta name="build-time" content="${new Date().toISOString()}">`
);
// 或者返回数组格式(更精细控制注入位置)
return {
html,
tags: [
{
tag: 'script',
attrs: { src: '/analytics.js' },
injectTo: 'body',
},
],
};
},
// 处理 HMR 更新(精细控制哪些文件的 HMR 行为)
handleHotUpdate({ file, server, modules }) {
if (file.endsWith('.json')) {
// JSON 文件变化,触发全量重载
server.ws.send({ type: 'full-reload' });
return []; // 返回空数组表示自己处理了,不走默认逻辑
}
// 返回 undefined 走默认 HMR 逻辑
},
// Dev Server 配置(添加自定义路由/中间件)
configureServer(server) {
server.middlewares.use('/api/mock', (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ mock: true }));
});
},
};
插件执行顺序(enforce: pre/normal/post)
// Vite 插件执行顺序(关键!)
// 1. enforce: 'pre' 插件(最先执行)
// 用途:需要在其他插件处理之前修改代码(如 @vitejs/plugin-vue)
// 2. 普通 Vite 插件(无 enforce)
// 3. enforce: 'post' 插件(最后执行)
// 用途:需要在其他转换完成后处理(如分析器、压缩器)
// 实际示例
export default defineConfig({
plugins: [
// pre: 最先处理 .vue 文件
{ ...vuePlugin, enforce: 'pre' },
// normal: 正常顺序
reactPlugin(),
// post: 最后执行(可看到所有转换后的最终结果)
{ ...analyzerPlugin, enforce: 'post' },
],
});
// 完整执行顺序:
// pre.config → normal.config → post.config
// pre.configResolved → normal.configResolved → post.configResolved
// pre.resolveId → normal.resolveId → post.resolveId
// pre.load → normal.load → post.load
// pre.transform → normal.transform → post.transform
三、Rollup 核心原理
3.1 为何适合库打包(ESM 输出纯净,无 runtime)
Webpack 打包库的问题:
// Webpack 打包后,即使是一个简单的库,也会包含大量 runtime 代码:
/******/ (() => { // webpackBootstrap
/******/ var __webpack_modules__ = ({
/******/ "./src/index.js": ((__unused_webpack_module, __webpack_exports__) => {
// ... 大量 runtime 代码
/******/ })();
Rollup 打包库的输出(极其纯净):
// rollup 输出(ESM 格式)
const add = (a, b) => a + b;
const multiply = (a, b) => a * b;
export { add, multiply };
// 就这么干净!没有任何 runtime 开销
Rollup 为什么适合库:
- 无 runtime 代码(Webpack 有
__webpack_require__等运行时) - 输出格式灵活(ESM/CJS/UMD/IIFE,一次构建多个格式)
- 天然 Tree Shaking(所有文件默认 ESM 处理)
- 用户可以基于 Rollup 输出再做优化
3.2 Scope Hoisting(模块内联,减少闭包)
// 有两个模块
// math.js
export const PI = 3.14159;
export const circumference = radius => 2 * PI * radius;
// main.js
import { circumference } from './math';
console.log(circumference(5));
Webpack 打包(有 module 闭包):
// 每个模块都包裹在函数中(为了模拟 CommonJS 作用域)
__webpack_modules__["./math.js"] = (module, exports) => {
const PI = 3.14159;
const circumference = radius => 2 * PI * radius;
exports.circumference = circumference;
};
// 主模块
const math = __webpack_require__("./math.js");
console.log(math.circumference(5));
Rollup 打包(Scope Hoisting):
// 模块被"内联"到同一作用域
const PI = 3.14159;
const circumference = radius => 2 * PI * radius;
console.log(circumference(5));
// 完全扁平化,无额外函数调用开销
性能好处:
- 减少函数调用(JS 引擎优化更容易)
- 减少闭包(内存占用更少)
- 代码体积更小
- V8 内联优化(inline)效果更好
3.3 Tree Shaking 比 Webpack 更彻底的原因
原因一:Scope Hoisting 让死代码更容易识别
// math.js
export const add = (a, b) => a + b; // 被使用
export const subtract = (a, b) => a - b; // 未被使用
// main.js
import { add } from './math';
console.log(add(1, 2));
Rollup 的处理:
// 内联后,subtract 从来没有被引用 → 直接删除
const add = (a, b) => a + b;
console.log(add(1, 2));
// subtract 彻底消失!
Webpack 的处理:
// 标记 subtract 为 unused,由 Terser 最终删除
// 但仍然有 module 闭包的间接引用,Terser 要分析才能删除
原因二:Rollup 的 Statement 级别分析
Rollup 可以精确到每一条语句(statement)的副作用分析,粒度比 Webpack 更细。
原因三:整个项目默认 ESM
Rollup 在一开始就假设所有模块都是 ESM,不需要处理 CommonJS 的动态性。
四、esbuild 核心原理
4.1 Go 语言实现,为何这么快
速度对比(构建一个中等规模项目):
webpack(5): 12,000 ms
rollup: 9,000 ms
parcel 2: 8,000 ms
esbuild: 200 ms ← 快 40~60 倍!
为什么这么快(四大原因):
1. Go 语言编译为原生机器码
JS(Node.js)→ V8 JIT 编译 → 机器码(动态,有 JIT 开销)
Go → 静态编译 → 原生机器码(无 JIT 开销)
性能差距:原生代码通常比 JIT 快 3~10 倍
2. 并行处理(利用多核 CPU)
// esbuild 内部伪代码
func buildAll(files []string) {
// 用 goroutine 并行处理所有文件
var wg sync.WaitGroup
results := make(chan Result, len(files))
for _, file := range files {
wg.Add(1)
go func(f string) {
defer wg.Done()
results <- parseAndTransform(f) // 并行!
}(file)
}
wg.Wait()
// JS/Python 受 GIL 限制,真并行很难做到
// Go 原生支持轻量级 goroutine(M:N 线程模型)
}
3. 没有过度的抽象层
Webpack 处理一个文件:
Plugin A → Plugin B → Loader C → Loader D → 多次 AST 转换 → ...
每次转换:代码 → AST → 代码 → AST(来回多次)
esbuild 处理一个文件:
一次 Parse(AST)→ 一次 Transform → 一次 Print
整个过程只做一次 AST 解析,减少了大量序列化/反序列化
4. 高效的内存使用
Go 的内存分配效率远高于 JS
GC 压力更小(Go 的 GC 针对低延迟优化)
缓存利用率高(数据结构紧凑,CPU cache 友好)
4.2 功能边界
esbuild 有意保持简单,不支持:
| 功能 | 支持情况 | 原因 |
|---|---|---|
| HMR 热更新 | ❌ 不支持 | 需要 dev server,超出 bundler 范畴 |
| 复杂代码分割 | ⚠️ 基础支持 | 动态 import 支持,但 chunk 分组策略简单 |
| CSS Modules | ❌ 不支持 | 复杂性高,交给 PostCSS 等工具 |
| 插件生态 | ✅ 支持(API 简单) | 但比 Rollup/Webpack 少 |
| TypeScript 类型检查 | ❌ 仅转换,不检查 | 转换很快,但 tsc 检查交给 IDE/CI |
| Vue SFC | ❌ 无官方支持 | 需要第三方插件,功能有限 |
esbuild 擅长的:
- 极速 JS/TS/JSX 转换(Transform,不是 Build)
- 简单项目的全量打包(无复杂分割需求)
- 作为其他工具的编译器内核(Vite 用它做预构建)
- 压缩(Minify,速度远超 Terser)
4.3 在 Vite 中的角色
Vite Dev Server
├── 依赖预构建:esbuild(将 node_modules CJS → ESM,合并小包)
├── 单文件转换:esbuild(TypeScript/JSX → JS,速度快)
└── HMR/路由/插件系统:Vite 自己实现
Vite Build(生产)
├── 打包:Rollup(或 Rolldown)← 不用 esbuild,原因见下
└── 压缩:esbuild(可选,比 Terser 快 10-20x)
为什么 Vite 生产不用 esbuild 打包:
- esbuild 的 code splitting 不够完善
- CSS 处理能力有限
- Tree Shaking 效果不如 Rollup
- 缺少 Rollup 丰富的插件生态
五、横向对比
| 维度 | Webpack 5 | Vite 8 | Rollup 4 | esbuild |
|---|---|---|---|---|
| 冷启动速度 | 慢(全量打包,10s~2min) | 极快(< 1s,ESM按需)⚡⚡⚡ | 中( | 极快(< 1s)⚡⚡⚡ |
| 热更新速度 | 中(需重新打包chunk,3~20s) | 极快(模块级HMR,< 100ms)⚡⚡⚡ | 无(库打包,无dev server) | 无(需自行实现) |
| 生产构建速度 | 慢(大项目 2~5min) | 快(Rolldown GA后 5~10x提速)⚡⚡ | 中等 ⚡ | 最快(10~40x)⚡⚡⚡ |
| 开发体验 | 配置复杂,上手成本高 | 开箱即用,配置简单 ⭐⭐⭐ | 配置手动,适合库开发 | 无完整 Dev 体验 |
| 生产优化 | 功能最全(splitChunks/scope hoisting/多种优化)⭐⭐⭐ | Rollup/Rolldown 生产,优化能力强 ⭐⭐⭐ | 输出最纯净,无 runtime ⭐⭐⭐ | 基础优化,压缩超快 ⭐⭐ |
| 代码分割 | 最强(splitChunks 精细配置)⭐⭐⭐ | 依赖 Rollup(manualChunks)⭐⭐ | 基于 dynamic import,简洁 ⭐⭐ | 基础 dynamic import ⭐ |
| Tree Shaking | 支持(需 ESM + production 模式)⭐⭐ | 依赖 Rollup(更彻底)⭐⭐⭐ | 最彻底(Scope Hoisting)⭐⭐⭐ | 支持(效果中等)⭐⭐ |
| 插件生态 | 最成熟(数千个 loader/plugin)⭐⭐⭐ | 兼容 Rollup 插件 + 专有插件 ⭐⭐⭐ | 生态丰富,库开发覆盖完整 ⭐⭐ | 生态较少,API 简单 ⭐ |
| TypeScript | 需要 ts-loader 或 babel-loader | 内置支持(esbuild 转换)⭐⭐⭐ | 需插件(@rollup/plugin-typescript)⭐⭐ | 原生支持(极快)⭐⭐⭐ |
| CSS 处理 | 功能最完整(CSS Modules/PostCSS/预处理器)⭐⭐⭐ | 内置(PostCSS/CSS Modules/预处理器)⭐⭐⭐ | 需插件,基础支持 ⭐ | 基础CSS,无 Modules ⭐ |
| SSR 支持 | 支持(较复杂)⭐⭐ | 内置 SSR 模式 ⭐⭐⭐ | 不直接支持 ⭐ | 不支持 |
| 学习曲线 | 陡峭(配置项多)❗❗❗ | 平缓(约定大于配置)✅ | 中等(主要配置 input/output/plugins)✅ | 简单(API 极少)✅ |
| 适用场景 | 大型复杂应用(遗留项目/微前端/特殊需求) | 新项目首选(应用开发/SSR/微前端)⭐ | 库/组件包发布首选 ⭐ | 工具链内核(如 Vite 预构建)⭐ |
| 典型用户 | 大厂存量项目/CRA | 新项目/Vue3/React新项目 | React/Vue 生态库作者 | Vite/Bun/Deno 内部 |
六、实战配置
6.1 Webpack 5 完整配置(含注释剥离、压缩、splitChunks)
// webpack.config.js(完整生产配置)
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const isDev = process.env.NODE_ENV === 'development';
module.exports = {
mode: isDev ? 'development' : 'production',
// 入口(支持多入口)
entry: {
main: './src/index.tsx',
// polyfill: './src/polyfill.ts', // 可拆分 polyfill
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: isDev ? 'js/[name].js' : 'js/[name].[contenthash:8].js',
chunkFilename: isDev ? 'js/[name].chunk.js' : 'js/[name].[contenthash:8].chunk.js',
assetModuleFilename: 'assets/[name].[contenthash:8][ext]',
clean: true, // 构建前清空 dist
publicPath: '/',
},
// Source Map
devtool: isDev ? 'eval-cheap-module-source-map' : 'hidden-source-map',
resolve: {
extensions: ['.tsx', '.ts', '.js', '.jsx', '.json'],
alias: {
'@': path.resolve(__dirname, 'src'),
},
// 优先使用 ESM 版本(Tree Shaking 更好)
mainFields: ['module', 'browser', 'main'],
},
module: {
rules: [
// TypeScript / JavaScript
{
test: /.[jt]sx?$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', {
useBuiltIns: 'usage',
corejs: 3,
targets: '> 0.5%, not dead',
}],
'@babel/preset-typescript',
['@babel/preset-react', { runtime: 'automatic' }],
],
// 禁用注释(生产环境,babel 不保留)
comments: isDev,
plugins: [
isDev && require.resolve('react-refresh/babel'),
].filter(Boolean),
},
},
],
},
// CSS
{
test: /.css$/,
use: [
isDev ? 'style-loader' : MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
modules: {
// CSS Modules:文件名带 .module.css
auto: /.module.css$/,
localIdentName: isDev
? '[path][name]__[local]'
: '[contenthash:8]',
},
},
},
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: ['autoprefixer', 'postcss-preset-env'],
},
},
},
],
},
// SCSS
{
test: /.s[ac]ss$/,
use: [
isDev ? 'style-loader' : MiniCssExtractPlugin.loader,
'css-loader',
'postcss-loader',
'sass-loader',
],
},
// 图片(Webpack 5 内置 asset modules,不需要 file-loader)
{
test: /.(png|jpe?g|gif|webp|svg)$/,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 8 * 1024, // < 8KB 转 base64
},
},
},
// 字体
{
test: /.(woff2?|eot|ttf|otf)$/,
type: 'asset/resource',
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
favicon: './public/favicon.ico',
// 注入时,自动添加 contenthash 的 script/link 标签
minify: isDev ? false : {
removeComments: true, // 删除 HTML 注释
collapseWhitespace: true,
removeAttributeQuotes: false,
},
}),
!isDev && new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash:8].css',
chunkFilename: 'css/[name].[contenthash:8].chunk.css',
}),
// 分析产物大小(按需开启)
process.env.ANALYZE && new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'bundle-report.html',
}),
].filter(Boolean),
optimization: {
minimize: !isDev,
minimizer: [
// JS 压缩(含注释剥离)
new TerserPlugin({
parallel: true, // 多进程压缩
extractComments: false, // 不生成 .LICENSE.txt(内部项目)
terserOptions: {
ecma: 2020,
format: {
// 🔑 核心:删除所有注释
comments: false,
// 保留 License:comments: /^**!|@preserve|@license/i
},
compress: {
drop_console: true, // 删除 console.log
drop_debugger: true,
dead_code: true, // 删除不可达代码
evaluate: true, // 常量折叠
passes: 2, // 多次压缩(更彻底,稍慢)
pure_funcs: ['console.info', 'console.debug', 'console.warn'],
},
mangle: {
safari10: true, // 修复 Safari 10 bug
},
},
}),
// CSS 压缩
new CssMinimizerPlugin({
minimizerOptions: {
preset: ['default', {
discardComments: { removeAll: true }, // 删除所有 CSS 注释
}],
},
}),
],
// 代码分割(精细配置)
splitChunks: {
chunks: 'all',
minSize: 20000, // 最小 20KB 才分割
maxSize: 244000, // 最大 244KB(超过继续分割)
minChunks: 1,
maxAsyncRequests: 30,
maxInitialRequests: 30,
cacheGroups: {
// React 核心库单独打包(长期缓存)
reactVendors: {
test: /[\/]node_modules[\/](react|react-dom|react-router|react-router-dom|scheduler)[\/]/,
name: 'react-vendors',
chunks: 'initial',
priority: 30,
enforce: true, // 忽略 minSize/maxSize
},
// 其他 node_modules
vendors: {
test: /[\/]node_modules[\/]/,
name(module) {
// 按包名分组(更细粒度的缓存)
const packageName = module.context.match(
/[\/]node_modules[\/](.*?)([\/]|$)/
)[1];
return `npm.${packageName.replace('@', '')}`;
},
priority: 20,
reuseExistingChunk: true,
},
// 公共业务代码(被 2+ chunk 引用)
common: {
minChunks: 2,
name: 'common',
priority: 10,
reuseExistingChunk: true,
},
},
},
// 将 webpack runtime 单独提取(避免每次内容变化影响 vendors hash)
runtimeChunk: {
name: 'runtime',
},
// Tree Shaking 相关
usedExports: true, // 标记 used exports
concatenateModules: true, // Scope Hoisting
innerGraph: true, // 追踪模块内部依赖(更精确的 Tree Shaking)
sideEffects: true, // 读取 package.json 中的 sideEffects
},
// 开发服务器
devServer: {
port: 3000,
hot: true,
historyApiFallback: true,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
// 性能预算
performance: {
hints: isDev ? false : 'warning',
maxEntrypointSize: 512 * 1024, // 500KB
maxAssetSize: 512 * 1024,
},
};
6.2 Vite 完整配置(含自定义插件、构建优化)
// vite.config.ts
import { defineConfig, loadEnv, Plugin } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
import { visualizer } from 'rollup-plugin-visualizer';
// 自定义插件:自动注入构建信息到 window.__BUILD_INFO__
function buildInfoPlugin(): Plugin {
return {
name: 'build-info',
enforce: 'post',
// 构建开始时生成构建信息
buildStart() {
this.buildInfo = {
time: new Date().toISOString(),
version: process.env.npm_package_version,
commit: process.env.COMMIT_SHA || 'dev',
};
},
// 在 index.html 中注入构建信息
transformIndexHtml(html) {
return {
html,
tags: [{
tag: 'script',
attrs: { type: 'text/javascript' },
children: `window.__BUILD_INFO__ = ${JSON.stringify(this.buildInfo)};`,
injectTo: 'head-prepend',
}],
};
},
};
}
// 自定义插件:移除生产环境中的 console.log
function removeConsolePlugin(): Plugin {
return {
name: 'remove-console',
transform(code, id) {
// 只处理生产环境的 JS 文件
if (process.env.NODE_ENV !== 'production') return;
if (!id.match(/.[jt]sx?$/)) return;
if (id.includes('node_modules')) return;
// 简单方案:正则替换(生产中应用 esbuild 选项,更可靠)
return code.replace(/console.(log|debug|info)(.*?);?/g, '');
},
};
}
export default defineConfig(({ mode }) => {
// 加载环境变量(.env, .env.production 等)
const env = loadEnv(mode, process.cwd(), '');
const isProd = mode === 'production';
return {
// 全局别名
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@components': resolve(__dirname, 'src/components'),
'@hooks': resolve(__dirname, 'src/hooks'),
'@utils': resolve(__dirname, 'src/utils'),
'@assets': resolve(__dirname, 'src/assets'),
},
},
// 全局 CSS 变量注入
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "@/styles/variables.scss";`,
},
},
modules: {
// CSS Modules 类名格式
generateScopedName: isProd
? '[hash:base64:8]'
: '[name]__[local]__[hash:base64:5]',
},
},
// 插件
plugins: [
react({
// React Fast Refresh(dev)/ 自动 JSX runtime
babel: {
plugins: [
// 只在开发环境开启 Fast Refresh
...(!isProd ? [['babel-plugin-react-refresh', {}]] : []),
],
},
}),
buildInfoPlugin(),
isProd && removeConsolePlugin(),
// Bundle 大小分析(ANALYZE=true vite build)
process.env.ANALYZE && visualizer({
open: true,
gzipSize: true,
brotliSize: true,
filename: 'dist/stats.html',
}),
].filter(Boolean),
// 环境变量(暴露给前端)
define: {
'__APP_VERSION__': JSON.stringify(env.npm_package_version),
'__API_BASE__': JSON.stringify(env.VITE_API_BASE_URL),
},
// Dev Server
server: {
port: 5173,
host: true, // 允许局域网访问
open: true,
proxy: {
'/api': {
target: env.VITE_API_PROXY_TARGET,
changeOrigin: true,
rewrite: (path) => path.replace(/^/api/, ''),
},
},
},
// 预构建(依赖预构建优化)
optimizeDeps: {
// 手动添加需要预构建的依赖(通常 Vite 自动检测)
include: ['lodash-es', 'axios', 'dayjs'],
// 排除不需要预构建的(纯 ESM 的库)
exclude: ['@vueuse/core'],
},
// 生产构建
build: {
target: 'es2020',
outDir: 'dist',
assetsDir: 'assets',
sourcemap: isProd ? 'hidden' : true, // 生产用 hidden(Sentry 上传用)
// Rollup 配置
rollupOptions: {
output: {
// 手动分割 chunks(精细控制 vendor 缓存)
manualChunks: (id) => {
// React 生态单独 chunk
if (id.includes('/node_modules/react') ||
id.includes('/node_modules/react-dom') ||
id.includes('/node_modules/scheduler')) {
return 'react-vendor';
}
// 路由
if (id.includes('/node_modules/react-router')) {
return 'router';
}
// 工具库
if (id.includes('/node_modules/lodash') ||
id.includes('/node_modules/dayjs') ||
id.includes('/node_modules/axios')) {
return 'utils-vendor';
}
// 其他 node_modules(统一打包)
if (id.includes('/node_modules/')) {
return 'vendor';
}
},
// 文件命名
chunkFileNames: 'js/[name]-[hash].js',
entryFileNames: 'js/[name]-[hash].js',
assetFileNames: ({ name }) => {
if (/.(png|jpe?g|gif|svg|webp)$/.test(name || '')) {
return 'images/[name]-[hash][extname]';
}
if (/.(css)$/.test(name || '')) {
return 'css/[name]-[hash][extname]';
}
if (/.(woff2?|eot|ttf|otf)$/.test(name || '')) {
return 'fonts/[name]-[hash][extname]';
}
return 'assets/[name]-[hash][extname]';
},
},
},
// 压缩配置
minify: 'esbuild', // 'terser' | 'esbuild' | false
// esbuild 比 terser 快 10~20x,但压缩率略低
// esbuild 压缩选项(minify: 'esbuild' 时有效)
// esbuildOptions 通过 vite 的 esbuild 选项配置
// 注意:移除注释通过 esbuild 的 legalComments 控制
// CSS 代码分割(每个 async chunk 提取独立 CSS)
cssCodeSplit: true,
// 静态资源 inline 阈值
assetsInlineLimit: 8192, // 8KB 以下 inline
// chunk 大小警告阈值
chunkSizeWarningLimit: 1000, // 1000KB
// 是否生成 manifest.json(用于后端路由集成)
manifest: isProd,
},
// esbuild 转换配置(dev + build 都生效)
esbuild: {
// 删除 console 和 debugger
drop: isProd ? ['console', 'debugger'] : [],
// 删除注释
legalComments: isProd ? 'none' : 'inline',
// JSX 注入
jsxImportSource: 'react',
},
};
});
6.3 如何验证 Tree Shaking 有效
方法一:Bundle Analyzer 可视化
# Webpack
ANALYZE=true webpack --config webpack.config.js
# 打开 http://localhost:8888 查看交互式图表
# Vite
ANALYZE=true vite build
# 打开 dist/stats.html
使用 webpack-bundle-analyzer:
// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
plugins: [
process.env.ANALYZE && new BundleAnalyzerPlugin({
analyzerMode: 'server', // 启动本地服务器
// analyzerMode: 'static', // 生成静态 HTML
openAnalyzer: true,
generateStatsFile: true,
statsFilename: 'stats.json',
}),
].filter(Boolean),
};
Vite / Rollup:
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
plugins: [
visualizer({
open: true,
gzipSize: true,
brotliSize: true,
template: 'treemap', // 'treemap' | 'sunburst' | 'network'
}),
]
方法二:source-map-explorer 精确分析
npm install -g source-map-explorer
# Webpack(需要开启 source-map)
npx source-map-explorer dist/js/main.*.js
# 会打开浏览器显示每个依赖的实际大小
方法三:手动验证(小实验)
// math.js(测试库)
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b; // 故意不使用
export const multiply = (a, b) => a * b; // 故意不使用
// main.js(只用 add)
import { add } from './math';
console.log(add(1, 2));
# 构建后,搜索 bundle 中是否包含 subtract/multiply
grep -r "subtract" dist/
grep -r "multiply" dist/
# 如果 Tree Shaking 有效:找不到这两个函数
# 如果无效:能找到(说明 Tree Shaking 失效)
方法四:sideEffects 验证
// package.json(告知所有文件无副作用)
{
"sideEffects": false
}
// 验证方法
// 引入一个文件,但不使用任何导出
import './utils/logger'; // 只是 import,没有用任何东西
// 如果 sideEffects: false,这个 import 应该被 Tree Shaking 删除
// 检查 bundle 中是否包含 logger 的代码
方法五:Webpack Stats JSON 分析
// webpack.config.js
module.exports = {
stats: {
// 详细统计信息
optimizationBailout: true, // 显示为什么 Tree Shaking 失败的模块
},
};
webpack --json > stats.json
# 用 https://webpack.github.io/analyse/ 上传 stats.json 分析
常见 Tree Shaking 失效原因排查:
| 问题 | 原因 | 解决方案 |
|---|---|---|
import * as xxx from | 命名空间导入,无法确定用哪些 | 改为具名导入 import { add } from |
| CommonJS 依赖 | require() 动态,无法静态分析 | 找 ESM 版本(lodash-es 替代 lodash) |
sideEffects 未配置 | Webpack 保守策略,不删除 | 在 package.json 配置 sideEffects: false |
| Babel 编译 ESM → CJS | 某些旧配置会把 ESM 转成 CJS | 确保 @babel/preset-env 的 modules: false |
| 副作用代码 | 模块顶层有副作用(如修改全局对象) | 加 /*#__PURE__*/ 或移除副作用 |
七、常见面试题精选
Q1:Webpack 的 Loader 和 Plugin 有什么区别?
Loader: 文件转换器,专注于单文件的转换(非 JS/JSON → JS)
- 在模块加载阶段运行
- 是一个函数,输入 source,输出转换后的 source
- 有序执行(pitch 从左到右,normal 从右到左)
Plugin: 功能扩展器,可以介入构建的任意阶段
- 基于 Tapable 事件系统
- 可以访问 Compiler 和 Compilation 对象
- 能做任何 Loader 不能做的事(生成文件、修改 bundle、添加资源等)
Q2:为什么 Vite 开发环境这么快,生产用 Rollup?
开发快: 利用浏览器原生 ESM,无需打包 = 零构建时间,只有 esbuild 预构建 node_modules
生产用 Rollup:
- 浏览器加载 1000+ 个 ESM 文件网络开销巨大(即使 HTTP/2)
- Rollup Tree Shaking 更彻底,输出更纯净
- 成熟的 code splitting 和插件生态
Q3:如何彻底删除所有代码注释?
// Webpack + TerserPlugin
new TerserPlugin({
extractComments: false,
terserOptions: {
format: { comments: false },
},
})
// Vite + esbuild
esbuild: {
legalComments: 'none', // 删除所有注释包括 License
}
// 或 build.minify: 'terser' + terserOptions
Q4:Tree Shaking 的必要条件是什么?
- 使用 ESM(
import/export语法,不是require) - Webpack 的
mode: 'production'或optimization.usedExports: true package.json配置sideEffects: false(或列出有副作用的文件)- Babel 不把 ESM 转成 CJS(
modules: false) - 依赖库提供 ESM 版本(
lodash-es而不是lodash)
Q5:HMR 和 live reload 的区别?
live reload(全量刷新): 文件改变 → 整页刷新 → 应用状态丢失
HMR(模块热替换): 文件改变 → 只更新修改的模块 → 保留组件状态
HMR 需要:
- 开发服务器支持(WebSocket 推送)
- 框架集成(React Fast Refresh / Vue HMR)
module.hot.accept注册更新回调
📝 学习总结: 打包工具的核心设计哲学是"在开发体验和生产优化之间找到最佳平衡"。Webpack 选择了灵活性(插件生态)、Vite 选择了开发体验(No-bundle)、Rollup 选择了纯净输出(库打包)、esbuild 选择了极速(工具链内核)。理解各自的设计权衡,才能在实际项目中做出正确选择。