Loader 的调用链

一、前言

在上一篇 《手把手教你实现 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-loaderpost-loadernormal-Loader, 顾名思义,它们执行顺序为 pre → normal → post

理论不如实战,我们还是基于上一个项目来实战一下。在 xq-loader 的文件夹下创建三个文件 a-loader.jsb-loader.jsc-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-loadermy-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-loaderb-loaderc-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 顺序----
		]
	}
}
  • ! 前缀

    js 复制代码
     import txt from '!d-loader!my-async-loader!./async.txt'

    执行结果:a-loader → my-async-loader → d-loader → c-loader

  • -! 前缀

    js 复制代码
      import txt from '-!d-loader!my-async-loader!./async.txt'

    执行结果:my-async-loader → d-loader → c-loader

  • !! 前缀

    js 复制代码
     import 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 信息。

    js 复制代码
       module.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";
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 的熔断作用。
相关推荐
加班是不可能的,除非双倍日工资4 小时前
css预编译器实现星空背景图
前端·css·vue3
wyiyiyi5 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip5 小时前
vite和webpack打包结构控制
前端·javascript
excel5 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国6 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼6 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin
Jimmy6 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
ZXT6 小时前
promise & async await总结
前端
Jerry说前后端6 小时前
RecyclerView 性能优化:从原理到实践的深度优化方案
android·前端·性能优化
画个太阳作晴天6 小时前
A12预装app
linux·服务器·前端