本文会从webpack文件结构入手,梳理出webpack核心的用法和概念。
你将会收获:
- webpack的
entry和output配置 - webpack的
loader配置、原理、以及实战 - webpack的
plugin配置、原理、以及实战 - webpack的
HMR配置、原理 - webpack的
source map配置 - webpack原理(是如何运行的?)
- 杂项:
一、Webpack文件结构
下面是一个webpack.config.js文件基本的配置:
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development', // 模式
entry: './src/index.js', // 打包入口地址
output: {
filename: 'bundle.js', // 输出文件名
path: path.join(__dirname, 'dist') // 输出文件目录
clean: true, // 清理 dist 目录
},
module: {
rules: [
{
test: /.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
},
},
{
test: /.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html', // 指定模板文件
filename: 'index.html', // 输出的文件名
}),
],
}
1. webpack的entry和output配置
- entry:入口路径
- output.filename:输出文件名称
- output.path:输出目录名称
上面的例子是单入口写法,还有多入口打包,生成多个js的方式。
例如:页面index/ 和 页面about/ 是两个独立的入口。
...
entry: {
index: './src/index/index.js', // 第一个入口
about: './src/about/about.js', // 第二个入口
},
output: {
filename: '[name].bundle.js', // 使用 [name] 占位符生成动态文件名
path: path.resolve(__dirname, 'dist'), // 输出目录
clean: true, // 清理 dist 目录
},
...
当然,filename是可以使用hash值命名的。例如
...
output: {
filename: '[name].[contenthash].bundle.js',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
...
Webpack 提供了多种哈希值占位符:
[hash]:基于整个项目的构建生成哈希值,所有文件共享同一个哈希值。[chunkhash]:基于每个 chunk 的内容生成哈希值,适用于多入口打包。[contenthash]:基于文件内容生成哈希值,通常用于 CSS 或静态资源。
推荐使用 [contenthash],因为它可以更精确地反映文件内容的变化。好处是:
- 当文件内容变化时,哈希值会改变,浏览器会加载新文件。
- 当文件内容不变时,哈希值不变,浏览器会使用缓存。
注意:一般只在生产阶段使用hash,开发阶段无需使用。
2. webpack的loader配置、原理、以及实战
截取 Loader 代码如下:
...
module: {
rules: [
{
test: /.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'], // Babel 配置
},
},
},
{
test: /.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
],
},
...
可见 Loader 通过 module.rules 进行配置。每个规则(Rule)通常包括以下属性:
test:匹配文件的正则表达式。use:指定使用的 Loader,可以是字符串、对象或数组。exclude:排除不需要处理的文件或目录。include:指定需要处理的文件或目录。loader:指定单个 Loader(use的简写形式)。options:传递给 Loader 的配置选项。
Loader 是一个函数,它接收源文件内容作为输入,经过处理后返回新的内容。Webpack 会按照配置的顺序依次调用 Loader。
2.1 Loader 的执行流程和原理
- 匹配文件:Webpack 根据
module.rules中的test正则表达式匹配文件。 - 链式调用:如果匹配成功,Webpack 会按照
use数组中 Loader 的顺序依次调用。 - 处理文件:每个 Loader 接收上一个 Loader 的处理结果,进行进一步处理。
- 返回结果:最后一个 Loader 返回 JavaScript 代码或可处理的模块。
值得一提的是 Laoder 的执行顺序是从后往前的。
2.2 Loader 的实战
以下是一个简单的自定义 Loader,这个 Loader 的功能是自动移除 JavaScript 文件中的 console.log 语句,适用于生产环境打包时清理调试代码。
/**
* 自定义 Loader:移除 JavaScript 文件中的 console.log 语句
* @param {string} source - JavaScript 文件内容
* @returns {string} - 处理后的 JavaScript 内容
*/
module.exports = function (source) {
// 使用正则表达式匹配并移除 console.log 语句
const cleanedSource = source.replace(/console.log(.*?);?/g, '');
// 返回处理后的内容
return cleanedSource;
}
在 Webpack 配置中使用自定义 Loader:
...
rules: [
{
test: /.js$/, // 匹配 .js 文件
use: [
{
loader: path.resolve(__dirname, 'remove-console-loader.js'), // 使用自定义 Loader
},
'babel-loader', // 其他 Loader(如 Babel)
],
},
],
...
至此,你应该对 Loader 的工作流程有一个初步的认识了,在文章末尾,会将 Loader 的原理串联进webpack的原理之中。
3. webpack的plugin配置、原理、以及实战
plugin的配置简单直接,是直接通过在plugins字段里面新建对象。
const HtmlWebpackPlugin = require('html-webpack-plugin');
...
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html', // 指定模板文件
filename: 'index.html', // 输出的文件名
}),
],
...
可见:每一个plugin都是一个js对象,首先require进来构造函数,再new出这个实例对象。
那么问题来了,plugin的构造函数是怎么写的呢?下面展示一个简单的plugin类。
class MyPlugin {
apply(compiler) {
// 监听 'done' 钩子(构建完成时触发)
compiler.hooks.done.tap('MyPlugin', (stats) => {
console.log('构建完成!');
});
// 监听 'emit' 钩子(生成资源到输出目录之前触发)
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
console.log('正在生成资源...');
// 可以访问 compilation.assets 修改输出内容
callback();
});
}
}
module.exports = MyPlugin;
这是一个在构建的不同阶段打印出对应文字的plugin。可以看到其实plugin类的写法非常简单:
- 首先内部实现了一个
apply方法,传入的参数是compiler。(这一步是固定的) compiler.hooks.[不同生命周期].tap('插件名称',(不同生命周期会有不同的参数)=>{})
注意:callback并非由我们自定义,而是用来通知webpack我们的钩子执行结束,可以进行下一步操作了,所以无论成功与否,都需要去执行callback()/callback(err)。
3.1 Plugin 的执行流程和原理
好了,上面我们知道了plugin的配置,以及plugin是如何编写的。但是一直到现在为止,我觉得把plugin的详细原理讲出来还是不太妥当。不妨我们卖个关子,先讲个大概。
webpack的构建过程其实有很多个生命周期。webpack提供给plugin监听各个生命周期的hooks,让开发者在不同的构建阶段介入,做一些自定义的操作。- 这些操作包括但不限于对产物的操作,例如:压缩js代码,提供热更新,显示构建进度等等。
粗略的说就是这些,具体的生命周期,和执行流程,我们放到最后的原理中详细阐述。
3.2 Plugin 的实战
就用上面那个简单的例子来说明好了。如下:
class MyPlugin {
apply(compiler) {
// 监听 'done' 钩子(构建完成时触发)
compiler.hooks.done.tap('MyPlugin', (stats) => {
console.log('构建完成!');
});
// 监听 'emit' 钩子(生成资源到输出目录之前触发)
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
console.log('正在生成资源...');
// 可以访问 compilation.assets 修改输出内容
callback();
});
}
}
module.exports = MyPlugin;
在配置文件中,可以这样写:
// webpack.config.js
const MyPlugin = require('./MyPlugin'); // 引入自定义 Plugin
module.exports = {
...
plugins: [
new MyPlugin(), // 使用自定义 Plugin
],
...
};
4.HMR配置、原理
HMR是webpack的核心功能之一,拥有热更新的项目给开发者带来的极大的便利。 通过设置devServer:{hot:true}就可以开启热更新,开启后可以根据代码的改动局部更新相应的页面内容,无需刷新整个页面。
配置如下:
...
devServer: {
hot: true, // 启用HMR
...
},
// webpack 5+不需要手动添加 HotModuleReplacementPlugin
4.1 HMR的流程及原理。
阶段 1:启动阶段
-
npm run dev触发-
启动
webpack-dev-server,同时创建两个实例:- Webpack 实例:监听文件变化、执行增量编译。
- Server 实例:托管静态资源、提供 WebSocket 服务(默认端口
8080)。
-
-
注入客户端运行时
-
自动注入以下代码到打包结果中:
// 1. WebSocket 客户端(通信层) import 'webpack-dev-server/client?http://localhost:8080'; // 2. HMR 核心逻辑(控制层) import 'webpack/hot/dev-server'; -
补充说明:
- 实际注入的代码由
HotModuleReplacementPlugin生成,包含module.hotAPI 实现。 - 生产环境构建时会自动剔除这些代码。
- 实际注入的代码由
-
阶段 2:文件修改与编译
-
监听文件变化
-
Webpack 通过
chokidar库监听文件系统,触发重新编译。 -
仅重新编译变更的模块,生成新 hash 和增量更新文件:
[hash].hot-update.json(变更清单)[chunkId].[hash].hot-update.js(增量模块代码)
-
-
推送变更通知
-
Server 通过 WebSocket 向浏览器发送消息:
// 消息示例 { type: 'hash', data: 'a1b2c3' } // 新编译的 hash 值 { type: 'still-ok' } // 编译完成但无更新 { type: 'error', errors: [...] } // 编译错误
-
阶段 3:浏览器处理更新
-
接收通知并拉取更新
-
浏览器 HMR 运行时收到
hash事件后:- 请求 Manifest:
GET a1b2c3.hot-update.json(确认哪些 chunk 需要更新)。 - 下载增量模块:
GET 1.a1b2c3.hot-update.js(动态加载变更的模块代码)。
- 请求 Manifest:
-
-
应用更新
-
关键逻辑判断:
flowchart LR A[接收新模块] --> B{目标模块或其父模块\n是否调用 module.hot.accept?} B -->|是| C[执行 accept 回调] B -->|否| D[整页刷新] -
具体行为:
- 有
accept回调:执行回调并局部更新(如重新渲染 React 组件)。 - 无
accept:回退到window.location.reload()。 - 更新失败(如模块执行报错):自动回退到整页刷新。
- 有
-
阶段 4:容错与优化
-
错误处理
- 编译错误:通过 WebSocket 推送
error类型消息,浏览器展示 overlay 错误遮罩。 - 运行时错误:HMR 自动回退到整页刷新。
- 编译错误:通过 WebSocket 推送
-
性能优化
-
Webpack 5 改进:
- 按需注入 HMR 运行时(未使用的 API 不生成代码)。
- 可能合并
hash和manifest消息,减少请求次数。
-
5. source-map
介绍source-map之前,想先问读者一个问题。你们有仔细观察过发布到线上的代码吗?能否直接通过控制台查看网页源码?如果能查看源码,那么是否会带来安全问题呢?
我们在dev调试的时候,是不需要对源代码进行代码压缩和代码混淆的,因为我们需要查看程序的执行,和错误堆栈。但是当我们的代码发布到生产环境,代码压缩可以有效减少代码体积,带来性能提升,代码混淆又能更好的保护我们代码逻辑的安全性,不容易被外部发现漏洞进行攻击。
比如:查看本地加密逻辑,构建爬虫工具不断爬取企业有价值的接口信息,构建黑客私有的信息库。
基于此source map的功能会便捷的解决以上问题。 5.1什么是source-map Source Map 是一个信息文件,它存储了源代码和转换后代码(如压缩、合并、编译后的代码)之间的位置映射关系。它就像一个“翻译字典”或“地图”,允许浏览器或调试工具将运行时的代码“映射”回原始的、人类可读的源代码。 现代前端开发流程中,源代码通常会经过一系列复杂的处理:
- 编译: 将 TypeScript、SCSS/Less、ES6+ 等高阶语言转换为浏览器能理解的 JavaScript 和 CSS。
- 合并: 将多个模块文件打包成一个或几个 bundle 文件以减少 HTTP 请求。
- 压缩: 删除空格、注释、缩短变量名(丑化)以减小文件体积。
- 优化: 进行 Tree Shaking、代码分割等。
最终部署到生产环境的是这些转换后的文件。如果代码在浏览器中运行时出现错误,调试工具(如 Chrome DevTools)只能定位到转换后代码的位置。试想一下,在压缩后的单行代码中看到一个错误,变量名都是 a, b, c,这几乎是无法调试的。
5.2如何使用source-map
可以通过devtool或者sourceMapDevToolPlugin来配置sourcemap,常用的可以设置成eval、eval-source-map、eval-cheap-source-map等,具体可根据项目情况选择不同的soucemap模式。可以参考官网。 工作流程:
-
Webpack 在打包时,根据你的配置生成(或不生成) Source Map。
-
在生成的 bundle 文件末尾,会添加一行特殊注释(或通过其他方式关联):
javascript
//# sourceMappingURL=bundle.js.map这行注释告诉浏览器:“这个文件的 Source Map 在哪里”。
-
当你在浏览器 DevTools 中开启 JavaScript 源映射功能后(默认开启),浏览器会下载并解析这个
.map文件。 -
当你在 DevTools 的 Sources 面板中查看代码或遇到错误时,浏览器会利用这个映射关系,将错误堆栈和显示的代码反向映射到原始的源代码上。