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

6,224 阅读43分钟

写在前边

日益繁杂的前端工程化中,围绕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配置文件:

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的顺序,比如这样一份配置文件:

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执行顺序为:

image.png

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

webpack中配置loader的三种方式

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

// webpack.config.js
module.exports = {
    ...
    module: {
        rules: [
            {
                test:/\.js$/,
                loader: 'babel-loader'
            }
        ]
    }
}

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

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

绝对路径

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

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方式进行配置,比如:

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时应该查找的目录,比如:

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, 比如我们在引用资源文件时:

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)

    import Styles from '!style-loader!css-loader?modules!./styles.css';
    
  • 使用 !! 前缀,将禁用所有已配置的 loader(preLoader, loader, postLoader)

    import Styles from '!!style-loader!css-loader?modules!./styles.css';
    
  • 使用 -! 前缀,将禁用所有已配置的 preLoader 和 loader,但是不禁用 postLoaders

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

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

Loader的执行顺序

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

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

image.png

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阶段。

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

// 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',
      },
    ],
  },
};
// 入口文件中
import something from 'inline1-loader!inline2-loader!./title.js';

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

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

image.png

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本质上也是一个函数,比如:

function loader() {
    // 正常的loader执行阶段...
}
loader.pitch = function () {
    // pitch loader
}

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

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

// inline1-loader normal
function inline1Loader () {
    // dosomething
}
// inline1-loader pitch
inline1Loader.pitch = function () {
    // do something
    return '19Qingfeng'
}

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

image.png

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

image.png

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

Loader开发相关API

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

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

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

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

function loader() {
    // ...
}

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

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

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

同步 or 异步loader

同步Loader

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

// 同步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后才会继续执行。

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

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

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进行编译。

// source为需要处理的源文件内容 
function loader(source) {
    // ...
    // 同时返回本次处理后的内容
    return source + 'hello !'
}

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

关于loader中的this被称作上下文对象,更多的属性你可以在这里看到

image.png

Pitch Loader

// normal loader
function loader(source) {
    // ...
    return source
}

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

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

  • remainingRequest
  • previousRequest
  • data
remainingRequest

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

image.png

同样我们在上边的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来举例:

image.png

  • 当我们在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类型。

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得到的通俗易懂的版本了😼)

前期准备

在进入loader-runner分析之前,我们让我们先来看一看webpack中是在哪里进入这个模块调用~

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

简单来说就是在每一个模块module通过webpack编译前都会首先根据对应文件后缀寻找匹配到对应的loader,先调用loader处理资源文件从而将处理后的结果交给webpack进行编译。

image.png

webpack中的_doBuild函数中调用了runLoaders方法,而runLoaders方法正是来自于loader-runner库。

简单来说webpack在进行模块编译时会调用_doBuild,在doBuild方法内部通过调用runLoaders方法调用loader处理模块。

runLoader参数

在真正进入runLoader方法前我们先来看一看runLoader方法传入的四个参数。

这里为了大家更加单纯的理解loader,我们单独将这四个参数拿出来并不会尽量做到和webpack编译过程解耦。

  • resource: resource参数表示需要加载的资源路径。

  • loaders: 表示需要处理的loader绝对路径拼接成为的字符串,以!分割。

  • context: loader上下文对象,webpack会在进入loader前创建一系列属性挂载在一个object上,而后传递给loader内部。

比如我们上边说到的this.getOptions()方法获得loader的配置options参数就是在进入runLoader函数前webpackgetOptions方法定义在了loaderContext对象中传递给context参数。

这里你可以理解这个参数就是loader的上下文对象,简单来说就是我们使用loader函数中的this,为了让大家更好的理解loader-runner,这里我们并不会涉及webpack对于context对象的处理。

  • processResource: 读取资源文件的方法。

同样源码中的processResource涉及了很多plugin hooks相关逻辑,这里你可以简单理解为它是一个fs.readFile方法就可以。本质上这个参数的核心就是通过processResource方法按照路径来读取我们的资源文件。

构造场景

正所谓磨刀不费砍柴功,在了解了webpack源码中调用loader函数时传递了四个参数之后,接下来让我们来模拟一下这四个参数吧。

目录构建

首先让我们先创建一个名为loader-runner的文件夹,其次在loader-runner下我们分别创建一个loader-runner/loaders文件夹和loader-runner/index.js两个文件分别存放我们的loader和对应的模拟入口文件: 同时让我们在loader-runner下创建一个title.js,它存放需要loader编译的js文件内容:

// title.js
require('inline1-loader!inline2-loader!./title.js');

此时我们应该拥有了这样的目录:

image.png

创建Loader

接下里让我们来给loader-runner/loaders下补充一些loader吧,这里我为loader-runner/loaders创建了8个loader:

image.png

他们的内容非常简单,而且都是类似的。比如inline1-loader中:

// 每一个loader文件中都存在对应的 normal loader和 pitch loader
// normal loader中打印一句 文件名: normal 和 对应的接受参数
// pitch loader 中打印一句 文件名 pitch
function loader(source) {
  console.log('inline1: normal', source);
  return source + '//inline1';
}

loader.pitch = function () {
  console.log('inline1 pitch');
};

module.exports = loader;

同理post1-loader中:

function loader(source) {
  console.log('post1: normal', source);
  return source + '//post1';
}

loader.pitch = function () {
  console.log('post2 pitch');
};

module.exports = loader;

剩下的文件具体内容都是一样的,请小伙伴们自己动手创建一下~

强烈小伙伴们建议跟着文章一步一步来敲一下。

此时我们就已经创建好了这8个我们需要使用到的loader了。

创建入口文件

上边我们说到过在webpack源码中是通过compilation编译时调用_doBild方法时创造参数并且调用runLoaders方法。

这里我们为了和webpack构建流程尽量解耦,所以我们先动手自己构建一下runLoaders函数需要的参数。

// loader-runner/index.js
// 入口文件
const fs = require('fs');
const path = require('path');
const { runLoaders } = require('loader-runner');

// 模块路径
const filePath = path.resolve(__dirname, './title.js');

// 模拟模块内容和.title.js一模一样的内容
const request = 'inline1-loader!inline2-loader!./title.js';

// 模拟webpack配置
const 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'],
  },
];

// 从文件引入路径中提取inline loader 同时将文件路径中的 -!、!!、! 等标志inline-loader的规则删除掉
const parts = request.replace(/^-?!+/, '').split('!');

// 获取文件路径
const sourcePath = parts.pop();

// 获取inlineLoader
const inlineLoaders = parts;

// 处理rules中的loader规则
const preLoaders = [],
  normalLoaders = [],
  postLoaders = [];

rules.forEach((rule) => {
  // 如果匹配情况下
  if (rule.test.test(sourcePath)) {
    switch (rule.enforce) {
      case 'pre':
        preLoaders.push(...rule.use);
        break;
      case 'post':
        postLoaders.push(...rule.use);
        break;
      default:
        normalLoaders.push(...rule.use);
        break;
    }
  }
});

/**
 * 根据inlineLoader的规则过滤需要的loader
 * https://webpack.js.org/concepts/loaders/
 * !: 单个!开头,排除所有normal-loader.
 * !!: 两个!!开头 仅剩余 inline-loader 排除所有(pre,normal,post).
 * -!: -!开头将会禁用所有pre、normal类型的loader,剩余post和normal类型的.
 */
let loaders = [];
if (request.startsWith('!!')) {
  loaders.push(...inlineLoaders);
} else if (request.startsWith('-!')) {
  loaders.push(...postLoaders, ...inlineLoaders);
} else if (request.startsWith('!')) {
  loaders.push(...postLoaders, ...inlineLoaders, ...preLoaders);
} else {
  loaders.push(
    ...[...postLoaders, ...inlineLoaders, ...normalLoaders, ...preLoaders]
  );
}

// 将loader转化为loader所在文件路径
// webpack下默认是针对于配置中的resolveLoader的路径进行解析 这里为了模拟我们省略了webpack中的路径解析
const resolveLoader = (loader) => path.resolve(__dirname, './loaders', loader);

// 获得需要处理的loaders路径
loaders = loaders.map(resolveLoader);

runLoaders(
  {
    resource: filePath, // 加载的模块路径
    loaders, // 需要处理的loader数组
    context: { name: '19Qingfeng' }, // 传递的上下文对象
    readResource: fs.readFile.bind(fs), // 读取文件的方法
    // processResource 参数先忽略
  },
  (error, result) => {
    console.log(error, '存在的错误');
    console.log(result, '结果');
  }
);

这里我们通过loader-runner/index.js来模拟webpack编译过程中传递给loader-runnerrunLoader的参数。

这里有几点需要大家注意:

  • filePathtitle.js的模块路径,换而言之我们就是通过loader来处理这个title.js文件。

  • request是我们模拟title.js中的内容,它其实和title.js文件内容是一模一样的,这里我们为了方便模拟webpack解析loader的处理规则所以直接将title.js的文件内容放在了request字符串中。

  • 这里我们给runLoaders中第一参数对应的属性分别是:

    • resource表示需要loader编译的模块路径。

    • loaders表示本次loader处理,需要有哪些loader进行处理。(它是一个所有需要处理的loader文件路径组成的数组)

    • context表示loader的上下文对象,真实源码中webpack会在进入runLoaders方法前对这个对象进行额外加工,这里我们不做过多处理,它就是loader中的this上下文。

    • readResource这个参数表示runLoaders方法中会以我们传入的这个参数去读取resource需要加载的文件路径从而得到文件内容。

  • runLoaders函数第二个参数传入的是一个callback表示本次loader处理完成的结果。

这里传入的loaders参数的顺序是我刻意而为之的。是按照pitch阶段的执行顺序来处理的: post -> inline -> normal -> pre

这里我们在上边的代码中调用了原版的loader-runner处理,让我们一起先来看看原版的结果吧。

image.png

我们可以看到原版loader-runnerrunLoaders我们传入的第二个参数callback在处理完成后会得到两个参数。

  • error: 如果runLoaders函数执行过程中遇到错误那么这个参数将会变成错误内容,否则将为null

  • result: 如果runLoaders函数执行完毕并且没有存在任何错误,那么这个result将会存在以下属性:

    • result:它是一个数组用来表示本次经过所有loaders处理完毕后的文件内容
    • resourceBuffer: 它是一个Buffer内容,这个内容表示我们的资源文件原始内容转化为Buffer的结果。
    • 其他参数是关于webpack构建与缓存时的参数,这里我们可以先不用关系这些参数。

流程梳理

在了解到原版的runLoaders方法接受的参数以及返回的结果之后,接下来让我们来实现自己的runLoaders方法吧。

首先,让我们先在一步一步先从创建目录开始吧!

image.png

这里我们在loader-runner目录下创建了一个core/index.js作为我们将要实现的loader-runner模块。

创建好目录之后让我们再来回顾一下这张图吧:

image.png

这里我想给大家强调的是整个runLoaders函数的核心逻辑就是接受待处理的资源文件路径,根据传入的Loaders首先经过pitch阶段读取资源文件内容再经过normal阶段处理资源文件内容最终得到返回结果。

把握这样一个核心执行过程,剩下的一些边界情况的处理我相信对于大家来说都是litter case

接下来让我们正式进入runLoaders方法的实现。

进入源码

创建loaders对象

// loader-runner
const fs = require('fs')

function runLoaders(options, callback) {
  // 需要处理的资源绝对路径
  const resource = options.resource || ''
  // 需要处理的所有loaders 组成的绝对路径数组
  let loaders = options.loaders || []
  // loader执行上下文对象 每个loader中的this就会指向这个loaderContext
  const context = options.context || {}
  // 读取资源内容的方法
  const readResource = options.readResource || fs.readFile.bind(fs);
  // 根据loaders路径数组创建loaders对象
  loaders = loaders.map(createLoaderObject);
}

首先第一步我们在runLoaders函数中保存了外部传入的options中的参数,细心的同学可能已经发现了我们通过createLoaderObject方法对传入的loaders进行map处理。

createLoaderObject方法做的事情很简单,其实就是将原本loaders路径数组中每个路径修改成为了一个对象,让我们来实现一下这个方法吧:

/**
 *
 * 通过loader的绝对路径地址创建loader对象
 * @param {*} loader loader的绝对路径地址
 */
function createLoaderObject(loader) {
  const obj = {
    normal: null, // loader normal 函数本身
    pitch: null, // loader pitch 函数
    raw: null, // 表示normal loader处理文件内容时 是否需要将内容转为buffer对象
    // pitch阶段通过给data赋值 normal阶段通过this.data取值 用来保存传递的data
    data: null,
    pitchExecuted: false, // 标记这个loader的pitch函数时候已经执行过
    normalExecuted: false, // 表示这个loader的normal阶段是否已经执行过
    request: loader, // 保存当前loader资源绝对路径
  };
  // 按照路径加载loader模块 真实源码中通过loadLoader加载还支持ESM模块 咱们这里仅仅支持CJS语法
  const normalLoader = require(obj.request);
  // 赋值
  obj.normal = normalLoader;
  obj.pitch = normalLoader.pitch;
  // 转化时需要buffer/string   raw为true时为buffer false时为string
  obj.raw = normalLoader.raw;
  return obj;
}

这里我们通过createLoaderObjectloader的绝对路径地址转化成为了loader对象,并且赋值了一些核心的属性normalpitchrawdata等等。

赋值loaderContext,处理上下文对象

在通过createLoaderObject函数将路径转化为loader对象后,让我们回到runLoaders函数中,让我们紧跟着来处理loader中的上下文对象loaderContext:

function runLoaders(options, callback) {
  // 需要处理的资源绝对路径
  const resource = options.resource || '';
  // 需要处理的所有loaders 组成的绝对路径数组
  let loaders = options.loaders || [];
  // loader执行上下文对象 每个loader中的this就会指向这个loaderContext
  const loaderContext = options.context || {};
  // 读取资源内容的方法
  const readResource = options.readResource || fs.readFile.bind(fs);
  // 根据loaders路径数组创建loaders对象
  loader = loader.map(createLoaderObject);
  // 处理loaderContext 也就是loader中的this对象
  loaderContext.resourcePath = resource; // 资源路径绝对地址 
  loaderContext.readResource = readResource; // 读取资源文件的方法
  loaderContext.loaderIndex = 0; // 我们通过loaderIndex来执行对应的loader
  loaderContext.loaders = loaders; // 所有的loader对象
  loaderContext.data = null;
  // 标志异步loader的对象属性
  loaderContext.async = null;
  loaderContext.callback = null;
  // request 保存所有loader路径和资源路径
  // 这里我们将它全部转化为inline-loader的形式(字符串拼接的"!"分割的形式)
  // 注意同时在结尾拼接了资源路径哦~
  Object.defineProperty(loaderContext, 'request', {
    enumerable: true,
    get: function () {
      return loaderContext.loaders
        .map((l) => l.request)
        .concat(loaderContext.resourcePath || '')
        .join('!');
    },
  });
  // 保存剩下的请求 不包含自身(以LoaderIndex分界) 包含资源路径
  Object.defineProperty(loaderContext, 'remainingRequest', {
    enumerable: true,
    get: function () {
      return loaderContext.loaders
        .slice(loaderContext + 1)
        .map((i) => i.request)
        .concat(loaderContext.resourcePath)
        .join('!');
    },
  });
  // 保存剩下的请求,包含自身也包含资源路径
  Object.defineProperty(loaderContext, 'currentRequest', {
    enumerable: true,
    get: function () {
      return loaderContext.loaders
        .slice(loaderContext)
        .map((l) => l.request)
        .concat(loaderContext.resourcePath)
        .join('!');
    },
  });
  // 已经处理过的loader请求 不包含自身 不包含资源路径
  Object.defineProperty(loaderContext, 'previousRequest', {
    enumerable: true,
    get: function () {
      return loaderContext.loaders
        .slice(0, loaderContext.index)
        .map((l) => l.request)
        .join('!');
    },
  });
  // 通过代理保存pitch存储的值 pitch方法中的第三个参数可以修改 通过normal中的this.data可以获得对应loader的pitch方法操作的data
  Object.defineProperty(loaderContext, 'data', {
    enumerable: true,
    get: function () {
      return loaderContext.loaders[loaderContext.loaderIndex].data;
    },
  });
}

这里我们为loaderIndex上下文对象上定义了一系列属性,比如其中我们通过loaderIndex控制当前loaders列表中,当前执行到第几个loader以及当前dataasynccallback等等属性。

其实这里我相信大家对这些参数属性是不是有一种似曾相识的感觉,之前我们说到过在loader中我们通过this上下文对象灵活的对于loader进行配置,这里我们定义的loaderContext恰恰正是this对象。

关于上下文对象这些参数的含义,我们在上文的loader开发阶段已经大概讲过对应API的用法和含义, 如果忘记的同学可以回到上边再次温习温习。

这里我们仅仅对于这些属性进行了定义,同时我希望在看到目前定义属性时同学们可以联想到每个API相应的用法。

定义完成loaderContext属性后,让我们再次回到runLoaders方法中:

function runLoaders(options, callback) {
  ...


  // 用来存储读取资源文件的二进制内容 (转化前的原始文件内容)
  const processOptions = {
    resourceBuffer: null,
  };
  // 处理完loaders对象和loaderContext上下文对象后
  // 根据流程我们需要开始迭代loaders--从pitch阶段开始迭代
  // 按照 post-inline-normal-pre 顺序迭代pitch
  iteratePitchingLoaders(processOptions, loaderContext, (err, result) => {
    callback(err, {
      result,
      resourceBuffer: processOptions.resourceBuffer,
    });
  });
}

让我们稍微回顾一下runLoaders传入的calback回调方法中result的打印值: image.png

这里我们定义的processOptions中的resourceBuffer正是result中的resourceBuffer原始(未经loader处理)的资源文件内容的Buffer对象。

iteratePitchingLoaders

在创建loader对象、赋值loaderContext属性后,按照之前的流程图。我们就要进入每一个loaderpitch执行阶段。

上边我们定义了iteratePitchingLoaders函数,并且为他传入了三个参数:

  • processOptions: 我们上述定义的对象,它存在一个resourceBuffer属性用来保存未经过loader处理前Buffer类型的资源文件内容。

  • loaderContext: loader上下文对象。

  • callback: 这个方法内部调用了runLoaders方法外部传入的callback,用来在回调函数中调用最终的runLoaders方法的结果。

了解了传入的参数后,让我们一起来看看iteratePitchingLoaders的实现吧。

/**
 * 迭代pitch-loaders
 * 核心思路: 执行第一个loader的pitch 依次迭代 如果到了最后一个结束 就开始读取文件
 * @param {*} options processOptions对象
 * @param {*} loaderContext loader中的this对象
 * @param {*} callback runLoaders中的callback函数
 */
function iteratePitchingLoaders(options, loaderContext, callback) {
  // 超出loader个数 表示所有pitch已经结束 那么此时需要开始读取资源文件内容
  if (loaderContext.loaderIndex >= loaderContext.loaders.length) {
    return processResource(options, loaderContext, callback);
  }

  const currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];

  // 当前loader的pitch已经执行过了 继续递归执行下一个
  if (currentLoaderObject.pitchExecuted) {
    loaderContext.loaderIndex++;
    return iteratePitchingLoaders(options, loaderContext, callback);
  }
  
  const pitchFunction = currentLoaderObject.pitch;

  // 标记当前loader pitch已经执行过
  currentLoaderObject.pitchExecuted = true;

  // 如果当前loader不存在pitch阶段
  if (!currentLoaderObject.pitch) {
    return iteratePitchingLoaders(options, loaderContext, callback);
  }

  // 存在pitch阶段 并且当前pitch loader也未执行过 调用loader的pitch函数
  runSyncOrAsync(
    pitchFunction,
    loaderContext,
    [
      currentLoaderObject.remainingRequest,
      currentLoaderObject.previousRequest,
      currentLoaderObject.data,
    ],
    function (err, ...args) {
      if (err) {
        // 存在错误直接调用callback 表示runLoaders执行完毕
        return callback(err);
      }
      // 根据返回值 判断是否需要熔断 or 继续往下执行下一个pitch
      // pitch函数存在返回值 -> 进行熔断 掉头执行normal-loader
      // pitch函数不存在返回值 -> 继续迭代下一个 iteratePitchLoader
      const hasArg = args.some((i) => i !== undefined);
      if (hasArg) {
        loaderContext.loaderIndex--;
        // 熔断 直接返回调用normal-loader
        iterateNormalLoaders(options, loaderContext, args, callback);
      } else {
        // 这个pitch-loader执行完毕后 继续调用下一个loader
        iteratePitchingLoaders(options, loaderContext, callback);
      }
    }
  );
}

我带大家稍微来看看iteratePitchingLoaders这个方法,它做的事情非常简单,** 本质上就是通过loaderContext.loaderIndex来递归迭代每一个loader对象的pitch方法。

这里有几点需要给大家提示下:

  • processResource方法是读取资源文件内容的方法,按照上文流程图中的步骤当所有pitch执行完毕后我们需要读取资源文件内容了。
  • runSyncOrAsync方法是执行调用loader函数的方法,loader的执行有两种方式同步/异步,这里正是通过这个方法进行的统一处理。
  • iterateNormalLoaders方法是迭代normal loader的方法。

上述这三个方法的实现细节这里你可以不用关心,只需要清楚iteratePitchingLoaders的流程就可以。

  • 需要额外注意的是,我们在模拟入口文件中传入的loader顺序是按照[...post,...inline,...normal,...pre]的传入的。所以内部我们通过loaderContext.loaderIndex从下标0开始迭代正好符合pitch阶段。

runSyncOrAsync

了解了iteratePitchingLoaders是如何迭代pitch loader,我们一起来看看运行loader的方法runSyncOrAsync:

/**
 *
 * 执行loader 同步/异步
 * @param {*} fn 需要被执行的函数
 * @param {*} context loader的上下文对象
 * @param {*} args [remainingRequest,previousRequest,currentLoaderObj.data = {}]
 * @param {*} callback 外部传入的callback (runLoaders方法的形参)
 */
function runSyncOrAsync(fn, context, args, callback) {
  // 是否同步 默认同步loader 表示当前loader执行完自动依次迭代执行
  let isSync = true;
  // 表示传入的fn是否已经执行过了 用来标记重复执行
  let isDone = false;

  // 定义 this.callback
  // 同时this.async 通过闭包访问调用innerCallback 表示异步loader执行完毕
  const innerCallback = (context.callback = function () {
    isDone = true;
    // 当调用this.callback时 标记不走loader函数的return了
    isSync = false;
    callback(null, ...arguments);
  });

  // 定义异步 this.async
  // 每次loader调用都会执行runSyncOrAsync都会重新定义一个context.async方法
  context.async = function () {
    isSync = false; // 将本次同步变更成为异步
    return innerCallback;
  };

  // 调用pitch-loader执行 将this传递成为loaderContext 同时传递三个参数
  // 返回pitch函数的返回值 甄别是否进行熔断
  const result = fn.apply(context, args);

  if (isSync) {
    isDone = true;
    if (result === undefined) {
      return callback();
    }
    // 如果 loader返回的是一个Promise 异步loader
    if (
      result &&
      typeof result === 'object' &&
      typeof result.then === 'function'
    ) {
      // 同样等待Promise结束后直接熔断 否则Reject 直接callback错误
      return result.then((r) => callback(null, r), callback);
    }
    // 非Promise 切存在执行结果 进行熔断
    return callback(null, result);
  }
}

runSyncOrAsync接受四个参数,分别是

  • fn需要被调用的函数
  • context 被调用的fn函数内部的this指针
  • args 被调用函数的fn传入的参数
  • callback 用来表示loader(fn)执行完毕后调用的回调函数。

它的实现很简单,内容通过闭包结合isSync变量实现异步this.async/this.callback这两个loader API的实现。

最终,loader执行完毕runSyncOrAsync方法会将loader执行完毕的返回值传递给callback函数的第二个参数。

实现了runSyncOrAsync,了解如何执行loader之后让我们回过头分析iteratePitchingLoaders中的runSyncOrAsync方法。

iteratePitchingLoaders函数中我们通过runSyncOrAsync去执行对应pitch loader,分别传入了这四个参数:

  • pitchFunction作为需要执行的fn

  • loaderContext表示pitch loader函数中的this上下文对象。

  • [currentLoaderObject.remainingRequest,currentLoaderObject.previousRequest,currentLoaderObject.data]。上文我们说到过pitch loader函数会接受三个参数分别是剩下的laoder请求, 已经处理过的loader请求以及作为传递给normal阶段的data

  • 第四个参数是一个回调函数,它表示pitch loader函数执行完毕后这个callback会被调用,如果pitch loader存在返回值那么它的第二个参数则会接受到pitch loader执行后的返回值。

这里我想和大家强调一下iteratePitchingLoaders中调用runSyncOrAsync执行loader时候传入的第四个callback函数:

  runSyncOrAsync(
    pitchFunction,
    loaderContext,
    [
      currentLoaderObject.remainingRequest,
      currentLoaderObject.previousRequest,
      currentLoaderObject.data,
    ],
    function (err, ...args) {
      if (err) {
        // 存在错误直接调用callback 表示runLoaders执行完毕
        return callback(err);
      }
      // 根据返回值 判断是否需要熔断 or 继续往下执行下一个pitch
      // pitch函数存在返回值 -> 进行熔断 掉头执行normal-loader
      // pitch函数不存在返回值 -> 继续迭代下一个 iteratePitchLoader
      const hasArg = args.some((i) => i !== undefined);
      if (hasArg) {
        loaderContext.loaderIndex--;
        // 熔断 直接返回调用normal-loader
        iterateNormalLoaders(options, loaderContext, args, callback);
      } else {
        // 这个pitch-loader执行完毕后 继续调用下一个loader
        iteratePitchingLoaders(options, loaderContext, callback);
      }
    }
  );

上边我们提到过第四个参数会在pitch loader函数执行完毕后 or 报错后callback会被调用。

如果存在错误,那么直接调用runLoaders传入的callback(err)

如果不存在错误,这里我们对于除开第一个表示错误的参数剩余参数做了判断我们知道这个参数表示loader执行完毕的返回值,让我们再来回顾一下pitch阶段的流程图:

image.png

任何一个loaderpitch阶段如何返回了非undefined的任何值,那么此时loader将会发生熔断的效果:立即掉头执行normal loader并且将pitch阶段的返回值传递给normal loader

所以这里我们通过callback中判断,如果args中存在任何一个非undefined的返回值。那么此时将loaderContext.loaderIndex递减,从而开始迭代normal loader

如果pitch loader运行结束后不存在返回值或者说返回的是undefeind,那么此时继续递归调用下一个pitch loader

针对于pitch loader的大致流程我们在这里就告一段落了,如果同学们还有疑问可以在重新翻看一些或者在评论区来一起讨论。其实在把握loader执行过程之后,单独代码逻辑来说我相信对于大家理解起来都不是很难,这也就是为什么前边我会花很多篇幅去讲诉loader的基础用法。

processResource

结束了iteratePitchingLoaders之后,迭代完成所有的pitch loader之后。下一步是什么呢?

如果忘记的同学建议一定要去翻看一下loader的执行流程图,此时应该是读取资源文件内容了,也就是我们上边没有完成的processResource方法。

这个方法做的事情非常简单:

  • 按照传入的方法读取文件内容,同时将得到的文件Buffer类型的内容保存进入processOptions.resourceBuffer中去。
  • 拿到文件内容后,将文件内容传入normal loader之后执行iterateNormalLoaders迭代执行normal loader
/**
 *
 * 读取文件方法
 * @param {*} options
 * @param {*} loaderContext
 * @param {*} callback
 */
function processResource(options, loaderContext, callback) {
  // 重置越界的 loaderContext.loaderIndex
  // 达到倒叙执行 pre -> normal -> inline -> post
  loaderContext.loaderIndex = loaderContext.loaders.length - 1;
  const resource = loaderContext.resourcePath;
  // 读取文件内容
  loaderContext.readResource(resource, (err, buffer) => {
    if (err) {
      return callback(err);
    }
    // 保存原始文件内容的buffer 相当于processOptions.resourceBuffer = buffer
    options.resourceBuffer = buffer;
    // 同时将读取到的文件内容传入iterateNormalLoaders 进行迭代`normal loader`
    iterateNormalLoaders(options, loaderContext, [buffer], callback);
  });
}

它的代码其实比较容易理解,这里需要注意的有三点:

  1. loaderIndex在迭代pitch loader中越界了(也就是等于loaderContext.loaders.length)时才会进入processResource方法所以此时我们将loaderContext.loaderIndex重置为loaderContext.loaders.lenth -1

  2. iterateNormalLoaders额外传入了一个表示资源文件内容[buffer]的数组,这是刻意而为之,这里我先买个关子,后续你会发现我为什么这么做。

  3. 还记得我们在loaderContext.loaders中保存的loaders顺序吗,它是按照post -> inline -> normal -> pre的顺序保存的的,所以此时只要我们按照loaderIndex逆序去迭代,就可以得到normal loader的顺序

iterateNormalLoaders

完成了processResource读取文件内容之后,在processResource以及iteratePitchingLoaders方法中我们都用到的iterateNormalLoaders--迭代normal loader的函数还未实现。

接下来让我们先来看看这个函数的实现吧:

/**
 * 迭代normal-loaders 根据loaderIndex的值进行迭代
 * 核心思路: 迭代完成pitch-loader之后 读取文件 迭代执行normal-loader
 *          或者在pitch-loader中存在返回值 熔断执行normal-loader
 * @param {*} options processOptions对象
 * @param {*} loaderContext loader中的this对象
 * @param {*} args [buffer/any]
 * 当pitch阶段不存在返回值时 此时为即将处理的资源文件
 * 当pitch阶段存在返回值时 此时为pitch阶段的返回值
 * @param {*} callback runLoaders中的callback函数
 */
function iterateNormalLoaders(options, loaderContext, args, callback) {
  // 越界元素判断 越界表示所有normal loader处理完毕 直接调用callback返回
  if (loaderContext.loaderIndex < 0) {
    return callback(null, args);
  }
  const currentLoader = loaderContext.loaders[loaderContext.loaderIndex];
  if (currentLoader.normalExecuted) {
    loaderContext.loaderIndex--;
    return iterateNormalLoaders(options, loaderContext, args, callback);
  }

  const normalFunction = currentLoader.normal;
  // 标记为执行过
  currentLoader.normalExecuted = true;
  // 检查是否执行过
  if (!normalFunction) {
    return iterateNormalLoaders(options, loaderContext, args, callback);
  }
  // 根据loader中raw的值 格式化source
  convertArgs(args, currentLoader.raw);
  // 执行loader
  runSyncOrAsync(normalFunction, loaderContext, args, (err, ...args) => {
    if (err) {
      return callback(err);
    }
    // 继续迭代 注意这里的args是处理过后的args
    iterateNormalLoaders(options, loaderContext, args, callback);
  });
}

其实仔细阅读iterateNormalLoaders的代码它和iteratePitchingLoaders存在异曲同工之妙,它们的核心都是基于loaderContext.loaderIndex下标进行迭代loaders对象分别运行对应的pitch或者normal函数

只不过不同的是iteratePitchingLoaders仅仅接受三个参数,iterateNormalLoaders额外接受一个args参数表示资源文件对象的[Buffer](或者发生熔断时pitch loader的返回值)。这是由于pitch loader被调用时的参数可以通过loaderContext来获取(remainingRequest属性等),而normal loader的参数需要一层一层将处理后的内容传递下去。

同时为什么我们在上边将文件内容处理成数组[Buffer],正是因为传递给runSyncOrAsync方法时第三个参数是一个数组(表示调用loader函数时传递给loader函数的参数),因为通过apply进行调用所以统一处理为数组会让代码更加方便简洁。

细心的同学会发现在iterateNormalLoaders中有一个convertArgs函数的调用,我们先来看一看这个函数的内容:

/**
 *
 * 转化资源source的格式
 * @param {*} args [资源]
 * @param {*} raw Boolean 是否需要Buffer
 * raw为true 表示需要一个Buffer
 * raw为false表示不需要Buffer
 */
function convertArgs(args, raw) {
  if (!raw && Buffer.isBuffer(args[0])) {
    // 我不需要buffer
    args[0] = args[0].toString();
  } else if (raw && typeof args[0] === 'string') {
    // 需要Buffer 资源文件是string类型 转化称为Buffer
    args[0] = Buffer.from(args[0], 'utf8');
  }
}

convertArgs方法根据loaderraw属性判断这个loadernormal阶段需要接受资源文件内容是Buffer还是String

上边我们讲到过每个loader都存在一个raw属性,通过loader.raw标记normal loader的参数是Buffer还是String。这个方法正是在normal loader执行前对于参数进行转化处理的。

大功告成

此时针对于loader-runnerrunLoaders方法的核心逻辑我们已经全部实现了。

验证执行结果

让我们现在loader-runner/core/index.js中导出这个方法,同时在模拟入口文件loader-runner/index.js中替换为我们自己的模块:

// loader-runner/core/index.js

...

module.exports = {
    runLoaders
}

// loader-runner/index.js
// 入口文件
const fs = require('fs');
const path = require('path');
const { runLoaders } = require('./core/index');

...


runLoaders(
  {
    resource: filePath, // 加载的模块路径
    loaders, // 需要处理的loader数组
    context: { name: '19Qingfeng' }, // 传递的上下文对象
    readResource: fs.readFile.bind(fs), // 读取文件的方法
    // processResource 参数先忽略
  },
  (error, result) => {
    console.log(error, '存在的错误');
    console.log(result, '结果');
  }
);

接来下让我们执行我们的loader-runner/index.js:

image.png

我们打印出来的callback中的result形参对象中的:

  • result属性的值是一个数组,它的第一个元素是经过我们所有loader处理后的文件内容。

  • resourceBuffer属性的值是一个Buffer,它的内容是原始未经转化后的资源文件内容,有兴趣的朋友可以自己toString()一下看看。

至此我们正常阶段的执行结果和原始的runLoaders方法基本已经一模一样了。

熔断效果验证

此时,让我们再来验证一下熔断效果。

首先让我们修改loader-runner/loaders/inline2-loader,我们在inline2-loaderpitch函数中返回一个字符串:

function loader(source) {
  console.log('inline2: normal', source);
  return source + '//inline2';
}

loader.pitch = function () {
  console.log('inline2 pitch');
  return '19Qingfeng';
};

module.exports = loader;

再次执行我们的loader-runner/core/index.js:

image.png

正如我们期望的那样~一切正常!

至此,runLoaders的核心源码我已经带大家全部实现了。

我相信代码本身并不是很难理解,源码阅读本身就需要一定的耐心,如果大家对于代码有任何疑问或者对于原始源码有任何疑问欢迎大家在评论区骚扰我~

真心感谢每一位看到这里的朋友,对于loader源码的学习我希望大家可以以此为起点可以在后续更加深入的探索并着手于优化整个流程体系。(毕竟说实话runLoaders方法内部一些地方写的还是比较糙的嘛🐶)。

文章中的完整代码地址你可以在这里看到。

企业级Loader应用

在精进了源码部分的知识体系之后,让我们来聊一些比较轻松的话题吧。

接下来让我们手把手来实现一款企业级loader应用,带领大家真正精通并掌握开源工具中loader开发者的思想。

babel-loader流程

老样子~首先让我们来梳理一下关于需要开发babel-loader的流程吧。

image.png

让我们一起先来根据这张图来梳理一下。

所谓babel-loader实现的功能特别简单,本质上就是通过babel-loader函数以及对应配置loader时的参数将我们的js文件进行转化, 简单来说这就是babel-loader需要实现的功能。

当然在经过babel-loader处理后的内容后续还需要交给webapck进行编译。

babel-loader实现

const core = require('@babel/core');

/**
 *
 * @param {*} source 源代码内容
 */
function babelLoader(source) {
  // 获取loader参数
  const options = this.getOptions() || {};
  // 通过transform方法进行转化
  const { code, map, ast } = core.transform(source, options);
  // 调用this.callback表示loader执行完毕
  // 同时传递多个参数给下一个loader
  this.callback(null, code, map, ast);
}

module.exports = babelLoader;

看起来很简单吧,这里有一些注意点需要和大家阐述下:

这里我们通过core.transform将源js代码进行ast转化同时通过外部传递的options选项处理ast节点的转化,从而按照外部传入规则将js代码转化为转化后的代码。

  • 这里我们通过this.getOptions方法获得外部loader传递的参数。

webpack5中获取loader的方法在调用runLoaders方法时webpack已经在loaderContext中添加了这个getOptions的实现,从而调用runLoaders方法时传入了处理好的loaderContext参数。

webpack5之前并不存在this.getOptions方法,需要额外通过loader-utils这个包实现获取外部loader配置参数。

这个方法的实现非常简单,在源码中的webpack/lib/NormalModule.js中,有兴趣的朋友可以自行翻看。

接下来就让我们稍微来验证一下吧。

验证babel-loader

首先让我们重新搭建一个开发目录webpack-babel文件夹:

image.png

  • loaders/babel-loaders目录下存放我们自己定义的babel-loader

  • src/index.js存放入口文件。

  • webpack.config.jswebpack配置文件。

// src/index.js
// 这里我们使用ES6的语法
const arrowFunction = () => {
  console.log('hello');
};

console.log(arrowFunction);
// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].js',
  },
  devtool: 'eval-source-map',
  resolveLoader: {
    modules: [path.resolve(__dirname, './loaders')],
  },
  module: {
    rules: [
      {
        test: /\.js/,
        loader: 'babel-loader',
        options: {
          presets: ['@babel/preset-env'],
        },
      },
    ],
  },
  plugins: [new HtmlWebpackPlugin()],
};
// package.json
{
    ...
    // 这里我们定义两个脚本 一个为开发环境下的dev
    // 一个为build打包命令
  "scripts": {
    "dev": "webpack serve --mode developmen",
    "build": "webpack --config ./webpack.config.js"
  },
}

不要忘记安装我们需要用到的包。

yarn add webpack webpack-cli @babel/core @babel/preset-env html-webpack-plugin webpack-dev-server

接下来让我们来一起运行yarn build来看一看打包后输出的js内容吧:

code1.png

可以看到在yarn build之后,生产环境打包后的代码原本的箭头函数已经转化成为了普通函数。也就是说我们自定义的babel-loader功能已经生效了。

sourcemap完善babel-loader

生产环境下的确是没有任何问题了,可是开发环境呢。这里我们尝试在src/index.jsdebugger看一下:

// src/index.js
const arrowFunction = () => {
  console.log('hello');
};

debugger;

console.log(arrowFunction);

此时我们执行yarn dev,打开生成的html页面进入debugger

image.png

debugger中的代码此时并不是我们真正的源码,是已经被babel转译后的代码,这对于日常开发来说无疑是一种灾难,这里我们的src/index.js的文件内容很简单只有一个箭头函数。可是当项目中代码越来越复杂,这种情况无疑对于我们进行debugger代码时是一种噩梦。

其实导致这个问题的原因很简单,在babel-loader编译阶段我们并没有携带任何sourceMap映射。而在webpack编译阶段即使开启了sourceMap映射此时也仅仅只能将webpack编译后的代码在debugger中映射到webpack处理前,也就是已经经历过babel-loader处理了

image.png

此时,我们需要做的仅仅是需要在babel-loader转化过程中添加对应的sourcemap返回交给webpack编译阶段时候携带babel-loader生成的sourcemap就可以达到我们期望的效果。

让我们来动手实现一下:

const core = require('@babel/core');

/**
 *
 * @param {*} source 源代码内容
 */
function babelLoader(source, sourceMap, meta) {
  // 获取loader参数
  const options = this.getOptions() || {};
  // 生成babel转译阶段的sourcemap
  options.sourceMaps = true;
  // 保存之前loader传递进入的sourceMap
  options.inputSourceMap = sourceMap;
  // 获得处理的资源文件名 babel生成sourcemap时候需要配置filename
  options.filename = this.request.split('!').pop().split('/').pop();
  // 通过transform方法进行转化
  const { code, map, ast } = core.transform(source, options);
  console.log(map, 'map');
  // 调用this.callback表示loader执行完毕
  // 同时传递多个参数给下一个loader
  // 将transform API生成的sourceMap 返回给下一个loader(或者webpack编译阶段)进行处理
  this.callback(null, code, map, ast);
}

module.exports = babelLoader;

这里我们在babeltranform方法上接受到了上一次loader传递过来的soureMap结果(如果有的话)。

同时调用options.sourceMaps告诉babel在转译时生成sourceMap

最终将生成的sourcethis.callback中返回。

此时我们就在我们的babel转译阶段也生成了sourcemap同时最终会经过loader-chain将生成的sourcemap传递给webpack

同时额外注意webpackdevtool的配置,如果关闭了sourceMap的话就看不到任何源代码信息~

接下来让我们再次运行yarn build打开浏览器看一下:

image.png

大功告成!至此我们的babel-loader开发就告一断落了。这里我希望通过这个小例子可以带领大家真正进入loader开发者的世界。

回到我们最开始的内容,loader本质上就是一个函数而已。只不过是我们通过loader chain将多个loader链接在一起按照一定顺序和规则去执行而已。

我相信阅读到这里的你,在了解了loader的基础和原理部分这样的小例子对于大家每个人来说都是信手拈来,其实真实开源loader中相比这里无非也就是多了一些参数校验和边界处理而已。

babel-loader的实现代码你可以在这里看到。

写在结尾

感谢每一位看到这里的小伙伴,关于webpack loader的分享文章到这里就要结束了。

希望大家以此为起点,在探索loader的路上越走越远!

其实源码并不是那么晦涩难懂,我相信runLoaders源码中的设计理念一定会对大家有所帮助,这也是为什么我会花很大篇幅去在源码分析的章节中的原因。我希望带给大家的不仅仅是关于如何实现webpack loader,更多的是我希望带领大家去掌握一种如何阅读源码的方式。

其实认真看到这里的小伙伴会发现runLoaders方法中有部分内容的确写的也是不过如此,还有很多优化的点嘛哈哈~

大家如果关于loader有任何疑问也可以在评论区留下你的问题,我们可以一起探讨~