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 的熔断作用。
相关推荐
学不会•23 分钟前
css数据不固定情况下,循环加不同背景颜色
前端·javascript·html
活宝小娜3 小时前
vue不刷新浏览器更新页面的方法
前端·javascript·vue.js
程序视点3 小时前
【Vue3新工具】Pinia.js:提升开发效率,更轻量、更高效的状态管理方案!
前端·javascript·vue.js·typescript·vue·ecmascript
coldriversnow3 小时前
在Vue中,vue document.onkeydown 无效
前端·javascript·vue.js
我开心就好o3 小时前
uniapp点左上角返回键, 重复来回跳转的问题 解决方案
前端·javascript·uni-app
开心工作室_kaic4 小时前
ssm161基于web的资源共享平台的共享与开发+jsp(论文+源码)_kaic
java·开发语言·前端
刚刚好ā4 小时前
js作用域超全介绍--全局作用域、局部作用、块级作用域
前端·javascript·vue.js·vue
沉默璇年5 小时前
react中useMemo的使用场景
前端·react.js·前端框架
yqcoder5 小时前
reactflow 中 useNodesState 模块作用
开发语言·前端·javascript
2401_882727575 小时前
BY组态-低代码web可视化组件
前端·后端·物联网·低代码·数学建模·前端框架