此名字暂不可见
No distraction,it's you and me

Webpack

2025-07-20 秽土转生

webpack

本质上,webpack 是一个用于现代 JavaScript 应用程序的 静态模块打包工具。当 webpack 处理应用程序时,它会在内部从一个或多个入口点构建一个 依赖图(dependency graph),然后将你项目中所需的每一个模块组合成一个或多个 bundles,它们均为静态资源,用于展示你的内容。

​ ——webpack中文文档

为什么需要webpack

以html文件为例,如果要导入JS文件,一般是这样导入:

1
2
<script src="module-app1.js"></script>
<script src="module-app2.js"></script>

但是这种引入存在很多问题:

  • 所有的变量都放在window下面,容易造成变量污染
  • 需要手动维护文件加载顺序,比如app1中引用了app2中的某个函数,这样引用就会报错
  • 无法按需加载,拆分代码

所以需要一个打包工具,既要能编译代码,又要能整合模块,压缩优化,那就是webpack.

wepack配置文件

先来看看一个简单的webpack配置文件是怎么样

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
mode: 'development',
devtool: 'source-map'

entry: {
main: './src/main.js',
home: './src/home.js'
vendor: ['react', 'react-dom']
},

output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist'),
publicPath: '/',
},

module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
}
},

{
test: /\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: {
auto: true,
localIdentName: '[name]__[local]--[hash:base64:5]'
}
}
},
'postcss-loader'
]
},

]
},

plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
favicon: './public/favicon.ico'
}),
]

optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 10
}
}
},
runtimeChunk: 'single'
},

devServer: {
static: {
directory: path.join(__dirname, 'public')
},
compress: true,
port: 3000,
hot: true
},

resolve: {
extensions: ['.js', '.jsx', '.json'],
alias: {
'@': path.resolve(__dirname, 'src')
}
}
};

wepback的配置文件通常被命名为wepback.config.js,位于项目的根目录下,其所使用的模块规范是CommonJS,也就是node.js所使用的规范,下面介绍配置文件中的比较核心的一些概念。

entry

entry即入口,用来指示webpack应该将哪个模块作为依赖图的开始,递归地寻找依赖的模块,最后将这些模块组合成一个或者多个bundles

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = {
entry: './src/main.js' //单入口文件
}

------------

module.exports = {
entry: {
app1: './src/app1.js'
app2: './src/app2.js'
} //多入口文件
}

对于项目来说,如果有多个入口和多个模块,在webpack打包成bundle后,我们会得到一个包含所有代码地bundle文件。但是存在一些问题,如果我们在多个入口点之间共享了一些公共代码,这些代码将被打包到多个bundle,导致代码冗余。因此,webpack提供vendor配置项,允许我们将公共代码提取到一个单独的文件中

1
2
3
4
5
6
7
module.exports = {
entry: {
app1: './src/app1.js',
app2: './src/app2.js' ,
vendor: ['react', 'react-dom'] // 第三方库入口
}
};

像这样,react和react-dom会单独作为一个bundle打包出来,如果app1或者app2中对其有依赖,则会从打包后的公共模块的bundle中提取使用

output

output即输出,用来告诉webpackk在哪里输出他所创建地bundle,以及如何命名这些文件。默认情况下放在./dist文件夹中

1
2
3
4
5
output: {
filename: '[name].js', //输出的文件名
path: path.resolve(__dirname, 'dist'), //输出文件的路径
publicPath: '/', //公开访问路径
},
  • filename: 这个顾名思义,就是输出的文件名,[name]是一个占位符,即entry中定义的键名

​ 比如entry: { app: ‘./src/index.js’ },最后就会输出app.js

​ 除了[name]外,常见的占位符还有[contenthash],[id]等

  • path: 指定文件的输出路径,这里的__dirname来自于Node.js,表示当前配置文件所在的目录,path.resolve来自于Node.js的path模块,这是为了解决跨平台的路径分隔符问题(如 Windows 的 \ 和 Linux 的 /

  • publicPath: 指定文件在浏览器中的公开访问路径,比如说我把文件上传到了服务器中,它的publicPath是

​ ‘https://fkxqsVw50.com/assets',那么我们就可以通过浏览器访问到这个文件

mode

mode用来指定当前的构建环境,根据不同的mode,webpack有不同的打包方式

1
2
3
module.exports = {
mode: 'development' // 还有production和none
}
  • development: 顾名思义,开发模式。在development下,webpack会启用启用 eval、类型的 source map和热更新,同时保留原始变量名,不进行代码混淆,同时为了保证构建速度,会跳过代码压缩和树摇等操作。

  • production:生产模式,这种模式下,webpack会对代码进行压缩,比如使用TerserWebpackPlugin和CssMinimizerPlugin分别压缩JS和CSS,同时开启树摇和模块合并,并移除调试代码(console.log)

  • none:即什么都没有。none模式下会禁用所有默认优化,纯人工控制。

devtool

devtool用来控制是否生成,以及如何生成source map

1
2
3
module.exports = {
devtool: 'source-map'
}

从官网上看,devtool的配置比较吓人,有20+种,用于各种不同情况下的source map。这里就只介绍下development和production下常用的source map

注:source map 是源码与打包后代码的映射文件,用于在浏览器种直接查看和调试原始代码,精确定位报错位置

在development下:

  • eval:用eval()执行,构建速度最快,但是仅显示行号
  • cheap-eval-source-map:速度慢了一点,每行对应映射,含有行号但无列号
  • eval-source-map,速度又慢了一点,但是包含了完整源码

在production下:

none :不生成 source map 文件。

source-map :生成完整的 source map 文件。

hidden-source-map :生成 source map 文件,但不会在代码中暴露其引用。

nosources-source-map :生成 source map 文件,但不包含源代码内容。

module

module即模块,它主要用于扩展webpack的解析能力。因为webpack只能处理JS和JSON类型的文件,module可以通过module.rulers来通过各种loader来文件预处理,从而扩展webpack的解析能力。

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
module: {
rules: [
{
test: /\.(js|jsx)$/, //正则匹配js和jsx后缀的文件
exclude: /node_modules/, //跳过node_modules中的第三方库
use: { //使用的loader
loader: 'babel-loader', //调用Babel对代码进行转译
options: {
presets: [
'@babel/preset-env',
'@babel/preset-react'
] //因为是Babel,所以使用了相应的预设
}
}
},

{
test: /\.css$/,
use: [
'style-loader',
'css-loader',
'postcss-loader'
] //这里使用数组,对于loader数组,执行顺序是从后往前,从右往左
},
]
},

关于loader后面再详细介绍

plugins

plugins,即插件。相对于loader扩展了webpack的解析功能,plugins直接扩展了webpack的功能,比如打包优化,资源管理等,而且生命周期可以遍布整个构建流畅。

1
2
3
4
5
6
7
8
9
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
}),
]
}

plugin在使用需要先通过require获取,再在plugins中实例化即可,不同的plugin有不同的参数,后面在详细介绍

optimization

optimization,即优化。它用来控制代码的分割,压缩,去重等优化方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
optimization: {
splitChunks: { //splitChunks,对chunk进行拆分
chunks: 'all', //对所有类型的chunk进行拆分
cacheGroups: { //缓存组,wepback会根据配置将匹配的模块打包到独立的chunk中
vendor: { //分离第三方库
test: /[\\/]node_modules[\\/]/, //匹配node_modules下的模块
name: 'vendors', //输出文件名
priority: 10 //优先级
}
}
},
runtimeChunk: 'single' //将runtime拆分到单独的runtime.js文件中
},

webpack-dev-server

webpack 内置的 开发服务器 webpack-dev-server,用于在开发过程中提供本地服务器和热更新功能。

1
2
3
4
5
6
7
8
devServer: {
static: {
directory: path.join(__dirname, 'public')
},
compress: true,
port: 3000,
hot: true
},
  • static.directory: 设置静态文件目录,告诉 devServer 从哪里提供额外的静态资源(如 HTML、图片)。它不会经过 webpack 打包。通常是 public 目录。
  • compress:是否启用gzip压缩代码
  • port:设置服务器的端口号
  • hot:是否启动热更新

resolve

resolve 控制 webpack 如何解析模块的路径

1
2
3
4
5
6
 resolve: {
extensions: ['.js', '.jsx', '.json'],
alias: {
'@': path.resolve(__dirname, 'src')
}
}
  • extensions: 自动补全扩展名,比如require时没有后缀名,webpack 会按顺序尝试补全这些后缀
  • alias:路径别名,比如@ 被当成 src 目录的别名,方便在代码中引用模块,不必写长长的相对路径

webpack构建过程

省流:

  1. 初始化
  2. 构建
  3. 生成
  4. 输出

初始化

​ 在最开始,webpack会对参数进行初始化,它首先从wepback cli中获取命令行参数,并与webpack.config.js中的配置进行合并得到最终的配置参数,之后调用new Webpack(options)正式开始打包工作,这期间还用通过validateSchema校验配置正确与否,如果无误,就会创建一个compiler对象。

有了compiler,webpack会通过compiler的hooks挂载用户导入的plugins以及webpack的各种内置插件。到这里,不仅有了compiler实例,编译环境也搭载完毕,就可以开始构建过程了。

1
webpack --mode=production //webpack cli 即webpack的命令行,这里就传递了参数mode为production

注:

compiler:compiler包含了webpack的配置信息,如entry,output,loaders等,提供了注册和调用plugin的接口,同时负责调控整个整个编译过程,比如run,emit,done等,一遍webpack处理逻辑。

compilation: 每次构建资源过程创建,它包含当前构建的所有模块资源,生成资源及其依赖关系,相较于compiler,conpilation更注重编译的细节,比如buildModules,optimizeModules。

构建过程(make)

​ 构建阶段,compiler会调用compiler.run()表示开始构建流程,通过new Compilation(options)创建一个compilation实例来具体管理这次编译过程,compilation.make()会通知正式开始构建模块,通过buildModule从entry开始解析文件之间的依赖,这首先会经过loader处理,输出webpack能够理解的JS模块,然后由Acorn将JS文件解析为AST,compilation通过各种钩子监听,从中获取module的依赖,加入到module的依赖列表,之后对module的依赖递归执行上述步骤,并逐步构建ModuleGraph。全部解析之后,如果配置了optimization,则会通过optimizeModules对modules进行优化,至此构建阶段结束。

举个例子,有一个index.js文件,它引用了a.js和b.js两个文件,那么最后的ModuleGraph如下:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
ModuleGraph: {
_dependencyMap: Map(3){
{
EntryDependency{request: "./src/index.js"} => ModuleGraphConnection{
module: NormalModule{request: "./src/index.js"},
originModule: null
}
},
{
HarmonyImportSideEffectDependency{request: "./src/a.js"} => ModuleGraphConnection{
module: NormalModule{request: "./src/a.js"},
originModule: NormalModule{request: "./src/index.js"}
}
},
{
HarmonyImportSideEffectDependency{request: "./src/b.js"} => ModuleGraphConnection{
module: NormalModule{request: "./src/b.js"},
originModule: NormalModule{request: "./src/index.js"}
}
}
},

_moduleMap: Map(3){
NormalModule{request: "./src/index.js"} => ModuleGraphModule{
incomingConnections: Set(1) [
ModuleGraphConnection{ module: NormalModule{request: "./src/index.js"}, originModule:null }
],
outgoingConnections: Set(2) [
ModuleGraphConnection{ module: NormalModule{request: "./src/a.js"}, originModule: NormalModule{request: "./src/index.js"} },
ModuleGraphConnection{ module: NormalModule{request: "./src/b.js"}, originModule: NormalModule{request: "./src/index.js"} }
]
},
NormalModule{request: "./src/a.js"} => ModuleGraphModule{
incomingConnections: Set(1) [
ModuleGraphConnection{ module: NormalModule{request: "./src/a.js"}, originModule: NormalModule{request: "./src/index.js"} }
],
outgoingConnections: undefined
},
NormalModule{request: "./src/b.js"} => ModuleGraphModule{
incomingConnections: Set(1) [
ModuleGraphConnection{ module: NormalModule{request: "./src/b.js"}, originModule: NormalModule{request: "./src/index.js"} }
],
outgoingConnections: undefined
}
}
}

生成阶段(seal)

​ 如果说构建阶段的关键是module,那么生成阶段就是围绕chunk展开。make之后,compilation会进入seal阶段,通过构建阶段收集到的ModuleGraph来将有依赖关系得module封装为chunk,同时构建ChunkGraph。

这个chunk的封装很好理解,如下:

  • entry 及 entry 触达到的模块,组合成一个 chunk
  • 使用动态引入语句引入的模块,各自组合成一个 chunk
  • 如果配置了runtimeChunk,那么runtime也会组织成一个chunk

以前面的配置文件为例:

1
2
3
4
5
6
7
module.exports = {
entry: {
main: "./src/main.js",
home: "./src/home.js",
}
};

webpack根据entry创建出chunk[main]和chunk[home],之后,webpack根据ModuleGraph,将entry所依赖的文件注入到相应的chunk中。假如main.js依赖了a.js和b.js,那么a.js和b.js就会被注入到chunk[main]。

但如果模块用异步方式(require.ensure(“./xx.js”) 或 import(“./xx.js”))引入,比如home.js异步引入了async.js,那么webpack会为async.js单独创建一个chunk[async]并让chunk[home]引用它,此时对于异步生成的chunk,它的引用者被称为parent,它自己被称为child。

注: webpack的编译产物在运行需要一些支持模块化,异步加载等特性的代码,这列代码统称为runtime。

但是chunk是不够的,因为chunk本质上只是一个容器,它不关心里面的module是什么,它只关心是不是自己的。所以这里还有关键一步——compilation.CodeGenerator()

CodeGenerator()会通过chunkGraph得知module之间的依赖,module于chunk之间的关系以及module的runtime来处理module。

在compilation.codeGeneration()执行完毕之后,module转译的结果会保存compilation.codeGenerationResults对象中,然后通过chunk进行模块合并打包,生成完整的bundle,之后调用complation.emitAsset()将bundle写为assets,最后有compiler.emitAssets()将assets写入磁盘之中,至此,一次打包过程结束。

一点点的小细节

loader

从本质上讲,loader就是一个函数,它可以在webpack处理某些资源之前提前处理。比如我要用webpack处理一个ts的文件,我可以提前使用一个babel-loader将.ts后缀的文件提前编译为JS代码,再交给webpack处理。

loader的基本配置

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

  • test: test是一个正则表达式,我们会对应的资源文件根据test的规则去匹配。如果匹配到,那么该文件就会交给对应的loader去处理。
  • use: use表示匹配到test中匹配对应的文件应该使用哪个loader的规则去处理,use可以为一个字符串,也可以为一个数组。

注:use为一个数组时表示有多个loader依次处理匹配的资源,按照 从右往左(从下往上) 的顺序去处理。

  • enforce:enforce决定了loader执行的顺序。
1
2
3
4
5
6
7
8
9
10
module.exports = {
module: {
rules: [
{ test: /.css$/, use: 'sass-loader', enforce: 'pre' },
{ test: /.css$/, use: 'css-loader' },
{ test: /.css$/, use: 'style-loader', enforce: 'post' },
],
},
};

enforce的两个值决定loader的执行顺序,分别是per和post,比如现在loader会从前往后执行。

loader的种类和执行顺序

loader可以分为四种类型:pre,normal,post和inline。其中,前三种通过enforce配置,而inline则通过内联的方式配置

1
import Styles from 'style-loader!css-loader!./styles.css';

从命名上就可以看出loader的执行顺序:

pre loader -> normal loader -> inline loader -> post loader

webpack进行编译文件前,文件资源会匹配到对应loader:

  • 执行pre loader前置处理文件。
  • 将pre loader执行后的资源链式传递给normal loader正常的loader处理。
  • normal loader处理结束后交给inline loader处理。
  • 最终通过post loader处理文件,将处理后的结果交给webpack进行模块编译。

但是,在实际使用的时候存在一些问题:

  • Loader 链条一旦启动之后,需要所有 Loader 都执行完毕才会结束,没有中断的机会 —— 除非显式抛出异常
  • 某些场景下并不需要关心资源的具体内容,但 Loader 需要在 source 内容被读取出来之后才会执行

为了解决这两个问题,webpack 在 loader 基础上增加了 pitch 的概念。

loader的pitch阶段

关于loader的执行阶段实际分为两个阶段:

  • 在处理资源文件之前,首先会经历pitch阶段。
  • pitch结束后,读取资源文件内容。
  • 经过pitch处理后,读取到了资源文件,此时才会将读取到的资源文件内容交给正常阶段的loader进行处理。

简单来说就是webpack在使用loader处理资源时首先会经过loader.pitch阶段,此时loader从前往后执行,pitch`阶段结束后才会读取文件而后进行normal阶段处理,此时从后往前执行。

1
2
3
4
5
6
7
8
9
10
const loader = function (source){
console.log('后执行')
return source;
}

loader.pitch = function(requestString) {
console.log('先执行')
}

module.exports = loader //差不多就是这样

可以看到,pitch也是一个函数,或者说是loader上的一个方法,它接收三个参数,分别是:

  • remainingRequest:当前 loader 之后的所有 loader 加上资源模块
  • previousRequest:在执行当前 loader 之前经历过的 loader 列表
  • data: 在当前 loader 的 pitch和普通 loader 函数之间共享

这里不得不提到pitch的核心机制——熔断

在pitch阶段,如果loader.pitch返回了一个非undefined的值,那么loader就会马上掉头执行上一个已经执行的loader的normal阶段并且将返回值作为参数传入,之后再回来执行后面的loader。

这有什么用呢?😕

下面以最开始的配置为例

看一下几个loader的作用:

scss-loader:将 scss 规格的内容转换为标准 css

css-loader :将 css 内容包裹为 JavaScript 模块

style-loader :将 JavaScript 模块的导出结果以 link 、style 标签等方式挂载到 html 中,让 css 代码能够正确运行在浏览器上

注意到style-loader只是负责让 css 能够在浏览器环境下跑起来,本质上并不需要关心具体内容,很适合用 pitch 来处理,所以style-loader真正起作用的地方在pitch而非normal。

。。。。。。

那为什么会这样呢?🤔

loader的小月读

wepback编译模块之前通过一个叫runLoaders的方法来调用loader模块,它接收两个参数:

第一个是对象,包含四个参数:

  • resource: resource参数表示需要加载的资源路径。
  • loaders: 表示需要处理的loader绝对路径拼接成为的字符串,以!分割。
  • context: loader上下文对象,webpack会在进入loader前创建一系列属性挂载在一个object上,而后传递给loader内部。
  • processResource: 读取资源文件的方法。

第二个是callback,表示本次loader处理完成的结果,这个结果会分为两个参数给callback:

  • error: 如果runLoaders函数执行过程中遇到错误那么这个参数将会变成错误内容,否则将为null。
  • result:一个数组,用来表示本次经过所有loaders处理完毕后的文件内容。

下面看一点runloaders的小细节:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
function runLoaders(options, callback) {
// 需要处理的资源绝对路径
const resource = options.resource || '';
// 需要处理的所有loaders 组成的绝对路径数组
let loaders = options.loaders || [];
// loader执行上下文对象 每个loader中的this就会指向这个loaderContext
const loaderContext = options.context || {};
// 读取资源内容的方法
const readResource = options.readResource || fs.readFile.bind(fs);
// 根据loaders路径数组创建loaders对象
loader = loader.map(createLoaderObject);
// 处理loaderContext 也就是loader中的this对象
loaderContext.resourcePath = resource; // 资源路径绝对地址
loaderContext.readResource = readResource; // 读取资源文件的方法
loaderContext.loaderIndex = 0; // 我们通过loaderIndex来执行对应的loader
loaderContext.loaders = loaders; // 所有的loader对象
loaderContext.data = null;
// 标志异步loader的对象属性
loaderContext.async = null;
loaderContext.callback = null;

// request 保存所有loader路径和资源路径
// 这里我们将它全部转化为inline-loader的形式(字符串拼接的"!"分割的形式)
// 注意同时在结尾拼接了资源路径
Object.defineProperty(loaderContext, 'request', {
enumerable: true,
get: function () {
return loaderContext.loaders
.map((l) => l.request)
.concat(loaderContext.resourcePath || '')
.join('!');
},
});
// 保存剩下的请求 不包含自身(以LoaderIndex分界) 包含资源路径
Object.defineProperty(loaderContext, 'remainingRequest', {
enumerable: true,
get: function () {
return loaderContext.loaders
.slice(loaderIndex + 1)
.map((i) => i.request)
.concat(loaderContext.resourcePath)
.join('!');
},
});
// 保存剩下的请求,包含自身也包含资源路径
Object.defineProperty(loaderContext, 'currentRequest', {
enumerable: true,
get: function () {
return loaderContext.loaders
.slice(loaderContext)
.map((l) => l.request)
.concat(loaderContext.resourcePath)
.join('!');
},
});
// 已经处理过的loader请求 不包含自身 不包含资源路径
Object.defineProperty(loaderContext, 'previousRequest', {
enumerable: true,
get: function () {
return loaderContext.loaders
.slice(0, loaderContext.index)
.map((l) => l.request)
.join('!');
},
});
// 通过代理保存pitch存储的值 pitch方法中的第三个参数可以修改 通过normal中的this.data可以获得对应loader的pitch方法操作的data
Object.defineProperty(loaderContext, 'data', {
enumerable: true,
get: function () {
return loaderContext.loaders[loaderContext.loaderIndex].data;
},
});

const processOptions = {
resourceBuffer: null,
};
// 处理完loaders对象和loaderContext上下文对象后
// 根据流程我们需要开始迭代loaders--从pitch阶段开始迭代
// 按照 post-inline-normal-pre 顺序迭代pitch
iteratePitchingLoaders(processOptions, loaderContext, (err, result) => {
callback(err, {
result,
resourceBuffer: processOptions.resourceBuffer,
});
});

}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function createLoaderObject(loader) {
const obj = {
normal: null, // loader normal 函数本身
pitch: null, // loader pitch 函数
raw: null, // 表示normal loader处理文件内容时 是否需要将内容转为buffer对象
// pitch阶段通过给data赋值 normal阶段通过this.data取值 用来保存传递的data
data: null,
pitchExecuted: false, // 标记这个loader的pitch函数时候已经执行过
normalExecuted: false, // 表示这个loader的normal阶段是否已经执行过
request: loader, // 保存当前loader资源绝对路径
};
// 按照路径加载loader模块
const normalLoader = require(obj.request);
// 赋值
obj.normal = normalLoader;
obj.pitch = normalLoader.pitch;
// 转化时需要buffer/string raw为true时为buffer false时为string
obj.raw = normalLoader.raw;
return obj;
}

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
function iteratePitchingLoaders(options, loaderContext, callback) {
// 超出loader个数 表示所有pitch已经结束 那么此时需要开始读取资源文件内容
if (loaderContext.loaderIndex >= loaderContext.loaders.length) {
return processResource(options, loaderContext, callback);
}

const currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];

// 当前loader的pitch已经执行过了 继续递归执行下一个
if (currentLoaderObject.pitchExecuted) {
loaderContext.loaderIndex++;
return iteratePitchingLoaders(options, loaderContext, callback);
}

const pitchFunction = currentLoaderObject.pitch;

// 标记当前loader pitch已经执行过
currentLoaderObject.pitchExecuted = true;

// 如果当前loader不存在pitch阶段
if (!currentLoaderObject.pitch) {
return iteratePitchingLoaders(options, loaderContext, callback);
}

// 存在pitch阶段 并且当前pitch loader也未执行过 调用loader的pitch函数
runSyncOrAsync(
pitchFunction,
loaderContext,
[
currentLoaderObject.remainingRequest,
currentLoaderObject.previousRequest,
currentLoaderObject.data,
],
function (err, ...args) {
if (err) {
// 存在错误直接调用callback 表示runLoaders执行完毕
return callback(err);
}
// 根据返回值 判断是否需要熔断 or 继续往下执行下一个pitch
// pitch函数存在返回值 -> 进行熔断 掉头执行normal-loader
// pitch函数不存在返回值 -> 继续迭代下一个 iteratePitchLoader
const hasArg = args.some((i) => i !== undefined);
if (hasArg) {
loaderContext.loaderIndex--;
// 熔断 直接返回调用normal-loader
iterateNormalLoaders(options, loaderContext, args, callback);
} else {
// 这个pitch-loader执行完毕后 继续调用下一个loader
iteratePitchingLoaders(options, loaderContext, callback);
}
}
);
}

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
function runSyncOrAsync(fn, context, args, callback) {
// 是否同步 默认同步loader 表示当前loader执行完自动依次迭代执行
let isSync = true;
// 表示传入的fn是否已经执行过了 用来标记重复执行
let isDone = false;

// 定义 this.callback
// 同时this.async 通过闭包访问调用innerCallback 表示异步loader执行完毕
const innerCallback = (context.callback = function () {
isDone = true;
// 当调用this.callback时 标记不走loader函数的return了
isSync = false;
callback(null, ...arguments);
});

// 定义异步 this.async
// 每次loader调用都会执行runSyncOrAsync都会重新定义一个context.async方法
context.async = function () {
isSync = false; // 将本次同步变更成为异步
return innerCallback;
};

// 调用pitch-loader执行 将this传递成为loaderContext 同时传递三个参数
// 返回pitch函数的返回值 甄别是否进行熔断
const result = fn.apply(context, args);

if (isSync) {
isDone = true;
if (result === undefined) {
return callback();
}
// 如果 loader返回的是一个Promise 异步loader
if (
result &&
typeof result === 'object' &&
typeof result.then === 'function'
) {
// 同样等待Promise结束后直接熔断 否则Reject 直接callback错误
return result.then((r) => callback(null, r), callback);
}
// 非Promise 切存在执行结果 进行熔断
return callback(null, result);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function processResource(options, loaderContext, callback) {
// 重置越界的 loaderContext.loaderIndex
// 达到倒叙执行 pre -> normal -> inline -> post
loaderContext.loaderIndex = loaderContext.loaders.length - 1;
const resource = loaderContext.resourcePath;
// 读取文件内容
loaderContext.readResource(resource, (err, buffer) => {
if (err) {
return callback(err);
}
// 保存原始文件内容的buffer 相当于processOptions.resourceBuffer = buffer
options.resourceBuffer = buffer;
// 同时将读取到的文件内容传入iterateNormalLoaders 进行迭代`normal loader`
iterateNormalLoaders(options, loaderContext, [buffer], callback);
});
}

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
function iterateNormalLoaders(options, loaderContext, args, callback) {
// 越界元素判断 越界表示所有normal loader处理完毕 直接调用callback返回
if (loaderContext.loaderIndex < 0) {
return callback(null, args);
}
const currentLoader = loaderContext.loaders[loaderContext.loaderIndex];
if (currentLoader.normalExecuted) {
loaderContext.loaderIndex--;
return iterateNormalLoaders(options, loaderContext, args, callback);
}

const normalFunction = currentLoader.normal;
// 标记为执行过
currentLoader.normalExecuted = true;
// 检查是否执行过
if (!normalFunction) {
return iterateNormalLoaders(options, loaderContext, args, callback);
}
// 根据loader中raw的值 格式化source
convertArgs(args, currentLoader.raw);
// 执行loader
runSyncOrAsync(normalFunction, loaderContext, args, (err, ...args) => {
if (err) {
return callback(err);
}
// 继续迭代 注意这里的args是处理过后的args
iterateNormalLoaders(options, loaderContext, args, callback);
});
}

plugin

前置知识:Tapable

对于webpack的插件,本质上类似于Node.js中的发布订阅模式,Tapable就相当于EventEmitter。webpack通过Tapable来注册事件,并在不同的时机触发注册的事件。

Tapable提供了九种不同的钩子:

1
2
3
4
5
6
7
8
9
10
11
12
const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook
} = require("tapable");

这些hook可以按照执行机制进行分类:

  • Basic Hook : 基本类型的钩子,它仅仅执行钩子注册的事件,并不关心每个被调用的事件函数返回值如何。

  • Waterfall : 瀑布类型的钩子,瀑布类型的钩子和基本类型的钩子基本类似,唯一不同的是瀑布类型的钩子会在注册的事件执行时将事件函数执行非 undefined 的返回值传递给接下来的事件函数作为参数。

  • Bail : 保险类型钩子,保险类型钩子在基础类型钩子上增加了一种保险机制,如果任意一个注册函数执行返回非 undefined 的值,那么整个钩子执行过程会立即中断,之后注册事件函数就不会被调用了。

  • Loop : 循环类型钩子,循环类型钩子稍微比较复杂一点。循环类型钩子通过 call 调用时,如果任意一个注册的事件函数返回值非 undefeind ,那么会立即重头开始重新执行所有的注册事件函数,直到所有被注册的事件函数都返回 undefined。

也可以按照同步异步分:

  • 同步表示注册的事件函数会同步进行执行。
  • 异步表示注册的事件函数会异步进行执行。

:异步钩子可以分为:

  • 异步串行钩子:可以被串联(连顺序调用)执行的异步钩子函数。
  • 异步并行钩子:可以被并联(并发调用)执行的异步钩子函数。

Tapable 使用时通常需要经历如下步骤:

  • 创建钩子实例

  • 调用订阅接口注册回调,包括:tap、tapAsync、tapPromise

  • 调用发布接口触发回调,包括:call、callAsync、promise

1
2
3
4
5
6
7
8
9
10
11
const hook = new SyncHook(["arg1", "arg2", "arg3"]);

hook.tap('flag1', (arg1,arg2,arg3) => {
console.log('flag1:',arg1,arg2,arg3)
})


hook.call('扣1请我吃肯德基','111','吃吃吃')

// 打印结果
flag1: '扣1请我吃肯德基','111','吃吃吃'

首先我们需要通过 new 关键字实例不同种类的 Hook。

  • new Hook 时候接受一个字符串数组作为参数,数组中的值不重要,重要的是数组中对应的字符串个数。
  • 其实 new Hook 时还接受第二个参数 name ,它是一个 string。

其次通过 tap 函数监听对应的事件,注册事件时接受两个参数:

  • 第一个参数是一个字符串,它没有任何实际意义仅仅是一个标识位而已。这个参数还可以为一个对象。
  • 第二个参数表示本次注册的函数,在调用时会执行这个函数。

最后就是我们通过 call 方法传入对应的参数,调用注册在 hook 内部的事件函数进行执行。

  • 同时在 call 方法执行时,会将 call 方法传入的参数传递给每一个注册的事件函数作为实参进行调用。

这是同步hook的示例,但是异步串行hook和异步并行hook的情况不太一样:

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 { AsyncSeriesHook } = require('tapable');

const hook = new AsyncSeriesHook(['arg1', 'arg2', 'arg3']);

console.time('timer');

hook.tapAsync('flag1', (arg1, arg2, arg3, callback) => {
console.log('flag1:', arg1, arg2, arg3);
setTimeout(() => {
callback();
}, 1000);
});

hook.tapPromise('flag2', (arg1, arg2, arg3) => {
console.log('flag2:', arg1, arg2, arg3);
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 1000);
});
});

hook.callAsync('扣1请我吃肯德基','111','吃吃吃', () => {
console.log('已经没有了');
console.timeEnd('timer');
});

flag1: '扣1请我吃肯德基','111','吃吃吃'
flag2: '扣1请我吃肯德基','111','吃吃吃'
'已经没有了'
timer: 2.012s
  • tapAsync 注册时实参结尾额外接受一个 callback ,调用 callback 表示本次事件执行完毕。

    callback 的机制和 node 中是一致的,也就是说 callback 函数调用时,如果第一个参数表示错误对象,如果传递第一个参数的话那么就表示本次执行出现错误会中断执行。

    当然后续参数和 node.js 中同理,从 callback 函数第二个参数表示开始表示本次函数调用的返回值。

  • Promise 同理,如果这个 Promise 返回的结果是 reject 状态,那么和 callback 传递错误参数同样效果,也会中断后续的执行。

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 {  AsyncParallelHook  } = require('tapable');

const hook = new AsyncParallelHook (['arg1', 'arg2', 'arg3']);

console.time('timer');

hook.tapAsync('flag1', (arg1, arg2, arg3, callback) => {
console.log('flag1:', arg1, arg2, arg3);
setTimeout(() => {
callback();
}, 1000);
});

hook.tapPromise('flag2', (arg1, arg2, arg3) => {
console.log('flag2:', arg1, arg2, arg3);
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 1000);
});
});

hook.callAsync('扣1请我吃肯德基','111','吃吃吃', () => {
console.log('已经没有了');
console.timeEnd('timer');
});

flag1: '扣1请我吃肯德基','111','吃吃吃'
flag: '扣1请我吃肯德基','111','吃吃吃'
'已经没有了'
timer: 1.010ss

可以看到最终的回调函数执行时打印的事件为1s稍微多一点,也就是说 flag1 、 flage2 两个事件函数并行开始执行,在1s后两个异步函数执行结束,整体回调结束。


plugin的基本构成

1
2
3
4
5
6
7
8
9
10
class DonePlugin {
apply(compiler) {
compiler.hooks.done.tap('Plugin Done', () => {
console.log('到我了');
});
}
}

module.exports = DonePlugin;

上面就是一个plugin最基本的架构,巨简单,从上面的基本构成可以看到:

  • 首先一个 Plugin 应该是一个 class,当然也可以是一个函数。
  • 其次 Plugin 的原型对象上应该存在一个 apply 方法,当 webpack 创建 compiler 对象时会调用各个插件实例上的 apply 方法并且传入 compiler 对象作为参数(不一定是compiler)。
  • 同时需要指定一个绑定在 compiler 对象上的 Hook , 比如 compiler.hooks.done.tap 在传入的 compiler 对象上监听 done 事件。
  • 在 Hook 的回调中处理插件自身的逻辑。
  • 根据 Hook 的种类,在完成逻辑后通知 webpack 继续进行。

Author: John Doe

Link: https://159357254680.github.io/2025/07/20/Webpack/

Copyright: All articles in this blog are licensed under CC BY-NC-SA 3.0 unless stating additionally.

< PreviousPost
Vite
NextPost >
前端笑笑转之性能优化
CATALOG
  1. 1. webpack
    1. 1.1. 为什么需要webpack
    2. 1.2. wepack配置文件
      1. 1.2.1. entry
      2. 1.2.2. output
      3. 1.2.3. mode
      4. 1.2.4. devtool
      5. 1.2.5. module
      6. 1.2.6. plugins
      7. 1.2.7. optimization
      8. 1.2.8. webpack-dev-server
      9. 1.2.9. resolve
    3. 1.3. webpack构建过程
      1. 1.3.1. 初始化
      2. 1.3.2. 构建过程(make)
      3. 1.3.3. 生成阶段(seal)
    4. 1.4. 一点点的小细节
      1. 1.4.1. loader
        1. 1.4.1.1. loader的基本配置
        2. 1.4.1.2. loader的种类和执行顺序
        3. 1.4.1.3. loader的pitch阶段
        4. 1.4.1.4. loader的小月读
      2. 1.4.2. plugin
        1. 1.4.2.1. 前置知识:Tapable
        2. 1.4.2.2. plugin的基本构成