一、前言
在上一篇 《手把手教你实现 Loader》 中已经介绍了 loader 的基础知识以及如何自定义 loader。这篇文章主要聊聊 loader 的调用链,从这篇文章中,你将了解到以下内容:
- loader 的 normal & pitch 阶段
- loader 在不同阶段的执行顺序
- pitch 的应用(style-loader)
相关代码基于上篇文章,建议优先阅读上一篇。文章如有误,欢迎指出,大家一起学习交流~。 项目地址,期待大家的一键三连 💗
二、loader的两大阶段
一个 loader 可以分为两个阶段: 一个是 normal 阶段 ,另一个是 pitch 阶段。不同阶段对应的代码如下:
javaScript
// a-loader.js
module.exports = function (source, sourceMap, data) {
// normal 阶段
}
// pitch 阶段
module.exports.pitch = function (remainingRequest, previousRequest, data) {}
pitch 阶段的执行时期会早于 normal 阶段。那么具体的执行顺序是怎么样的呢?下面我们分开来聊一聊。
三、loader 的 normal 阶段
在开始之前,我们先来聊聊 enforce
这个属性 ,在配置文件中,可以通过 enforce 属性定义 loader 的类型, enforce 属性的取值为 pre
|| post
,没有则视为普通( normal
)的 loader 。即官方提到的 pre-loader
、post-loader
、normal-Loader
, 顾名思义,它们执行顺序为 pre → normal → post
。
理论不如实战,我们还是基于上一个项目来实战一下。在 xq-loader 的文件夹下创建三个文件 a-loader.js
、b-loader.js
、 c-loader.js
。
分别在每个文件中填充内容,并进行打印。
js
// a-loader.js
module.exports = function (source) {
console.log('a-loader')
return source
}
// b-loader.js
module.exports = function (source) {
console.log('b-loader')
return source
}
// c-loader.js
module.exports = function (source) {
console.log('c-loader')
return source
}
// my-async-loader.js
...// 省略
module.exports = function (source) {
console.log('my-async-loader')
// 通过 this.async 来返回一个异步函数,第一个参数 Error, 第二个参数是处理的结果。
const callback = this.async()
fs.readFile(path.join(__dirname, '../async.txt'), 'utf-8', (err, data) => {
const html = `module.exports = ${JSON.stringify(data)}`
callback(null, html)
})
}
接着在配置文件中,配置着三个loader。为了方便理解 loader 的执行顺序,减少项目中其他 loader 的影响,我们用这三个 loader 来处理 txt 文件,并使用 enforce
属性进行标识 。因 webpack 不能识别 txt 文件,所以我们保留之前的 my-async-loader
调用。
js
module.exports = {
...// 省略
module: {
rules: [
...// 省略
{
test: /\.txt$/,
use: {
loader: 'my-async-loader',
},
},
// start ---Loader 顺序----
{
test: /\.txt$/,
use: {
loader: 'a-loader',
},
enforce: 'pre',
},
{
test: /\.txt$/,
use: {
loader: 'c-loader',
},
enforce: 'post',
},
{
test: /\.txt$/,
use: {
loader: 'b-loader',
},
},
// end ---Loader 顺序----
]
}
}
配置完毕后, pnpm run build
打包之后,可以看到控制台的打印如下:
可以验证前面的执行顺序 pre → normal → post ,那么对于未使用 enforce
字段标识的 b-loader
和 my-async-loader
的执行顺序是啥呢?咱们可以记个口诀,对于相同类型 的 loader 的执行顺序是自下而上,从左到右。
为了更好的理解这个口诀,我们再增加一个普通的 d-loader.js
, 调整一下配置:
js
module.exports = {
...// 省略
module: {
rules: [
...// 省略
{
test: /\.txt$/,
use: {
loader: 'my-async-loader',
},
},
// start ---Loader 顺序----
{
test: /\.txt$/,
use: {
loader: 'a-loader',
},
enforce: 'pre',
},
{
test: /\.txt$/,
use: {
loader: 'c-loader',
},
enforce: 'post',
},
{
test: /\.txt$/,
use: [
{
loader: 'd-loader',
},
{
loader: 'b-loader',
},
],
},
// end ---Loader 顺序----
]
}
}
此时我们将三个普通的 loader 抽象出来, 可以将它们理解为一个二维数组,大致是这样:
my-async-loader | |
d-loader | b-loader |
套用一下口诀,执行顺序应该是 b-loader → d-loader → my-async-loader
,打包编译下:
没错,符合预期,看起来这个口诀还是挺管用的呢!
除了 enforce
字段可以决定 loader 的执行顺序,还有一种内联 import
的写法也可以改变loader 的执行顺序,让我们来尝试下这种内联写法。调整下 d-loader
的位置,先将它从配置文件中移除,然后在引入.txt 的 index.js 文件中通过 import
的方式引入它。多个 loader 之间的连接可以通过 !
来进行分割。
js
// index.js
import txt from 'd-loader!./async.txt'
...// 省略
我们再来看一看增加了这种行内(inline)写法之后的 loader 执行顺序如下,即:pre → normal → inline → post 。
行内写法也可以通过添加前缀的方式来改变 loader 的执行顺序,一共有 3 种前缀的语法:
- ! : 忽略已配置的 normol-loader
- -!:忽略已配置的 pre-oader、normol-loader
- !!:忽略已配置的 (pre、normol、post) loader
让我们来实践一下 ,先整理下 webpack.config.js 中的配置。为了更好的测试,将 my-async-loader
放到行内写法中,保留 a-loader
、b-loader
、c-loader
。
js
// config.js 配置
module.exports = {
...//
module: {
rules: [
// start ---Loader 顺序----
{
test: /\.txt$/,
use: {
loader: 'a-loader',
},
enforce: 'pre',
},
{
test: /\.txt$/,
use: {
loader: 'c-loader',
},
enforce: 'post',
},
{
test: /\.txt$/,
use: [
{
loader: 'b-loader',
},
],
},
// end ---Loader 顺序----
]
}
}
-
! 前缀
jsimport txt from '!d-loader!my-async-loader!./async.txt'
执行结果:
a-loader → my-async-loader → d-loader → c-loader
-
-! 前缀
jsimport txt from '-!d-loader!my-async-loader!./async.txt'
执行结果:
my-async-loader → d-loader → c-loader
-
!! 前缀
jsimport txt from '!!d-loader!my-async-loader!./async.txt'
执行结果:
my-async-loader → d-loader
3.1 loader-normal 的参数
normal 阶段接收三个参数:
-
source
表示需要处理的源文件的内容。对于第一个loader 来说,source 是源文件,对于后续的 loader 来说是前一个loader 执行之后的结果。
js// a-loader.js module.exports = function (source, sourceMap,data) { console.log('a-loader') return source + "a-loader" }
-
sourceMap
可选参数,代码的sourceMap
-
data
可选参数,其他需要在loader 链中传递的信息。比如这个posthtml-loader 使用了data 来传递ast 信息。
jsmodule.exports = function (source, sourceMap, data) { console.log('a-loader') if (!data) { data = {} } data.name = 'xiaoqi' this.callback(null, source, sourceMap, data) }
到这里我们就聊完了loader的 normal 阶段的执行顺序,接下来我们来了解下 loader 的 pitch 阶段的执行顺序。
四、loader 的 pitch 阶段
对于一个 loader ,可以用 module.exports
来导出一个函数外(normal 阶段), 也可以用 module.exports.pitch
来导出(pitch 阶段)一个函数。前面说过对于 normal 阶段来说同一类型的 loader 可以套用口诀,自下而上,从右到左 ,不同类型遵循 pre → normal → inline → post
。 那么对于 pitch 阶段来说反过来即可。
我们来实践一下,先更改下配置,每个 loader 中导出 pitch 函数:
js
import txt from 'my-async-loader!./async.txt' // inline loader
// config.js 配置
module.exports = {
...//
module: {
rules: [
// start ---Loader 顺序----
{
test: /\.txt$/,
use: {
loader: 'a-loader',
},
enforce: 'pre',
},
{
test: /\.txt$/,
use: {
loader: 'c-loader',
},
enforce: 'post',
},
{
test: /\.txt$/,
use: [
{
loader: 'd-loader',
},
{
loader: 'b-loader',
},
],
},
// end ---Loader 顺序----
]
}
}
// a-loader.js 其他loader文件类似
module.exports = function (source) {
console.log('a-loader')
return source
}
module.exports.pitch = function () {
console.log('a-pitch-loader')
}
最终打印的结果如下:
可以看到,pitch 阶段先执行, normal 阶段后执行,pitch 阶段正好和 normal 阶段相反。
4.1 pitch 的熔断作用
上述的例子中,在 ptich 阶段我们都没有任何的返回操作,那如果在 pitch 阶段有返回值,loader 的执行顺序是什么样呢?
loader 的调用过程:
如果 pitch 过程中有非 undefined 的返回值时,将熔断 loader 的调用链,跳过后续的 loader ,将结果传递给前一个loader 。
在 d-loader
的pitch 阶段返回一个值:
js
// d-loader.js
module.exports.pitch = function () {
console.log('d-pitch-loader')
return 1
}
执行结果如下,直接熔断后续 loader 的调用:
常见的 style-loader 、vue-loader 都用到了pitch 阶段的熔断作用来实现。
4.2 loader - pitch 的参数
ptich 方法接受3个参数:
-
remainingRequest
表示剩余未处理的loader的绝对路径和资源文件的绝对路径 用 !分割 组合成的字符串。
对于上面的调用链图来说,以
my-async-loader
为例,remainRequest
表示的是右侧这部分的 loader的绝对路径+资源文件的绝对路径,即:xxx/d-loader.js!xxx/b-loader.js!xxx/a-loader.js!xxx/.txt
-
previousRequest
表示已经处理的loader的绝对路径用 !分割 组合成的字符串。
还是以
my-async-loader
为例,previousRequest
的是左侧这一部分的 loader 的绝对路径,即:xxx/c-loader.js
-
data
normal 阶段与pitch阶段之间的数据交互可以用 data 对象来传递,默认是一个空对象 {}。
当在一个 loader 的 pitch 阶段中对其 data 参数做处理后,在 normal 阶段可以通过
this.data
进行获取。js// my-async-loader.js module.exports = function (source) { console.log('my-async-loader', this.data.id) // 获取this.data.id ...// 省略 } module.exports.pitch = function (remainingRequest, previousRequest, data) { data.id = '010101' console.log('my-async-pitch-loader') console.log('my-async-pitch-loader-remainingRequest:', remainingRequest) console.log('my-async-pitch-loader-previousRequest:', previousRequest) }
4.3 pitch的应用 --- style-loader
一般情况下处理 css/less 文件的 loader 调用链为 [style-loader、css-loader, less-loader]。
- less-loader: 将 less 转换为标准 css。
- css-loader : 将 css 转换为 js 模块。
- style-loader : 将 js 模块以 link 、style 标签等方式挂在到 html 中。
实际上,style-loader 的作用只是将 css 如何挂在到 html 中, 其他的并不关心。style-loader 的核心代码如下:
js
// <https://github.com/webpack-contrib/style-loader/blob/master/src/index.js>
loader.pitch = function pitch(request) {
// ... 省略
const options = this.getOptions(schema);
// 以什么方式插入,默认 style 标签
const injectType = options.injectType || "styleTag";
// ... 省略
switch (injectType) {
case "linkTag": {
...// 省略
}
case "lazyStyleTag":
case "lazyAutoStyleTag":
case "lazySingletonStyleTag": {
...// 省略
}
case "styleTag":
case "autoStyleTag":
case "singletonStyleTag":
default {
... // 省略
return `
... // 省略
${getImportStyleAPICode(esModule, this)} // 1、导入runtime
${getImportInsertStyleElementCode(esModule, this)} // 2、导入创建 style 标签的方法
${getImportStyleContentCode(esModule, this, request)} // 3、导入 css 模块的内容
... // 省略
var options = ${JSON.stringify(runtimeOptions)};
options.setAttributes = setAttributes;
options.domAPI = ${getdomAPI(isAuto)};
options.insertStyleElement = insertStyleElement;
var update = API(content, options); // 4、将 css 样式插入到 style 标签中
`
}
}
}
injectType
表示将 css 以什么形式插入到 DOM 中,不同的 type 处理方式不同, 我们直接看 default 部分,比较符合日常的使用,即使用 <style>
标签插入到 DOM 中。这一部分关键点可以分为4步。
- 第一步:导入 API 方法。
injectStylesIntoStyleTag
主要是提供将 css 样式插入到 style 标签的 api。
js
// 导入runtime
${getImportStyleAPICode(esModule, this)}
// 实际返回
import API from "!../node_modules/.pnpm/style-loader@3.3.1_webpack@5.73.0/node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js";
- 第二步:导入一个
insertStyleElement
方法,用于创建 style 空标签。
js
// 导入空 style 标签
${getImportInsertStyleElementCode(esModule, this)}
// 实际返回
import insertStyleElement from "!../node_modules/.pnpm/style-loader@3.3.1_webpack@5.73.0/node_modules/style-loader/dist/runtime/insertStyleElement.js";
// insertStyleElement 方法如下
function insertStyleElement(options) {
const element = document.createElement("style");
options.setAttributes(element, options.attributes);
options.insert(element, options.options);
return element;
}
- 第三步:导入 css-loader 处理的结果,是一个被 webpack 处理好的 module。
js
// 导入 css 结果
${getImportStyleContentCode(esModule, this, request)}
// 实际返回
import content, * as namedExport from "!!../node_modules/.pnpm/css-loader@6.7.1_webpack@5.73.0/node_modules/css-loader/dist/cjs.js!./xq-loader/sprite-loader.js!./index.css"
这里 getImportStyleContentCode
方法调用上下文的 this.utils
api 中的 contextify
方法将 request
路径 (即pitch 方法的第一个参数 remainingRequest)转换为一个新的请求路径(即一个相对路径)。
js
function getImportStyleContentCode(esModule, loaderContext, request) {
const modulePath = stringifyRequest(loaderContext, `!!${request}`);
return esModule
? `import content, * as namedExport from ${modulePath};`
: `var content = require(${modulePath});`;
}
// stringifyRequest 方法返回的内容
loaderContext.utils.contextify(loaderContext.context, request) => "../node_modules/.pnpm/css-loader@6.7.1_webpack@5.73.0/node_modules/css-loader/dist/cjs.js!./xq-loader/sprite-loader.js!./index.css"
注意这里的 !!
符号,即忽略已经配置过的 loader ,避免循环执行。webpack 执行到这里时,会递归执行这个行内loader 最终返回一个 module
(即这里的conent),可供 runtime 阶段直接使用。(这里是按照项目中配置的loader来,所以会有一个 xq-loader/sprite-loader.js
)
js
import content, * as namedExport from "!!../node_modules/.pnpm/css-loader@6.7.1_webpack@5.73.0/node_modules/css-loader/dist/cjs.js!./xq-loader/sprite-loader.js!./index.css"
- 第四步:runtime阶段,将前面导入的 css内容插入到style 标签中。
js
var update = API(content, options);
以上就是 style-loader 的工作原理。假如这里 style-loader 设计为一个 normal loader,按照执行顺序,style-loader 需要额外处理 css-loader 返回的 js 脚本内容,提取需要的样式内容,这样无疑需要写一堆处理逻辑,设计成 pitch loader 只需要关注如何将 css 内容插入到 DOM 中即可,其他的交给 webpack 处理。将这一部分转换为流程图更好理解。
- 执行 pitch 方法,返回一段 js 脚本给 webpack ,后续 loader 不再执行
- webpack 继续解析构建 style-loader 返回的内容,遇到 inline-loader 之后
五、总结
- loader的两个阶段:对于一个 loader 来说,都有自己的 normal ��法和 pitch 方法。
- loader 的调用链:对于多个 loader 的调用链即先从低到高(post → pre)依次调用 pitch 方法,再依次从高到低 (pre → post)调用各自的 normal 方法。
- pitch 的熔断作用:pitch 方法中如有非undefined的返回,则会熔断后续loader的执行。style-loader 、vue-loader 都巧妙的利用了 pitch 的熔断作用。