【webpack进阶】你真的掌握了loader么?- loader十问

往期文章:javascript

1. loader 十问

在我学习webpack loader的过程当中,也阅读了网上不少相关文章,收获很多。可是大多都只介绍了loader的配置方式或者loader的编写方式,对其中参数、api及其余细节的介绍并不清晰。css

这里有一个「loader十问」,是我在阅读loader源码前心中的部分疑问:html

  1. webpack默认配置是在哪处理的,loader有什么默认配置么?
  2. webpack中有一个resolver的概念,用于解析模块文件的真实绝对路径,那么loader和普通模块的resolver使用的是同一个么?
  3. 咱们知道,除了config中的loader,还能够写inline的loader,那么inline loader和normal config loader执行的前后顺序是什么?
  4. 配置中的module.rules在webpack中是如何生效与实现的?
  5. webpack编译流程中loader是如何以及在什么时候发挥做用的?
  6. loader为何是自右向左执行的?
  7. 若是在某个pitch中返回值,具体会发生什么?
  8. 若是你写过loader,那么可能在loader function中用到了this,这里的this到底是什么,是webpack实例么?
  9. loader function中的this.data是如何实现的?
  10. 如何写一个异步loader,webpack又是如何实现loader的异步化的?

也许你也会有相似的疑问。下面我会结合loader相关的部分源码,为你们还原loader的设计与实现原理,解答这些疑惑。前端

2. loader运行的整体流程

webpack编译流程很是复杂,但其中涉及loader的部分主要包括了:java

  • loader(webpack)的默认配置
  • 使用loaderResolver解析loader模块路径
  • 根据rule.modules建立RulesSet规则集
  • 使用loader-runner运行loader

其对应的大体流程以下:node

首先,在Compiler.js中会为将用户配置与默认配置合并,其中就包括了loader部分。webpack

而后,webpack就会根据配置建立两个关键的对象——NormalModuleFactoryContextModuleFactory。它们至关因而两个类工厂,经过其能够建立相应的NormalModuleContextModule。其中NormalModule类是这篇文章主要关注的,webpack会为源码中的模块文件对应生成一个NormalModule实例。git

在工厂建立NormalModule实例以前还有一些必要步骤,其中与loader最相关的就是经过loader的resolver来解析loader路径。github

NormalModule实例建立以后,则会经过其.build()方法来进行模块的构建。构建模块的第一步就是使用loader来加载并处理模块内容。而loader-runner这个库就是webpack中loader的运行器。web

最后,将loader处理完的模块内容输出,进入后续的编译流程。

上面就是webpack中loader涉及到的大体流程。下面会结合源码对其进行具体的分析,而在源码阅读分析过程当中,就会找到「loader十问」的解答。

3. loader运行部分的具体分析

3.1. webpack默认配置

Q:1. webpack默认配置是在哪处理的,loader有什么默认配置么?

webpack和其余工具同样,都是经过配置的方式来工做的。随着webpack的不断进化,其默认配置也在不断变更;而曾经版本中的某些最佳实践,也随着版本的升级进入了webpack的默认配置。

webpack的入口文件是lib/webpack.js,会根据配置文件,设置编译时的配置options (source code)(上一篇《可视化展现webpack内部插件与钩子关系📈》提到的plugin也是在这里触发的)

options = new WebpackOptionsDefaulter().process(options);
compiler = new Compiler(options.context);
compiler.options = options;
复制代码

因而可知,默认配置是放在WebpackOptionsDefaulter里的。所以,若是你想要查看当前webpack默认配置项具体内容,能够在该模块里查看。

例如,在module.rules这部分的默认值为[];可是此外还有一个module.defaultRules配置项,虽然不开放给开发者使用,可是包含了loader的默认配置 (source code)

this.set("module.rules", []);
this.set("module.defaultRules", "make", options => [
    {
        type: "javascript/auto",
        resolve: {}
    },
    {
        test: /\.mjs$/i,
        type: "javascript/esm",
        resolve: {
            mainFields:
                options.target === "web" ||
                options.target === "webworker" ||
                options.target === "electron-renderer"
                    ? ["browser", "main"]
                    : ["main"]
        }
    },
    {
        test: /\.json$/i,
        type: "json"
    },
    {
        test: /\.wasm$/i,
        type: "webassembly/experimental"
    }
]);
复制代码

此外值得一提的是,WebpackOptionsDefaulter继承自OptionsDefaulter,而OptionsDefaulter则是一个封装的配置项存取器,封装了一些特殊的方法来操做配置对象。

3.2. 建立NormalModuleFactory

NormalModule是webpack中不得不提的一个类函数。源码中的模块在编译过程当中会生成对应的NormalModule实例。

NormalModuleFactoryNormalModule的工厂类。其建立是在Compiler.js中进行的,Compiler.js是webpack基本编译流程的控制类。compiler.run()方法中的主体(钩子)流程以下:

.run()在触发了一系列beforeRunrun等钩子后,会调用.compile()方法,其中的第一步就是调用this.newCompilationParams()建立NormalModuleFactory实例。

newCompilationParams() {
    const params = {
        normalModuleFactory: this.createNormalModuleFactory(),
        contextModuleFactory: this.createContextModuleFactory(),
        compilationDependencies: new Set()
    };
    return params;
}
复制代码

3.3. 解析(resolve)loader的真实绝对路径

Q:2. webpack中有一个resolver的概念,用于解析模块文件的真实绝对路径,那么loader模块与normal module(源码模块)的resolver使用的是同一个么?

NormalModuleFactory中,建立出NormalModule实例以前会涉及到四个钩子:

  • beforeResolve
  • resolve
  • factory
  • afterResolve

其中较为重要的有两个:

  • resolve部分负责解析loader模块的路径(例如css-loader这个loader的模块路径是什么);
  • factory负责来基于resolve钩子的返回值来建立NormalModule实例。

resolve钩子上注册的方法较长,其中还包括了模块资源自己的路径解析。resolver有两种,分别是loaderResolver和normalResolver。

const loaderResolver = this.getResolver("loader");
const normalResolver = this.getResolver("normal", data.resolveOptions);
复制代码

因为除了config文件中能够配置loader外,还有inline loader的写法,所以,对loader文件的路径解析也分为两种:inline loader和config文件中的loader。resolver钩子中会先处理inline loader。

3.3.1. inline loader

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

上面是一个inline loader的例子。其中的request为style-loader!css-loader?modules!./styles.css

首先webpack会从request中解析出所需的loader (source code):

let elements = requestWithoutMatchResource
    .replace(/^-?!+/, "")
    .replace(/!!+/g, "!")
    .split("!");
复制代码

所以,从style-loader!css-loader?modules!./styles.css中能够取出两个loader:style-loadercss-loader

而后会将“解析模块的loader数组”与“解析模块自己”一块儿并行执行,这里用到了neo-async这个库。

neo-async库和async库相似,都是为异步编程提供一些工具方法,可是会比async库更快。

解析返回的结果格式大体以下:

[ 
    // 第一个元素是一个loader数组
    [ { 
        loader:
            '/workspace/basic-demo/home/node_modules/html-webpack-plugin/lib/loader.js',
        options: undefined
    } ],
    // 第二个元素是模块自己的一些信息
    {
        resourceResolveData: {
            context: [Object],
            path: '/workspace/basic-demo/home/public/index.html',
            request: undefined,
            query: '',
            module: false,
            file: false,
            descriptionFilePath: '/workspace/basic-demo/home/package.json',
            descriptionFileData: [Object],
            descriptionFileRoot: '/workspace/basic-demo/home',
            relativePath: './public/index.html',
            __innerRequest_request: undefined,
            __innerRequest_relativePath: './public/index.html',
            __innerRequest: './public/index.html'
        },
	resource: '/workspace/basic-demo/home/public/index.html'
    }
]
复制代码

其中第一个元素就是该模块被引用时所涉及的全部inline loader,包含loader文件的绝对路径和配置项。

3.3.2. config loader

Q:3. 咱们知道,除了config中的loader,还能够写inline的loader,那么inline loader和normal config loader执行的前后顺序是什么?

上面一节中,webpack首先解析了inline loader的绝对路径与配置。接下来则是解析config文件中的loader (source code),即module.rules部分的配置:

const result = this.ruleSet.exec({
    resource: resourcePath,
    realResource:
        matchResource !== undefined
            ? resource.replace(/\?.*/, "")
            : resourcePath,
    resourceQuery,
    issuer: contextInfo.issuer,
    compiler: contextInfo.compiler
});
复制代码

NormalModuleFactory中有一个ruleSet的属性,这里你能够简单理解为:它能够根据模块路径名,匹配出模块所需的loader。RuleSet细节此处先按下不表,其具体内容我会在下一节介绍。

这里向this.ruleSet.exec()中传入源码模块路径,返回的result就是当前模块匹配出的config中的loader。若是你熟悉webpack配置,会知道module.rules中有一个enforce字段。基于该字段,webpack会将loader分为preLoader、postLoader和loader三种 (source code)

for (const r of result) {
    if (r.type === "use") {
        // post类型
        if (r.enforce === "post" && !noPrePostAutoLoaders) {
            useLoadersPost.push(r.value);
        // pre类型
        } else if (
            r.enforce === "pre" &&
            !noPreAutoLoaders &&
            !noPrePostAutoLoaders
        ) {
            useLoadersPre.push(r.value);
        } else if (
            !r.enforce &&
            !noAutoLoaders &&
            !noPrePostAutoLoaders
        ) {
            useLoaders.push(r.value);
        }
    }
    // ……
}
复制代码

最后,使用neo-aysnc来并行解析三类loader数组 (source code)

asyncLib.parallel(
    [
        this.resolveRequestArray.bind(
            this,
            contextInfo,
            this.context,
            useLoadersPost, // postLoader
            loaderResolver
        ),
        this.resolveRequestArray.bind(
            this,
            contextInfo,
            this.context,
            useLoaders, // loader
            loaderResolver
        ),
        this.resolveRequestArray.bind(
            this,
            contextInfo,
            this.context,
            useLoadersPre, // preLoader
            loaderResolver
        )
    ]
    // ……
}
复制代码

那么最终loader的顺序到底是什么呢?下面这一行代码能够解释:

loaders = results[0].concat(loaders, results[1], results[2]);
复制代码

其中results[0]results[1]results[2]loader分别是postLoader、loader(normal config loader)、preLoader和inlineLoader。所以合并后的loader顺序是:post、inline、normal和pre。

然而loader是从右至左执行的,真实的loader执行顺序是倒过来的,所以inlineLoader是总体后于config中normal loader执行的。

3.3.3. RuleSet

Q:4. 配置中的module.rules在webpack中是如何生效与实现的?

webpack使用RuleSet对象来匹配模块所需的loader。RuleSet至关于一个规则过滤器,会将resourcePath应用于全部的module.rules规则,从而筛选出所需的loader。其中最重要的两个方法是:

  • 类静态方法.normalizeRule()
  • 实例方法.exec()

webpack编译会根据用户配置与默认配置,实例化一个RuleSet。首先,经过其上的静态方法.normalizeRule()将配置值转换为标准化的test对象;其上还会存储一个this.references属性,是一个map类型的存储,key是loader在配置中的类型和位置,例如,ref-2表示loader配置数组中的第三个。

p.s. 若是你在.compilation中某个钩子上打印出一些NormalModule上request相关字段,那些用到loader的模块会出现相似ref-的值。从这里就能够看出一个模块是否使用了loader,命中了哪一个配置规则。

实例化后的RuleSet就能够用于为每一个模块获取对应的loader。这个实例化的RuleSet就是咱们上面提到的NormalModuleFactory实例上的this.ruleSet属性。工厂每次建立一个新的NormalModule时都会调用RuleSet实例的.exec()方法,只有当经过了各种测试条件,才会将该loader push到结果数组中。

3.4. 运行loader

3.4.1. loader的运行时机

Q:5. webpack编译流程中loader是如何以及在什么时候发挥做用的?

loader的绝对路径解析完毕后,在NormalModuleFactoryfactory钩子中会建立当前模块的NormalModule对象。到目前为止,loader的前序工做已经差很少结束了,下面就是真正去运行各个loader。

咱们都知道,运行loader读取与处理模块是webpack模块处理的第一步。但若是说到详细的运行时机,就涉及到webpack编译中compilation这个很是重要的对象。

webpack是以入口维度进行编译的,compilation中有一个重要方法——.addEntry(),会基于入口进行模块构建。.addEntry()方法中调用的._addModuleChain()会执行一系列的模块方法 (source code)

this.semaphore.acquire(() => {
    moduleFactory.create(
        {
            // ……
        },
        (err, module) => {
            if (err) {
                this.semaphore.release();
                return errorAndCallback(new EntryModuleNotFoundError(err));
            }
            // ……
            if (addModuleResult.build) {
                // 模块构建
                this.buildModule(module, false, null, null, err => {
                    if (err) {
                        this.semaphore.release();
                        return errorAndCallback(err);
                    }

                    if (currentProfile) {
                        const afterBuilding = Date.now();
                        currentProfile.building = afterBuilding - afterFactory;
                    }

                    this.semaphore.release();
                    afterBuild();
                });
            }
        }
    )
}
复制代码

其中,对于未build过的模块,最终会调用到NormalModule对象的.doBuild()方法。而构建模块(.doBuild())的第一步就是运行全部的loader

这时候,loader-runner就登场了。

3.4.2. loader-runner —— loader的执行库

Q:6. loader为何是自右向左执行的?

webpack将loader的运行工具剥离出来,独立成了loader-runner库。所以,你能够编写一个loader,并用独立的loader-runner来测试loader的效果。

loader-runner分为了两个部分:loadLoader.js与LoaderRunner.js。

loadLoader.js是一个兼容性的模块加载器,能够加载例如cjs、esm或SystemJS这种的模块定义。而LoaderRunner.js则是loader模块运行的核心部分。其中暴露出来的.runLoaders()方法则是loader运行的启动方法。

若是你写过或了解如何编写一个loader,那么确定知道,每一个loader模块都支持一个.pitch属性,上面的方法会优先于loader的实际方法执行。实际上,webpack官方也给出了pitch与loader自己方法的执行顺序图:

|- a-loader `pitch`
  |- b-loader `pitch`
    |- c-loader `pitch`
      |- requested module is picked up as a dependency
    |- c-loader normal execution
  |- b-loader normal execution
|- a-loader normal execution
复制代码

这两个阶段(pitch和normal)就是loader-runner中对应的iteratePitchingLoaders()iterateNormalLoaders()两个方法。

iteratePitchingLoaders()会递归执行,并记录loader的pitch状态与当前执行到的loaderIndexloaderIndex++)。当达到最大的loader序号时,才会处理实际的module:

if(loaderContext.loaderIndex >= loaderContext.loaders.length)
    return processResource(options, loaderContext, callback);
复制代码

loaderContext.loaderIndex值达到总体loader数组长度时,代表全部pitch都被执行完毕(执行到了最后的loader),这时会调用processResource()来处理模块资源。主要包括:添加该模块为依赖和读取模块内容。而后会递归执行iterateNormalLoaders()并进行loaderIndex--操做,所以loader会“反向”执行。

接下来,咱们讨论几个loader-runner的细节点:

Q:7. 若是在某个pitch中返回值,具体会发生什么?

官网上说:

if a loader delivers a result in the pitch method the process turns around and skips the remaining loaders

这段说明表示,在pitch中返回值会跳过余下的loader。这个表述比较粗略,其中有几个细节点须要说明:

首先,只有当loaderIndex达到最大数组长度,即pitch过全部loader后,才会执行processResource()

if(loaderContext.loaderIndex >= loaderContext.loaders.length)
    return processResource(options, loaderContext, callback);
复制代码

所以,在pitch中返回值除了跳过余下loader外,不只会使.addDependency()不触发(不将该模块资源添加进依赖),并且没法读取模块的文件内容。loader会将pitch返回的值做为“文件内容”来处理,并返回给webpack。


Q:8. 若是你写过loader,那么可能在loader function中用到了this,这里的this到底是什么,是webpack实例么?

其实这里的this既不是webpack实例,也不是compiler、compilation、normalModule等这些实例。而是一个loaderContext的loader-runner特有对象

每次调用runLoaders()方法时,若是不显式传入context,则会默认建立一个新的loaderContext。因此在官网上提到的各类loader API(callback、data、loaderIndex、addContextDependency等)都是该对象上的属性。


Q:9. loader function中的this.data是如何实现的?

知道了loader中的this实际上是一个叫loaderContext的对象,那么this.data的实现其实就是loaderContext.data的实现 (source code)

Object.defineProperty(loaderContext, "data", {
    enumerable: true,
    get: function() {
        return loaderContext.loaders[loaderContext.loaderIndex].data;
    }
});
复制代码

这里定义了一个.data的(存)取器。能够看出,调用this.data时,不一样的normal loader因为loaderIndex不一样,会获得不一样的值;而pitch方法的形参data也是不一样的loader下的data (source code)

runSyncOrAsync(
    fn,
    loaderContext,
    [loaderContext.remainingRequest, loaderContext.previousRequest, currentLoaderObject.data = {}],
    function(err) {
        // ……
    }
);
复制代码

runSyncOrAsync()中的数组[loaderContext.remainingRequest, loaderContext.previousRequest, currentLoaderObject.data = {}]就是pitch方法的入参,而currentLoaderObject就是当前loaderIndex所指的loader对象。

所以,若是你想要保存一个“贯穿始终”的数据,能够考虑保存在this的其余属性上,或者经过修改loaderIndex,来取到其余loader上的数据(比较hack)。


Q:10. 如何写一个异步loader,webpack又是如何实现loader的异步化的?

pitch与normal loader的实际执行,都是在runSyncOrAsync()这个方法中。

根据webpack文档,当咱们调用this.async()时,会将loader变为一个异步的loader,并返回一个异步回调。

在具体实现上,runSyncOrAsync()内部有一个isSync变量,默认为true;当咱们调用this.async()时,它会被置为false,并返回一个innerCallback做为异步执行完后的回调通知:

context.async = function async() {
    if(isDone) {
        if(reportedError) return; // ignore
        throw new Error("async(): The callback was already called.");
    }
    isSync = false;
    return innerCallback;
};
复制代码

咱们通常都使用this.async()返回的callback来通知异步完成,但实际上,执行this.callback()也是同样的效果:

var innerCallback = context.callback = function() {
    // ……
}
复制代码

同时,在runSyncOrAsync()中,只有isSync标识为true时,才会在loader function执行完毕后当即(同步)回调callback来继续loader-runner。

if(isSync) {
    isDone = true;
    if(result === undefined)
        return callback();
    if(result && typeof result === "object" && typeof result.then === "function") {
        return result.catch(callback).then(function(r) {
            callback(null, r);
        });
    }
    return callback(null, result);
}
复制代码

看到这里你会发现,代码里有一处会判断返回值是不是Promise(typeof result.then === "function"),若是是Promise则会异步调用callback。所以,想要得到一个异步的loader,除了webpack文档里提到的this.async()方法,还能够直接返回一个Promise。

4. 尾声

以上就是webapck loader相关部分的源码分析。相信到这里,你已经对最开始的「loader十问」有了答案。但愿这篇文章可以让你在学会配置loader与编写一个简单的loader以外,能进一步了解loader的实现。

阅读源码的过程当中可能存在一些纰漏,欢迎你们来一块儿交流。

告别「webpack配置工程师」

webpack是一个强大而复杂的前端自动化工具。其中一个特色就是配置复杂,这也使得「webpack配置工程师」这种戏谑的称呼开始流行🤷可是,难道你真的只知足于玩转webpack配置么?

显然不是。在学习如何使用webpack以外,咱们更须要深刻webpack内部,探索各部分的设计与实现。万变不离其宗,即便有一天webpack“过气”了,但它的某些设计与实现却仍会有学习价值与借鉴意义。所以,在学习webpack过程当中,我会总结一系列【webpack进阶】的文章和你们分享。

欢迎感兴趣的同窗多多交流与关注!

往期文章:

相关文章
相关标签/搜索