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

2022-02-28 09:27:45 浏览数 (1)

写在前边

日益繁杂的前端工程化中,围绕Webpack的前端工程化在前端项目中显得格外重要, 谈到webpack必不可少的就会提起Loader机制。

这里我们会从应用-原理-实现一层一层来揭开loader的面目。废话不多说,让我们快速开始吧。

文章会围绕以下三个方面循序渐进带你彻底掌握Webpack Loader机制:

  • Loader**概念**: 何谓Loader, 从基础出发带你快速入门日常业务中Loader的各种配置方式。
  • Loader**原理**: 从源码解读Loader模块,手把手实现Webpack核心loader-runner库。
  • Loader**实现**: 复刻高频次出现的Babel-loader,带你掌握企业级Loader开发流程。

这里我们会告别枯燥的源码阅读方式,图文并茂的带大家掌握Loader核心原理并且熟练应用于各种场景之下。

文章基于webpack最新5.64.1版本loader-runner4.2.0版本进行分析。

Ok! Let's Do It !

Loader概念

Loader的作用

让我们先从最基础的开始说起,所谓Loader本质上就是一个函数。

loader 用于对模块的源代码进行转换。loader 可以使你在 import 或 "load(加载)" 模块时预处理文件。因此,loader 类似于其他构建工具中“任务(task)”,并提供了处理前端构建步骤的得力方式。loader 可以将文件从不同的语言(如 TypeScript)转换为 JavaScript 或将内联图像转换为 data URL。loader 甚至允许你直接在 JavaScript 模块中 import CSS 文件!

webpack中通过compilation对象进行模块编译时,会首先进行匹配loader处理文件得到结果(string/buffer),之后才会输出给webpack进行编译。

简单来说,**loader就是一个函数,通过它我们可以在webpack处理我们的特定资源(文件)之前进行提前处理**。

比方说,webpack仅仅只能识别javascript模块,而我们在使用TypeScript编写代码时可以提前通过babel-loader.ts后缀文件提前编译称为JavaScript代码,之后再交给Webapack处理。

Loader配置相关API

常用基础配置参数

我们来看一段最简单的webpack配置文件:

代码语言:javascript复制
module.exports = {
  module: {
    rules: [
      { test: /.css$/, use: 'css-loader',enforce: 'post' },
      { test: /.ts$/, use: 'ts-loader' },
    ],
  },
};

相信这段配置代码大家已经耳熟能详了,我们通过module中的rules属性来配置loader

其中:

test参数

test是一个正则表达式,我们会对应的资源文件根据test的规则去匹配。如果匹配到,那么该文件就会交给对应的loader去处理。

use参数

use表示匹配到test中匹配对应的文件应该使用哪个loader的规则去处理,use可以为一个字符串,也可以为一个数组。

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

enforce参数

loader中存在一个enforce参数标志这loader的顺序,比如这样一份配置文件:

代码语言:javascript复制
module.exports = {
  module: {
    rules: [
      { test: /.css$/, use: 'sass-loader', enforce: 'pre' },
      { test: /.css$/, use: 'css-loader' },
      { test: /.css$/, use: 'style-loader', enforce: 'post' },
    ],
  },
};

针对.css结尾的资源文件,我们在打包过程中module.rules分别有三条规则匹配到,也就是对于同一个.css文件我们需要使用匹配到的三个loader分别进行处理。

那么此时,如果我们希望三个**loader**的顺序可以不根据书写时的顺序去处理,那么**enforce**就会大显身手

enforce有两个值分别为prepost

  • 当我们的rules中的规则没有配置enforce参数时,默认为normal loader(默认loader)。
  • 当我们的rules中的规则配置enforce:'pre'参数时,我们称之它为pre loader(前置loader)。
  • 当我们的rules中的规则配置enforce:'post'参数时,我们称之它为post loader(后置loader)。

关于这三种loader的执行顺序,我想大家根据名称也可以猜的出来一二,没错在 正常**loader** 的执行阶段这三种类型的loader执行顺序为:

当然,那么什么是不正常loader呢?我们会在后续详细给大家讲到。

webpack中配置loader的三种方式

通常我们在配置时都是直接使用直接使用loader名称的方式,比如:

代码语言:javascript复制
// webpack.config.js
module.exports = {
    ...
    module: {
        rules: [
            {
                test:/.js$/,
                loader: 'babel-loader'
            }
        ]
    }
}

上边的配置文件中,相当于告诉webpack关于js结尾的文件使用babel-loader去处理。可是这里我们明明只写了一个**babel-loader**的字符串,它是如何去寻找到**babel-loader**的真实内容呢?

带着这个疑问,接下来让我们一起来看看在webpack中配置loader的三种方式。

绝对路径

第一种方式在项目内部存在一些未发布的自定义loader时比较常见,直接使用绝对路径地址的形式指向**loader**文件所在的地址。 比如:

代码语言:javascript复制
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文件。

resolveLoader.alias

第二种方式我们可以通过webpack中的resolveLoader的别名alias方式进行配置,比如:

代码语言:javascript复制
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文件从而使用该文件进行处理。

当然在我们定义loader时如果每一个loader都需要定义一次resolveLoader.alias的话无疑太过于冗余了,情况在真实业务场景下通常我们都很少自己定义resolveLoader选项但是webpack也可以自动的帮我们找到对应的loader,这就要引出我们的另一个参数了。

resolveLoader.modules

我们可以通过resolveLoader.modules定义webpack在解析loader时应该查找的目录,比如:

代码语言:javascript复制
const path = require('path')
// webpack.config.js
module.exports = {
    ...
    resolveLoader: {
        modules: [ path.resolve(__dirname,'../loaders/') ]
    },
    module: {
        rules: [
            {
                test:/.js$/,
                loader: 'babel-loader'
            }
        ]
    }
}

上述代码中我们将resolveLoader.modules配置为 path.resolve(__dirname,'../loaders/'),此时在webpack解析loader模块规则时就会去path.resolve(__dirname,'../loaders/')目录下去寻找对应文件。

当然resolveLoader.modules的默认值是['node_modules'],自然在默认业务场景中我们通过npm install按照的第三方loader都是存在于node_modules内所以配置mainFields默认就可以找到对应的loader入口文件。

关于resolveLoader有些同学可能户非常眼熟,它和resolve正常模块解析的配置参数是一模一样的。只不过resolveLoader是相对于loader的模块加载规则的,具体更多的配置手册你可以在这里看到。

同时需要注意的是modules字段中的相对路径查找规则是类似于 Node 查找 'node_modules' 的方式进行查找。比如说modules:['node_modules'],即是在当前目录中通过查看当前目录以及祖先路径(即 ./node_modules../node_modules 等等)进行规则查找。

loader种类与执行顺序

Loader的种类

上边我们讲到了通过配置文件的enforce参数可以将loader分为三种类型:pre loadernormal loaderpost noraml,分别代表了三种不同的执行顺序。

当然在在 配置文件中根据**loader**的执行顺序,我们可以将**loader**分为三种类型同时**webpack**还支持一种内联的方式配置**loader**, 比如我们在引用资源文件时:

代码语言:javascript复制
import Styles from 'style-loader!css-loader?modules!./styles.css';

通过上述的方式,我们在引用./styles.css时候,调用了css-loaderstyle-loader进行提前处理文件,同时给css-loader传递了modules的参数。

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

至此,我们清楚关于loader的种类存在四种类型的loader,分别是pre loadernormal loaderinline loaderpost loader四种类型。

关于inline loader还有一些特殊的前置参数需要大家清楚:

通过为内联 import 语句添加前缀,可以覆盖配置中的所有 normalLoader, preLoaderpostLoader

使用 ! 前缀,将禁用所有已配置的 normal loader(普通 loader)

代码语言:javascript复制
import Styles from '!style-loader!css-loader?modules!./styles.css';

使用 !! 前缀,将禁用所有已配置的 loader(preLoader, loader, postLoader)

代码语言:javascript复制
import Styles from '!!style-loader!css-loader?modules!./styles.css';

使用 -! 前缀,将禁用所有已配置的 preLoader 和 loader,但是不禁用 postLoaders

代码语言:javascript复制
import Styles from '-!style-loader!css-loader?modules!./styles.css';

这里大家没有死记硬背的必要,了解大致用法后具体可以通过webpack官方网站进行查阅即可。

Loader的执行顺序

在了解了我们将loader分为了pre loadernormal loaderinline loaderpost loader四种loader

其实这四种loader通过命名我们也可以看出来他们的执行顺序,在默认的**Loader**执行阶段这四种loader会按照如下顺序执行:

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

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

注意这里我们强调的是默认loader的执行阶段,那么什么是非默认呢?接下来让我们一起来看看所谓的pitch loader阶段。

loaderpitch阶段

关于loader的执行阶段其实分为两种阶段:

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

简单来说就是所谓的loader在处理文件资源时分为两个阶段: pitch阶段和nomral阶段。

让我们来看这样一个例子:

代码语言:javascript复制
// webpack.config.js

module.exports = {
  module: {
    rules: [
      // 普通loader
      {
        test: /.js$/,
        use: ['normal1-loader', 'normal2-loader'],
      },
      // 前置loader
      {
        test: /.js$/,
        use: ['pre1-loader', 'pre2-loader'],
        enforce: 'pre',
      },
      // 后置loader
      {
        test: /.js$/,
        use: ['post1-loader', 'post2-loader'],
        enforce: 'post',
      },
    ],
  },
};
代码语言:javascript复制
// 入口文件中
import something from 'inline1-loader!inline2-loader!./title.js';

这里,我们在webpack配置文件中对于js文件配置了三种处理规则6个loader。同时在入口文件引入./title.js使用了我们之前讲到过的inline loader

让我们用一张图来描述下所谓loader的执行顺序:

loader的执行阶段实际上分为两个阶段,webpack在使用loader处理资源时首先会经过**loader.pitch**阶段,**pitch**阶段结束后才会读取文件而后进行**normal**阶段处理

  • Pitching 阶段: loader 上的 pitch 方法,按照 后置(post)、行内(inline)、普通(normal)、前置(pre) 的顺序调用。
  • Normal 阶段: loader 上的 常规方法,按照 前置(pre)、普通(normal)、行内(inline)、后置(post) 的顺序调用。

请各位同学牢牢记住上边的loader执行流程图,之后我们也会详细使用这个流程去带大家实现loader-runner的源码。

关于pitch阶段有什么作用,webpack为何如此设计loader。别着急,后边的内容慢慢为你解开这些答案。

pitch Loader的熔断效果

上边我们通过一张图描述了webpackloader的执行顺序。我们了解到除了正常的loader执行阶段还额外存在一个loader.pitch阶段。

pitch loader本质上也是一个函数,比如:

代码语言:javascript复制
function loader() {
    // 正常的loader执行阶段...
}
loader.pitch = function () {
    // pitch loader
}

关于pitch loader的需要特别注意的就是Pitch Loader带来的熔断效果。

假设我们在上边配置的8个loader中,为inline1-loader添加一个pitch属性使它拥有pitch函数,并且,我们让它的**pitch**函数随便返回一个非**undefined**的值

代码语言:javascript复制
// inline1-loader normal
function inline1Loader () {
    // dosomething
}
// inline1-loader pitch
inline1Loader.pitch = function () {
    // do something
    return '19Qingfeng'
}

这里我们在inline1-loader pitch阶段返回了一个字符串19Qingfeng,我们上边说到过在loader的执行阶段是会按照这张图进行执行(pitch阶段全部返回undefined情况下):

但是一旦在某一个**loader**的**pitch**阶函数中返回一个非**undefined**的值就会发生熔断的效果:

我们可以看到当我们在**inline1-loader**的**pitch**函数中返回了一个字符串**19Qingfeng**时,**loader**的执行链条会被阻断--立马掉头执行,直接掉头执行上一个已经执行的**loader**的**normal**阶段并且将**pitch**的返回值传递给下一个**normal loader**,简而言之这就是**loader**的熔断效果。

Loader开发相关API

上边我们带大家入门了loader的基础概念和配置用法,我们了解了loader按照执行阶段分为4中类型且loader执行时分为两个阶段:pitchnormal阶段。

接下来让我们来看一下常见开发loader相关内容:

关于执行顺序对于loader开发的影响

这里我特意想和大家强调一下,上边我们说过loader本质上就是一个函数。

代码语言:javascript复制
function loader() {
    // ...
}

// pitch 属性是可有可无的
loader.pitch = function () {
    // something
}

关于loader的执行顺序是通过**webpack**配置中决定的,换而言之一个loader到底是prenormalinline还是postloader开发本身是没有任何关系的。

执行顺序仅仅取决于**webpack**应用**loader**时的配置(或者引入文件时候添加的前缀)。

同步 or 异步loader

同步Loader

上边我们罗列的loader都是同步loader,所谓同步loader很简单。就是在loader本身阶段同步处理对应逻辑从而返回对应的值:

代码语言:javascript复制
// 同步loader
// 关于loader的source参数 我们会在后续详尽讲述到 这里你可以理解为需要处理的文件内容
function loader(source) {
    // ...
    // 一系列同步逻辑 最终函数返回处理后的结果交给下一个阶段
    return source
}

// pitch阶段的同步同理
loader.pitch = function () {
    // 一系列同步操作 函数执行完毕则pitch执行完毕
}

同步loadernormal阶段返回值时可以通过函数内部的return语句进行返回,同时如果需要返回多个值时也可以通过this.callback()表示loader结束传入多个值进行返回,比如this.callback(error,value2,...),需要注意this.callback第一个参数一定是表示是否存在错误。具体你可以在这里进行查看更加详细的用法。

异步Loader

在开发loader时绝大多数情况下我们是用同步loader就可以满足我们的要求了,但是往往会存在一些特殊情况。比如我们需要在loader内部调用一些远程接口或者定时器之类的操作。此时就需要loader可以等待异步返回结束后才继续执行下一个阶段处理:

loader变为异步loader有两种方式:

返回Promise

我们仅仅修改loader的返回值为一个Promise就可以将loader变为异步loader,后续步骤会等待返回的Promise变成resolve后才会继续执行。

代码语言:javascript复制
funciton asyncLoader() {
    // dosomething
    return Promise((resolve) => {
        setTimeout(() => {
            // resolve的值相当于同步loader的返回值
            resolve('19Qingfeng')
        },3000)
    })
}
通过this.async

同样还有另一种方式也是比较常用的异步loader方式,我们通过在loader内部调用this.async函数将loader变为异步,同时this.async会返回一个callback的方式。只有当我们调用callback方法才会继续执行后续阶段处理。

代码语言:javascript复制
function asyncLoader() {
    const callback = this.async()
    // dosomething
    
    // 调用callback告诉loader-runner异步loader结束
    callback('19Qingfeng')
}

同样loaderpitch阶段也可以通过上述两个方案变成异步loader

normal loader & pitch loader参数详解

Normal Loader

normal loader默认接受一个参数,这个参数是需要处理的文件内容。在存在多个loader时,它的参数会受上一个loader的影响。

同时nomral loader存在一个返回值,这个返回值会链式调用给下一个loader作为入参,当最后一个loader处理完成后,会讲这个返回值返回给webpack进行编译。

代码语言:javascript复制
// source为需要处理的源文件内容 
function loader(source) {
    // ...
    // 同时返回本次处理后的内容
    return source   'hello !'
}

关于normal loader中其实有非常多的属性会挂载在函数中的this上,比如通常我们在使用某个loader时会在外部传递一些参数,此时就可以在函数内部通过this.getOptions()方法获取。 关于loader中的this被称作上下文对象,更多的属性你可以在这里看到

Pitch Loader
代码语言:javascript复制
// normal loader
function loader(source) {
    // ...
    return source
}

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

LoaderPitch阶段也是一个函数,它接受3个参数,分别是:

  • remainingRequest
  • previousRequest
  • data
remainingRequest

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

同样我们在上边的loader中为每个normal loader分别添加一个pitch属性,我们以loader2.pitch来举例:

loader.pitch函数中remainingRequest的值为xxx/loader3.js的字符串。如果说后续还存在多个loader,那么他们会以!进行分割。

需要注意的是remainingRequest与剩余loader有没有pitch属性没有关系,无论是否存在pitch属性remainingRequest都会计算pitch阶段还未处理剩余的loader

previousRequest

在理解了remainingRequest的概念之后,那么pitch loader的第二个参数就很好理解了。

它表示**pitch**阶段已经迭代过的**loader**按照**!**分割组成的字符串

注意同样previousRequest和有无pitch属性没有任何关系。同时remainingRequestpreviousRequest都是不包括自身的(也就是我们例子中都不包含loader2自身的绝对路径)。

data

现在让我们来看看pitch loader最后一个参数。这个参数默认是一个空对象{}

normalLoaderpitch Loader进行交互正是利用了第三个data参数。

同样我们以上图中的loader2来举例:

  • 当我们在loader2.pith函数中通过给data对象上的属性赋值时,比如data.name="19Qingfeng"
  • 此时在loader2函数中可以通过this.data.name获取到自身pitch方法中传递的19Qingfeng

loaderraw属性

值得一提的是日常我们在开发一些loader时,normal Loader的参数我们讲到过它会接受前置normal loader or 对应资源文件(当它为第一个loader还未经过任何loader处理时) 的内容。这个内容默认是一个string类型的字符串。

但是在我们开发一些特殊的loader时,比如我们需要处理图片资源时,此时对于图片来说将图片变成字符串明显是不合理的。针对于图片的操作通常我们需要的是读取图片资源的**Buffer**类型而非字符串类型

此时我们可以通过loader.raw标记normal loader的参数是Buffer还是String:

  • loader.rawfalse时,此时我们normal loadersource获取的是一个String类型,这也是默认行为。
  • loader.rawtrue时,此时这个loadernormal函数接受的source参数就是一个Buffer类型。
代码语言:javascript复制
function loader2(source) {
    // 此时source是一个Buffer类型 而非模型的string类型
}

loader2.raw = true

module.exports = loader2

Normal Loader & Pitch Loader 返回值

上边其实我们已经详细讲过了关于Normal LoaderPitch Loader的返回值。

  • Normal阶段,loader函数的返回值会在loader chain中进行一层一层传递直到最后一个loader处理后传递将返回值给webpack进行传递。
  • Pitch阶段,任意一个loaderpitch函数如果返回了非undefined的任何值,会发生熔断效果同时将pitch的返回值传递给normal阶段loader的函数。

需要额外注意的是,在normal阶段的最后一个loader一定需要返回一个js代码(一个module的代码,比如包含module.exports语句)。

关于熔断效果我相信大家如果认真看到这里一定能够理解它,如果对于熔断还有疑问的小伙伴我强烈建议再去看看我们上边关于熔断的两张图。

Loader源码分析

在上边我们对于loader的基础内容和概念进行了详细的讲解。掌握了上边的内容之后我相信在日常业务中对于绝大多数loader的场景你都可以游刃有余。

可是作为一个合格的前端工程师,任何一款工具的使用如果仅仅停留在应用方便一定是不合格的。

接下来,让我们从源码出发一步一步去掌握webpack中是如何实现loader从而更深层次的理解loader核心内容与loader的设计哲学吧!

写在源码分析之前

webpack中的loader机制就独立出来成为了一个loader-runner.js,所以相对于loader处理的逻辑和webpack没有过多的耦合比较清晰。

首先,源码分析对于大多数人来说都觉得枯燥无趣,这里我会尽量简化步骤手把手带大家实现一款loader-runner库。

文章中我想给大家强调的是一个源码流程,而非和真实源码一模一样。这样做的好处是简化了很多边界条件的处理可以更加快速、方便的带大家去掌握loader背后的设计哲学。

但是并不是说我们实现的loader-runner并不是源码,我们会在源码的基础上进行分析省略它的冗余步骤并且提出对于源码中部分写法我自己的优化点。

(毕竟是我一下一下debugger得到的通俗易懂的版本了

0 人点赞