重温前端打包工具-Webpack

Webpack

理解 Webpack,最好不要一上来就把它拆成一堆孤立概念。Webpack 的核心其实只有一句话:它从入口文件开始,递归分析项目里的模块依赖,把 JavaScript、CSS、图片、字体、框架组件等资源都纳入同一张依赖图,最后输出浏览器能够加载的静态资源。

所以 Webpack 首先是一个静态模块打包器。所谓“静态”,是指它在构建阶段分析代码;所谓“模块”,是指在 Webpack 的视角里,不只有 JS 文件才是模块,CSS、图片、字体、Vue 单文件组件、TypeScript 文件也都可以是模块;所谓“打包”,就是把这些模块经过转换、合并、拆分、优化之后,生成一个或多个 bundle。

这也是 Webpack 和 Grunt、Gulp 这类工具最大的区别。Grunt 和 Gulp 更像任务执行器,关注“文件如何按任务流转”,例如先编译 Sass,再压缩 JS,再复制图片。Webpack 更关注“模块之间如何依赖”,只要告诉它入口在哪里,它就会沿着 importrequire、CSS 中的 url() 等关系自动构建依赖图。它当然也能完成编译、压缩、复制资源这些任务,但它的出发点不是任务流水线,而是模块图。

从入口到出口:Webpack 如何组织一次构建

Webpack 配置里最基础的是 entryoutputentry 告诉 Webpack 从哪个文件开始分析依赖;output 告诉 Webpack 最终把构建结果输出到哪里、文件如何命名。

1
2
3
4
5
6
7
8
9
10
const path = require('path');

module.exports = {
entry: './src/index.js',
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
};

单入口常用于单页应用,多入口常用于多页面应用。多入口时,每个入口通常对应一个页面,Webpack 会为不同入口生成不同 chunk。再配合 HtmlWebpackPlugin,可以让每个页面只引入自己需要的 chunk。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
entry: {
pageOne: './src/pageOne/index.js',
pageTwo: './src/pageTwo/index.js',
},
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
plugins: [
new HtmlWebpackPlugin({
template: './src/pageOne/index.html',
filename: 'pageOne.html',
chunks: ['pageOne'],
}),
new HtmlWebpackPlugin({
template: './src/pageTwo/index.html',
filename: 'pageTwo.html',
chunks: ['pageTwo'],
}),
],
optimization: {
splitChunks: {
chunks: 'all',
},
},
};

完整构建流程可以理解成四步。

  • 第一步是初始化,Webpack 会读取配置文件和命令行参数,合并出最终配置。
  • 第二步是编译,它从 entry 出发,解析模块内容,遇到非原生可理解的文件就交给 loader 转换,然后继续分析这些模块的依赖,直到形成完整的依赖图。
  • 第三步是生成资源,Webpack 会把模块组合成 chunk,再把 chunk 转成最终文件。
  • 第四步是输出,把文件写入 output.path。在这整个过程中,plugin 会在不同生命周期钩子里介入,比如生成 HTML、清理目录、压缩资源、注入变量等。

Loader:让 Webpack 看懂各种文件

Webpack 原生只能理解 JavaScript 和 JSON。如果项目里有 CSS、Sass、Less、图片、字体、TypeScript、JSX、Vue 单文件组件,这些内容都需要经过 loader 转换成 Webpack 能够纳入依赖图的模块。

Loader 的核心职责是“转换单个文件”。它配置在 module.rules 里,每条规则通常包含 testusetest 用来匹配文件,use 指定使用哪些 loader。多个 loader 的执行顺序是从右到左,也就是 use: ['style-loader', 'css-loader', 'sass-loader'] 中,先执行 sass-loader,再执行 css-loader,最后执行 style-loader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
},
},
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
],
},
};

处理 JavaScript 兼容性时,最常见的是 babel-loader。它是 Webpack 和 Babel 之间的桥梁,用 Babel 把 ES6+ 代码转换成更兼容的 JavaScript。(实际工作都是babel做的,babel-loader只是为了兼容webpack做的adapter)实际项目中通常还会有 babel.config.js.babelrc,用 @babel/preset-env 控制目标浏览器,用 @babel/preset-react 处理 JSX,用 @babel/preset-typescript 处理 TypeScript。

1
2
3
4
5
6
7
8
9
10
11
12
13
module.exports = {
presets: [
[
'@babel/preset-env',
{
useBuiltIns: 'usage',
corejs: 3,
},
],
'@babel/preset-react',
'@babel/preset-typescript',
],
};

如果要支持 TypeScript,也可以直接用 ts-loader。这种方式需要安装 typescriptts-loader,并准备 tsconfig.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module.exports = {
entry: './src/index.ts',
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
};

样式处理最典型的是 css-loaderstyle-loader 的配合。css-loader 负责解析 CSS 中的 @importurl(),让 CSS 变成 Webpack 能识别的模块;style-loader 负责把解析后的 CSS 通过 <style> 标签注入页面。也就是说,css-loader 解决“读懂 CSS”,style-loader 解决“把 CSS 应用到页面”。

如果使用 Sass 或 Less,就在链路最右侧再加预处理器 loader。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
module.exports = {
module: {
rules: [
{
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
},
{
test: /\.less$/,
use: ['style-loader', 'css-loader', 'less-loader'],
},
],
},
};

生产环境里通常不再用 style-loader 把样式塞进 JS,而是用 MiniCssExtractPlugin.loader 把 CSS 抽成独立文件。这样 CSS 和 JS 可以并行加载,也能分别缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader'],
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: 'styles/[name].[contenthash].css',
}),
],
};

处理图片和字体时,Webpack 5 推荐使用内置 Asset Modules,不再需要 Webpack 4 时代常见的 file-loaderurl-loaderasset/resource 会输出独立文件并返回 URL,类似 file-loaderasset/inline 会把资源转成 Data URI,类似 url-loaderasset/source 会导出资源源码;asset 会根据文件大小自动在独立文件和内联之间选择。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
module.exports = {
module: {
rules: [
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
generator: {
filename: 'images/[name][contenthash][ext]',
},
},
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
generator: {
filename: 'fonts/[name][contenthash][ext]',
},
},
],
},
};

如果是 Webpack 4,图片字体一般用 file-loaderurl-loaderfile-loader 负责复制文件并返回 URL;url-loader 在文件小于 limit 时会转成 Base64 内联,大于阈值时行为类似 file-loader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
module.exports = {
module: {
rules: [
{
test: /\.(png|svg|jpg|gif)$/i,
use: [
{
loader: 'url-loader',
options: {
limit: 8192,
name: 'images/[name].[hash:8].[ext]',
},
},
],
},
],
},
};

Loader 也可以自定义。一个 loader 本质上就是一个 Node.js 模块,导出一个函数,接收源文件内容,返回转换后的内容。

1
2
3
4
// my-loader.js
module.exports = function(source) {
return source.replace(/console.log(.*);?/g, '');
};

使用自定义 loader 时,可以通过 resolveLoader.modules 告诉 Webpack 去哪里找 loader。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const path = require('path');

module.exports = {
resolveLoader: {
modules: ['node_modules', path.resolve(__dirname, 'loaders')],
},
module: {
rules: [
{
test: /\.js$/,
use: ['my-loader'],
},
],
},
};

对于耗时 loader,还可以开启缓存。比如 babel-loader 支持 cacheDirectory: true,Webpack 5 本身也提供文件系统级别的持久化缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
module.exports = {
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename],
},
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true,
},
},
},
],
},
};

Plugin:介入整个构建生命周期

如果说 loader 解决的是“某类文件怎么转换”,那么 plugin 解决的是“构建过程中要额外做什么”。Plugin 作用于整个构建流程,可以监听 Webpack 生命周期钩子,在合适时机执行任务,比如生成 HTML、清理 dist、提取 CSS、压缩资源、注入环境变量、分析包体积、拆分公共代码等。

Loader 和 plugin 的区别可以这样记:loader 面向单个模块,职责是转换;plugin 面向整个 compilation,职责是增强。需要改文件内容,用 loader;需要参与构建过程、产物管理或优化,用 plugin。

Plugin 配置在 plugins 数组中,通常需要 new 一个实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const webpack = require('webpack');

module.exports = {
plugins: [
new HtmlWebpackPlugin({
title: 'My App',
template: './src/index.html',
}),
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
}),
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify('production'),
__API_URL__: JSON.stringify('https://api.example.com'),
}),
],
};

HtmlWebpackPlugin 用来生成 HTML,并自动把构建出来的 JS 和 CSS 注入进去。它可以指定模板、输出文件名、注入位置、压缩选项,也可以通过 chunks 控制某个 HTML 只引入指定 chunk,这在多页面应用里很常见。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
plugins: [
new HtmlWebpackPlugin({
title: 'Webpack App',
filename: 'index.html',
template: 'src/template.html',
inject: 'body',
minify: {
removeComments: true,
collapseWhitespace: true,
},
}),
],
};

CleanWebpackPlugin 的作用是构建前清理输出目录,避免 dist 中残留旧文件。不过 Webpack 5 已经内置了这个能力,推荐直接使用 output.clean: true

1
2
3
4
5
module.exports = {
output: {
clean: true,
},
};

DefinePlugin 全局变量无脑替换插件,用于在编译时创建全局常量。需要注意,它做的是文本替换,不是定义插件,所以值通常要用 JSON.stringify 包起来。否则 'production' 可能会被替换成变量名 production,而不是字符串字面量。

  • 服务器一般通过nginx转发,不通过 defineplugin 硬编码,可是有一些第三方服务依然需要
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const webpack = require('webpack');

module.exports = (env) => {
const isProduction = env.production;

return {
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(isProduction ? 'production' : 'development'),
__API_URL__: JSON.stringify(isProduction ? 'https://api.prod.com' : 'https://api.dev.com'),
}),
],
};
};

Webpack 配置环境还可以通过 mode--envmode 设置为 developmentproduction 后,Webpack 会自动启用对应默认优化,并设置 process.env.NODE_ENV--env 可以把命令行参数传给配置函数。

1
2
3
4
5
module.exports = (env) => {
return {
mode: env.production ? 'production' : 'development',
};
};

自定义 plugin 通常是一个类,并实现 apply 方法。Webpack 启动时会调用 apply,并传入 compiler,插件可以通过 compiler.hooks 注册生命周期事件。

1
2
3
4
5
6
7
8
9
class MyCustomPlugin {
apply(compiler) {
compiler.hooks.done.tap('MyCustomPlugin', (stats) => {
console.log('Hello from MyCustomPlugin! Build completed.');
});
}
}

module.exports = MyCustomPlugin;

开发体验:DevServer、HMR、Source Map 和代理

开发阶段最常见的组合是 webpack-dev-server、HMR 和 Source Map。Webpack DevServer 是一个基于 Express 的轻量开发服务器,可以提供静态资源服务、自动刷新、热模块替换和接口代理。

1
2
3
4
5
6
7
8
9
10
module.exports = {
devServer: {
static: './dist',
hot: true,
open: true,
port: 8080,
compress: true,
historyApiFallback: true,
},
};

HMR,也就是 Hot Module Replacement,指的是应用运行时不刷新整个页面,只替换、添加或删除发生变化的模块。配置上通常在 devServer 中开启 hot: true。Webpack 4+ 在开发模式下通常会自动添加 HotModuleReplacementPlugin。如果是 React、Vue 等框架,脚手架往往已经处理好模块更新逻辑;如果是原生 JS,则可能需要手动使用 module.hot.accept() 指定更新后的处理方式。

关于webpack热模块更新的总结如下:

  • 通过webpack-dev-server创建两个服务器:提供静态资源的服务(express)和Socket服务
  • express server 负责直接提供静态资源的服务(打包后的资源直接被浏览器请求和解析)
  • socket server 是一个 websocket 的长连接,双方可以通信
  • 当 socket server 监听到对应的模块发生变化时,会生成两个文件.json(manifest文件)和.js文件(update chunk)
  • 通过长连接,socket server 可以直接将这两个文件主动发送给客户端(浏览器)
  • 浏览器拿到两个新的文件后,通过HMR runtime机制,加载这两个文件,并且针对修改的模块进行更新

Source Map 用来把打包转换后的代码映射回原始源码,让浏览器调试时看到的是源文件,而不是构建后的 bundle。它通过 devtool 配置。常见值包括 evalsource-mapeval-source-mapcheap-module-source-mapinline-source-map 等。开发环境常用 eval-cheap-module-source-map,兼顾构建速度和调试体验;生产环境如果需要线上调试可以用 source-map,但要注意源码暴露风险,也可以选择关闭。

1
2
3
module.exports = {
devtool: 'eval-cheap-module-source-map',
};

开发环境跨域问题通常用 devServer.proxy 解决。例如把 /api 开头的请求代理到后端服务:

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:3000',
pathRewrite: { '^/api': '' },
secure: false,
changeOrigin: true,
},
},
},
};

框架支持:React、Vue、Angular

Webpack 支持框架的方式,本质仍然是 loader 和 plugin 的组合。

React 主要是处理 JSX,一般使用 babel-loader@babel/preset-react

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module.exports = {
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
},
},
},
],
},
};

Vue 需要 vue-loader 处理 .vue 单文件组件,同时还要配合 VueLoaderPlugin。Vue 2 需要 vue-template-compiler,Vue 3 需要 @vue/compiler-sfc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const { VueLoaderPlugin } = require('vue-loader');

module.exports = {
module: {
rules: [
{
test: /\.vue$/,
use: 'vue-loader',
},
],
},
plugins: [
new VueLoaderPlugin(),
],
};

Angular 通常不需要手动配置 Webpack,因为 Angular CLI 已经封装了构建链路。除非项目有特殊需求,否则直接使用 Angular CLI 是更常见的选择。

生产优化:代码分割、Tree Shaking、压缩和缓存

Webpack 的生产优化可以围绕两个目标理解:让用户少下载、让浏览器好缓存。

代码分割 Code Splitting 是把代码拆成多个 bundle 或 chunk,而不是一次性把所有代码打进一个大文件。这样首屏只加载必要代码,其他部分可以按需加载或并行加载。Webpack 实现代码分割主要有三种方式:多入口、动态导入和 SplitChunksPlugin。本质上都是为了减少用户首次加载时一次性加载过多的文件卡死。 

动态导入使用 import() 语法。Webpack 看到它时,会自动把对应模块拆成独立 chunk,并在代码执行到这里时再加载。

1
2
3
4
button.addEventListener('click', async () => {
const module = await import('./heavy-module');
module.run();
});

SplitChunksPlugin 是 Webpack 内置的公共代码拆分能力,配置位置是 optimization.splitChunks。它可以把 node_modules 中的依赖或多个入口共享的模块提取到单独 chunk,减少重复代码,也有利于长期缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: -10,
},
common: {
name: 'common',
minChunks: 2,
priority: -20,
chunks: 'all',
},
},
},
},
};

Webpack 5 在 production 模式下已经有比较智能的默认分包策略,很多项目不需要一开始就手动写复杂的 cacheGroups

Tree Shaking 是移除未引用代码的优化技术。它依赖 ES Module 的静态结构,也就是 importexport。CommonJS 的 require 更动态,不利于静态分析。要让 Tree Shaking 生效,通常需要使用 ES Module,开启 production 模式,并正确声明副作用。如果项目代码没有副作用,可以在 package.json 里设置:

1
2
3
{
"sideEffects": false
}

如果某些文件确实有副作用,比如全局样式或 polyfill 或者某些js,就要显式保留:

1
2
3
4
5
6
{
"sideEffects": [
"./src/style.css",
"./src/polyfill.js"
]
}
  • js 副作用场景,如果你开启了sideEffects: false 选项,这里就会无脑取消你的 window.isHappy = true; 操作。因为引入js即全局作用域的代码就会被执行
1
2
3
4
5
6
7
8
9
// -------- joke.js --------
export const tellJoke = () => "哈哈哈哈";

// 【高能注意】:下面这行代码就是“副作用”!
window.isHappy = true;


// -------- main.js ---------
import {tellJoke} from 'joke.js'

压缩方面,production 模式下 Webpack 会自动使用 TerserPlugin 压缩 JS。CSS 压缩通常使用 CssMinimizerWebpackPlugin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
mode: 'production',
optimization: {
minimize: true,
minimizer: [
new CssMinimizerPlugin(),
],
splitChunks: {
chunks: 'all',
},
},
};

缓存可以分成构建缓存和浏览器缓存。构建缓存用于提高本地二次构建速度,Webpack 5 可以开启文件系统缓存,babel-loader 也可以开启自己的缓存。浏览器缓存则主要依赖输出文件名中的 [contenthash]。内容不变,hash 不变,浏览器可以继续使用缓存;内容变化,hash 变化,浏览器会请求新文件。

包体积分析可以使用 webpack-bundle-analyzer。它能可视化展示每个依赖占用的体积,帮助定位过大的库、重复打包、拆包不合理等问题。

  • webpack可以打包 require,但是 tree-shakingrequire 进来的会失效,webpack 可以同时打包 require 和 ESM,他自己做的有兼容操作。2015年之前前端都是用 CommonJS 写的。

面试收束:如何把这些点串起来

如果面试官问“Webpack 是什么”,可以从模块打包器开始回答:Webpack 从入口出发构建依赖图,把各种资源当作模块处理,最终输出 bundle。

如果继续问“它怎么处理不同文件”,就顺着讲 loader:Webpack 原生只认识 JS 和 JSON,其他资源需要 loader 转换;loader 写在 module.rules,按从右到左执行;CSS、Sass、Babel、TypeScript、图片字体都可以放到这条线里说。

如果问“loader 和 plugin 区别”,就强调职责边界:loader 转换单个文件,plugin 监听构建生命周期、增强整个构建流程。生成 HTML、抽离 CSS、定义环境变量、清理输出目录、代码分割、分析包体积,这些都属于 plugin 或 optimization 参与的范围。

如果问“Webpack 怎么优化性能”,可以从开发和生产分开。开发侧关注 DevServer、HMR、Source Map、缓存;生产侧关注 Tree Shaking、Code Splitting、懒加载、压缩、contenthash、SplitChunks、CSS 抽离和包体积分析。

这样整套知识就不是“基础概念、配置、Loader、Plugin”四块彼此重复的问答,而是一条完整链路:Webpack 为什么出现,它如何从入口构建依赖图,如何用 loader 读懂资源,如何用 plugin 介入构建流程,如何服务开发体验,最后如何优化生产产物。


重温前端打包工具-Webpack
http://example.com/2026/06/05/重温前端打包工具-Webpack/
作者
Lingkai Shi
发布于
2026年6月5日
许可协议