跳到主要内容

认识

2024年04月06日
柏拉文
越努力,越幸运

一、认识


Webpack 中的 loader 本质上是一个函数, 接收文件内容源代码作为入参, 同时返回处理后的结果。

Webpack 使用 loader-runner 来依次调用配置好的 LoaderLoader 的处理分为两个阶段: 1. Pitch 阶段, 从左到右依次执行所有 Loaderpitch 方法, 每个 Loaderpitch 可以选择提前返回结果, 若返回结果则会中断后续的 pitch 调用, 否则继续到下一个 Loader, 如果所有 Loaderpitch 均未返回数据, 则会读取原始文件内容, 进入 normal 阶段; 2. Normal 阶段, Loadernormal 方法按照从右向左的顺序执行(即倒序执行), 在该阶段, 每个 Loader 对上一步传递的内容进行转换,例如对 ES6 语法进行 Babel 转译、对 TypeScript 代码进行编译等。此过程可能非常消耗 CPU,尤其是当 Loader 数量多、转换逻辑复杂时(如使用 babel-loadereslint-loaderts-loader 等时,有时会多次生成 AST 进行校验与转换)。Webpack 5 内置了持久化缓存机制,可以将 Loader 处理、AST 解析等中间结果缓存起来,加快后续增量构建的速度。

二、语法


2.1 配置语法

module.exports = {
module:{
rules: [
{
test: //,
enforce: 'pre',
use: [{loader: 'xx-loader'}]
}
]
}
}
  • test: 是一个正则表达式,我们会对应的资源文件根据 test 的规则去匹配。如果匹配到,那么该文件就会交给对应的loader去处理。

  • use: use表示匹配到test中匹配对应的文件应该使用哪个loader的规则去处理,use可以为一个字符串,也可以为一个数组。额外注意,如果use为一个数组时表示有多个loader依次处理匹配的资源,按照从右往左(从下往上) 的顺序去处理。

  • enforce: loader中存在一个enforce参数标志着loader的顺序。enforce有两个值分别为prepost:

    1. 当我们的rules中的规则没有配置enforce参数时,默认为normal loader(默认loader)。

    2. 当我们的rules中的规则配置enforce:'pre'参数时,我们称之它为pre loader(前置loader)。

    3. 当我们的rules中的规则配置enforce:'post'参数时,我们称之它为post loader(后置loader)。

2.2 编写语法

function loader(sourceCode, sourceMap?, data?){
console.log(this);
}

loader.pitch = function(remainingRequest, previousRequest, data){

}

module.exports = loader;
  • sourceCode: 资源输入, 对于第一个执行的 Loader 为资源文件的内容;后续执行的 Loader 则为前一个 Loader 的执行结果,可能是字符串,也可能是代码的 AST 结构

  • sourceMap: 可选参数,代码的 sourcemap 结构

  • data: 可选参数,其它需要在 Loader 链中传递的信息,比如 posthtml/posthtml-loader 就会通过这个参数传递额外的 AST 对象

  • this: 表示上下文对象, 上下文对象将在运行 Loader 时以 this 方式注入到 Loader 函数。有如下属性:

    • this.fs: Compilation 对象的 inputFileSystem 属性,我们可以通过这个对象获取更多资源文件的内容

    • this.resource: 当前文件路径

    • this.resourceQuery: 文件请求参数,例如 import "./a?foo=bar"resourceQuery 值为 ?foo=bar

    • this.async: 用于声明这是一个异步 Loader,开发者需要通过 async 接口返回的 callback 函数传递处理结果

    • this.callback:

    • this._compiler: 用于访问 webpack 的当前 Compiler 对象

    • this._compilation: 用于访问 webpack 的当前 Compilation 对象

    • this.getOptions(schema): 用于获取当前 Loader 的配置对象。提取给定的 loader 选项,接受一个可选的 JSON schema 作为参数

    • this.cacheable(true / false): Webpack 默认会缓存 Loader 的执行结果直到资源或资源依赖发生变化,开发者需要对此有个基本的理解,必要时可以通过 this.cachable 显式声明不作缓存

    • this.emitWarning: 添加警告

    • this.emitError: 添加错误信息,注意这不会中断 Webpack 运行

    • this.emitFile: 用于直接写出一个产物文件,例如 file-loader 依赖该接口写出 Chunk 之外的产物;

    • addDependency: 将 dep 文件添加为编译依赖,当 dep 文件内容发生变化时,会触发当前文件的重新构建

  • pitch:

    • remainingRequest: 表示剩余需要处理的loader的绝对路径以!分割组成的字符串。

    • previousRequest: 表示pitch阶段已经迭代过的loader按照!分割组成的字符串。

    • data: 默认是一个空对象{}, 在normalLoaderpitch Loader进行交互正是利用了第三个data参数。

三、编写


3.1 通过 this.cacheable() 取消 Loader 缓存

需要注意,Loader 中执行的各种资源内容转译操作通常都是 CPU 密集型 —— 这放在 JavaScript 单线程架构下可能导致性能问题;又或者异步 Loader 会挂起后续的加载器队列直到异步 Loader 触发回调,稍微不注意就可能导致整个加载器链条的执行时间过长。

为此,Webpack 默认会缓存 Loader 的执行结果直到模块或模块所依赖的其它资源发生变化,我们也可以通过 this.cacheable 接口显式关闭缓存:

module.exports = function(source) {
this.cacheable(false);
// ...
return output;
};

3.2 Loader 通过 this.callback() 返回多个结果

简单的 Loader 可直接 return 语句返回处理结果,复杂场景还可以通过 callback 接口返回更多信息,供下游 Loader 或者 Webpack 本身使用

export default function loader(content, map) {
this.callback(null, content, map);
}

通过 this.callback(null, content, map) 语句,同时返回转译后的内容与 sourcemap 内容。callback 的完整签名如下:

this.callback(
// 异常信息,Loader 正常运行时传递 null 值即可
err: Error | null,
// 转译结果
content: string | Buffer,
// 源码的 sourcemap 信息
sourceMap?: SourceMap,
// 任意需要在 Loader 间传递的值
// 经常用来传递 ast 对象,避免重复解析
data?: any
);

3.3 Loader 通过 this.async() 返回异步结果

涉及到异步或 CPU 密集操作时,Loader 中还可以以异步形式返回处理结果

async function loader(source){
// 1. 调用 this.async() 获取异步回调函数, 此时 Webpack 会将该 Loader 标记为异步加载器, 会挂起当前执行队列直到 callback 被触发;
const callback = this.async();

try{
// 2. 编译模块
const result = await handler(source);
}catch(error){

}

// 3. 编译结束, 调用异步回调 callback 返回处理结果
callback(null,css,map);
}

3.4 Loader 通过 return promise 返回异步结果

涉及到异步或 CPU 密集操作时,Loader 中还可以以异步形式返回处理结果

function loader(source){
return Promise((resolve)=>{
setTimeout(()=>{
resolve(3000);
});
});
}

3.5 通过导出 raw 处理 Loader 二进制资源

默认情况下,资源文件会被转化为 UTF-8 字符串,然后传给 loader。通过设置 rawtrueloader 可以接收原始的 Buffer。每一个 loader 都可以用 String 或者 Buffer 的形式传递它的处理结果。complier 将会把它们在 loader 之间相互转换。

有时候我们期望以二进制方式读入资源文件,例如在 file-loaderimage-loader 等场景中,此时只需要添加 export const raw = true 语句即可,如:

module.exports = function (content) {
assert(content instanceof Buffer);
return someSyncOperation(content);
// 返回值也可以是一个 `Buffer`
// 即使不是 "raw",loader 也没问题
};
module.exports.raw = true;

3.6 Loader 通过 this.emitFile() 直接写出文件

Loader ContextemitFile 接口可用于直接写出新的产物文件

function loader(source){
this.emitFile(outputPath,content,null,assetInfo);
return ……
}

借助 emitFile 接口,我们能够在 Webpack 构建主流程之外提交更多产物,这有时候是必要的,除上面提到的 file-loader 外,response-loadermermaid-loader 等也依赖于 emitFile 实现构建功能。

3.7 Loader 通过 this.addDependency() 添加额外依赖

Loader ContextaddDependency 接口用于添加额外的文件依赖,当这些依赖发生变化时,也会触发重新构建

function loader(source){
this.addDependency(……);
}

3.8 Loader 函数上挂载 pitch 函数, 先于 Loader 执行

Webpack 启动后会以一种所谓链式调用的方式按 use 数组顺序从后到前调用 Loader。链式调用有两个问题:

  1. Loader 链条一旦启动之后,需要所有 Loader 都执行完毕才会结束,没有中断的机会 —— 除非显式抛出异常

  2. 某些场景下并不需要关心资源的具体内容,但 Loader 需要在 source 内容被读取出来之后才会执行

为了解决这两个问题,WebpackLoader 基础上叠加了 pitch 的概念。Webpack 允许在 Loader 函数上挂载名为 pitch 的函数,运行时 pitch 会比 Loader 本身更早执行。

function loader1(sourceCode){
console.log("loader1");
return sourceCode + `\n // loader1 处理`;
}

loader1.pitch = function(remainingRequest, previousRequest, data){

}

export default loader1;
  • remainingRequest: 当前 loader 之后的资源请求字符串

  • previousRequest: 在执行当前 loader 之前经历过的 loader 列表

  • data: 与 Loader 函数的 data 相同,用于传递需要在 Loader 传播的信息

实现上,Loader 链条执行过程分三个阶段:pitch、解析资源、执行。pitch 阶段按配置顺序从左到右逐个执行 loader.pitch 函数(如果有的话),开发者可以在 pitch 返回任意值中断后续的链路的执行。纯函数.pitch 如果返回一个非 undefined 的值会发生熔断, loader 执行链条会被阻断, 立马掉头执行, 直接掉头执行上一个已经执行的loadernormal阶段并且将pitch的返回值传递给下一个normal loader

pitcher loader 应用场景: 如果在loader开发中你的需要依赖loader其他loader,但此时上一个loadernormal函数返回的并不是处理后的资源文件内容而是一段js脚本, 可以将逻辑放在 pitch 中, 通过 import someThing from !!${remainingRequest} 的方式, 将脚本交给webpack去编译执行。

四、配置


4.1 modules.rules loaderName

module.exports = {
module: {
rules: [
{
test:/\.js$/,
loader: "xx-loader"
}
]
}
}
module.exports = {
module: {
rules: [
{
test:/\.js$/,
use: ['xx-loader','xx-loader']
}
]
}
}
module.exports = {
module: {
rules: [
{
test:/\.js$/,
use: [{ loader: 'xx-loader' }]
}
]
}
}

4.2 module.rules loaderPath

const path = require('path')
// webpack.config.js
module.exports = {
...
module: {
rules: [
{
test:/\.js$/,
// .js后缀其实可以省略,后续我们会为大家说明这里如何配置loader的模块查找规则
loader: path.resolve(__dirname,'../loaders/babel-loader.js')
}
]
}
}

这里我们在loader参数中传入一个绝对路径的形式,直接去该路径查找对应的loader所在的js文件。

4.3 module.rules resolveLoader.alias

const path = require('path')
// webpack.config.js
module.exports = {
...
resolveLoader: {
alias: {
'babel-loader': path.resolve(__dirname,'../loaders/babel-loader.js')
}
},
module: {
rules: [
{
test:/\.js$/,
loader: 'babel-loader'
}
]
}
}

此时,当webpack在解析到loader中使用babel-loader时,查找到alias中定义了babel-loader的文件路径。就会按照这个路径查找到对应的loader文件从而使用该文件进行处理。

4.4 module.rules resolveLoader.modules

const path = require('path')
// webpack.config.js
module.exports = {
...
resolveLoader: {
modules: [ path.resolve(__dirname,'../loaders/') ]
},
module: {
rules: [
{
test:/\.js$/,
loader: 'babel-loader'
}
]
}
}

4.5 import inline loader

引用资源时,通过!分割使用loader的方式称为行内loader

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

4.6 import inline of disable normal loader

单个 ! 开头, 排除所有 normal-loader

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

4.7 import inline of disable all loader

两个 !! 开头, 排除所有 pre-loadernormal-loaderpost-loader

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

4.8 import inline of disable preLoader and normalLoader

一个 -! 开头, 排除所有 pre-loadernormal-loader

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

五、思考与沉淀


5.1 loader 与 plugin 的区别

Webpack 中的 loader 本质上是一个函数, 接收文件内容源代码作为入参, 同时返回处理后的结果。Loader 用于转换模块的源代码。它们在模块被加入到依赖图之前执行,主要负责对文件内容进行预处理,如将 TypeScript 转译为 JavaScript、将 SCSS 转换为 CSS 等。Loader 按照配置的规则匹配特定文件,依次处理文件内容,支持链式调用(多个 Loader 串联执行)。Loader 的执行顺序为: Pitch 阶段, 从左到右顺序执行, Normal 阶段, 从右到左逆序执行。

function loader(sourceCode, sourceMap?, data?){
console.log(this);
}

loader.pitch = function(remainingRequest, previousRequest, data){

}

module.exports = loader;

Webpack 中的 Plugin 是一个类, 每一个插件都必须提供一个 apply 方法。apply 方法会接收一个 compiler 对象。Plugin 用于扩展 Webpack 的整体构建流程和功能, 它们可以在整个编译周期内介入, 从初始化、编译、打包到输出等各个环节发挥作用。比如在 compiler.run 阶段、compiler.compile 阶段、compiler.make 阶段、compiler.emit 阶段、compilation.optimizeAssets 等。

class MyPlugin {
constructor(options){

}

apply(compiler){
compiler.hook.compilation.tap("MyPlugin", (compilation)=>{

});
}
}

参考资料


多角度解析Webpack5之Loader核心原理

你不知道的「pitch loader」应用场景