包看包会: webpack run loader

661 阅读9分钟

我在尝试以一种更为直白的方式看源码,下面示例中的代码均为简化代码,这些代码这文字解说是对应的,化繁为简。先骨骼,再慢慢丰盈。

1. webpack-loader 是什么

webpack 只能理解 JavaScript 和 JSON 文件,loader 让 webpack 能够去处理其他类型的文件,并将它们转换为有效 模块,以供应用程序使用,以及被添加到依赖图中。

2. loader 的配置及默认配置

webpack loaders 是怎么配置的,在哪里配置的,有默认配置么?

  • 2.1 webpack.config.js 中配置 module.rules
  • 2.2 在 webpack.js 中创建 compiler 对象前通过 WebpackOptionsDefaulter.js 初始化默认配置的时候会在合并(merge cli 和 webpack.config.js)后的配置项中增加 module.defaultRules;
// ... webpack.js
if (Array.isArray(options)) {} 
else if (typeof options === "object") {
  options = new WebpackOptionsDefaulter().process(options); // process 方法来自 WebpackOptionsDefaulter 父类 OptionsDefaulter
  compiler = new Compiler(options.context);
  compiler.options = options;
} 

// WebpackOptionsDefaulter 类 来自 webpack/lib/WebpackOptionsDefaulter.js 如下:
class WebpackOptionsDefaulter extends OptionsDefaulter { 
  constructor () {
    // ...
    // loader 默认配置包括 js,json
    this.set("module.defaultRules", "make", options => [
        { type: "javascript/auto", resolve: {} },
        { test: /.mjs$/i, type: "javascript/esm", resolve: { mainFields: 'example' } },
        { test: /.json$/i, type: "json" },
        { test: /.wasm$/i, type: "webassembly/experimental" }
    ]);
    // ... other default cfg
  }
}

思考:这些配置又是如何配合 loader 一起工作的?

3. loader 的具体执行部分

3.1 webpack loader 的 resolve

webpack.config.js 只需要配置一个名字,webpack 又是如何通过名字找到 loader 模块

webpack 查找 loader 则是要进入到编译阶段,而编译的入口在 Compiler 的 run 方法; 该过程发生在创建 compiler 实例(new Compiler()) 后,调用 compiler.run 方法初始化 compilation 对象。

初始化 compilation 对需要创建两个特殊的对象: NormalModuleFactory 和 ContextModuleFactory 实例,其中 NormalModuleFactory 是解答本问题的关键点。NormalModuleFactory 会为 webpack 的源码模块生成 NormalModule 实例,在创建 NormalModule 实例之前会经历一些必要的过程,这一过程就包含通过 loader resolver 根据 loader 的名字及相关配置,来解析 loader 的路径;

3.2 webpack.config.js 中的 module.rules

webpack.config.js 中配置的 module.rules 又是经历了怎样的过程才生效的?

webpack 会整合 webpack.config.js 配置项(modules.rules) 和 默认的配置项(options.defaultRules)创建 RuleSet 实例,这一过程同样发生在创建 NormalModuleFactory 实例的过程中;示例代码:

class NormalModuleFactory extends Tapable {
   constructor(context, resolverFactory, options) {
        // ...
        this.ruleSet = new RuleSet(options.defaultRules.concat(options.rules))
   }
}

RuleSet 实例相当于过滤器,通过调用其 exec 方法并传入当前的 request 相关信息,将会返回该 request 所需的 loader 数据,即根据 webpack.config.js 中 module 的配置匹配出的 loader 结果集。

exec 的调用发生在解析 inline-loader 后的回调中;示例代码:

asyncLib.parallel([/*处理 inline-loader 即路径信息*/], (err, results) => {
    const result = this.ruleSet.exec({
        resource: resourcePath,
        realResource:
        matchResource !== undefined
        ? resource.replace(/?.*/, "")
        : resourcePath,
        resourceQuery,
        issuer: contextInfo.issuer,
        compiler: contextInfo.compiler
    });
})

3.3 inline-loader 和 普通 loader

除了 webpack.config.js 配置的 loader 还可以使用 inline-loader,inline-loader 执行和普通 loader 谁的优先级更高?

下面是一个 inline-loader 的例子:

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

// webpack 处理 inline-loader 的代码:webpack/lib/NormalFactory.js -> constructor -> this.hooks.resolver.tap( handler
let elements = requestWithoutMatchResource
    .replace(/^-?!+/, "")
    .replace(/!!+/g, "!")
    .split("!");

经过处理后得到两个 loader:style-loadercss-loader。得到这两个 loader 以后用 loader-resolver 和模块 resolver,把 2 个 loader 和 模块 ./style.css 的路径解析出来;这个过程发生在上面解析 inline-loader 之后,是通过 asyncLib.parallel 方法并行处理的,在 asyncLib.parallel 的回调可以拿到 resolve 解析后的结果:

  1. 第一项为解析所得的 inline-loader 的路径及模块等信息;
  2. 第二项为模块路径信息,后面在这个回调里将对配置的 loader 和 inline-loader 进行排序;示例:
// webpack/lib/NormalFactory.js -> constructor -> this.hooks.resolver.tap( hanlder
asyncLib.parallel(
    [
      callback => this.resolveRequestArray(contextInfo, context, elements, loaderResolver, callback), // 解析 inline-loader 路径
      callback => normalResolver.resolve(contextInfo, context, resource, {}, (err, resource, resourceResolveData) => { // 解析模块路径
        callback(null, {resourceResolveData,resource })
      })
    ],
    (err, results) => {
        // resolve 后的 inline-loader
        let loaders = results[0]
    }
)

3.4 enforce 调整 loader 优先级

通过配置 enforceprepost 来定义 loader 类型为 pre-loader 或 post-loader(若不配置称为普通loader),进而调整该 loader 的执行顺序,这又是怎么实现的?

webpack 内部在拿到 webpack.config.js 中的 loader 结果集合之后会按照是否配置了 pre/post 将配置 loader 分为三组,useLoaderPost, useLoaders, useLoaderPre。 接着就去 resolve 这些三组 loader 模块的路径信息,待 resolve 完成后对所有的 loader 的终极排序;最终得出执行顺序为:前置(pre)、普通(normal)、行内(inline)、后置(post)

  • 简化代码如下:
// webpack/lib/NormalModuleFactory.js -> constructor
this.hooks.resolver.tap("NormalModuleFactory", () => (data, callback) => {
    // ....
    asyncLib.parallel([/*处理 inline-loader 即路径信息*/], (err, results) => {
        let loaders = results[0]; // inline-loaders

        const result = this.ruleSet.exec({});// 这是结合了 webpack.config.js 和当前的 request 信息得出的配置 loader 结果集
    
        // 开始分组
        const useLoadersPost = [];
        const useLoaders = [];
        const useLoadersPre = [];
        for (const r of result) {
            if (r.type === "use") {
                if (r.enforce === "post" && !noPrePostAutoLoaders) {
                    useLoadersPost.push(r.value);
                } else if (
                    r.enforce === "pre" &&
                    !noPreAutoLoaders &&
                    !noPrePostAutoLoaders
                ) {
                    useLoadersPre.push(r.value);
                } else if (
                    !r.enforce &&
                    !noAutoLoaders &&
                    !noPrePostAutoLoaders
                ) {
                    useLoaders.push(r.value);
                }
            } 
        }
        // 分组后开始 resolve 然后进行终极分组:
        asyncLib.parallel(
            [
                this.resolveRequestArray.bind(/* other args,*/ useLoadersPost,loaderResolver),
                this.resolveRequestArray.bind(/* other args,*/ useLoaders,loaderResolver),
                this.resolveRequestArray.bind(/* other args,*/ useLoadersPre, loaderResolver)
            ],
            (err, results) => {
                if (matchResource === undefined) {
                    // results [useLoaderPost, useLoaders, useLoadersPre]
                    // loaders inline-loaders
                    // 最终结果:post-loader、inline-loader、normal-loader、pre-loader
                    loaders = results[0].concat(loaders, results[1], results[2]);
                } 
                process.nextTick(() => {
                    callback(null, {
                        context: context,
                        request: loaders.map(loaderToIdent).concat([resource]).join("!"), // 拼接过 loader 的 request
                        dependencies: data.dependencies,
                        userRequest,
                        rawRequest: request,
                        loaders, // 经排序后的所有 loaders
                        resource,
                        matchResource,
                        resourceResolveData,
                        settings,
                        type,
                        parser: this.getParser(type, settings.parser),
                        generator: this.getGenerator(type, settings.generator),
                        resolveOptions
                    });
                });
            }
      )
    })
})

3.5 run loaders

在上一节 3.4 的最后,在 process.nextTick 调用 callback,传递一个包含经整合的 loaders 在内的对象。那么 callback 又是哪里传入的?里面又发生了什么? callback 是在 NormalModuleFactory.constructor 中 执行 this.hooks.factory.tap("NormalModuleFactory", handler) 时,其 handler 返回的 factory 函数中调用 resolver 时传入的。

  • 简化代码如下:
// webpack/lib/NormalModuleFactory.js
class NormalModuleFactory extends Tapable {
    constructor(context, resolverFactory, options) {
        this.hooks.factory.tap("NormalModuleFactory", () => (result, callback) => {
            let resolver = this.hooks.resolver.call(null);
            resolver(result, (err, data) => { 
                /* 该函数为 resolver 的 callback,用于接收整合后的 loaders */
                this.hooks.afterResolve.callAsync(data, (err, result) => {
                  createdModule = new NormalModule(result); // webpack/lib/NormalModule.js
                  return callback(null, createdModule);
                })
        })
        this.hooks.resolver.tap("NormalModuleFactory", () => (data, callback) => {})
    }
}

忽略支线剧情,在这个 callback 中,会创建 NormalModule 的实例,将通过这个实例创建模块,在此期间将会 NormalModule.prototype.build() 方法,该方法会完成 run-loaders、解析 ast 分析依赖等工作,我们的重点仍放在 run-loaders,build 方法将会调用 doBuild 方法。 doBuild 方法中首先创建 loaderContext 对象,这个对象为开发一个 loader 提供了许多能力,接着调用 runLoaders 方法开始执行 loader,runLoaders 方法来自独立的库: loader-runner

  • 简化代码:
// webpack/lib/NormalModule.js
class NormalModule extends Module {
    constructor({type, request, userRequest, rawRequest, loaders, resource, matchResource, parser, generator, ...}) {
      // 看下这个 constructor 的参数,其中的 loaders,rawRequest,request 等是不是很眼熟,是的,就是来自前面处理 loaders 排序后返回的对象
    }
    build(options, compilation, resolver, fs, callback) {
      return this.doBuild(options, compilation, resolver, fs, err => {})
    }
    doBuild(options, compilation, resolver, fs, callback) {
        const loaderContext = this.createLoaderContext(resolver, options, compilation,fs);
        
        runLoaders(
            {
                resource: this.resource,
                loaders: this.loaders,
                context: loaderContext,
                readResource: fs.readFile.bind(fs)
            },
            (err, result) => {}
        )
    }
    createLoaderContext(resolver, options, compilation, fs) {
        const requestShortener = compilation.runtimeTemplate.requestShortener;
        const getCurrentLoaderName = () => {};
        const loaderContext = {
            version: 2,
            emitWarning: warning => {},
            emitError: error => {},
            getLogger: name => {},
            exec: (code, filename) => {},
            resolve(context, request, callback) {},
            getResolve(options) {},
            emitFile: (name, content, sourceMap, assetInfo) => {},
            rootContext: options.context,
            webpack: true,
            sourceMap: !!this.useSourceMap,
            mode: options.mode || "production",
            _module: this,
            _compilation: compilation,
            _compiler: compilation.compiler,
            fs: fs
        };
        return loaderContext;
    }
}

3.6 loader-runner 的内幕

在 loader-runner 导出的 runLoaders 方法用于执行 loader,runLoaders 方法接收包含 loaderContext 和 loaders 集合在内的一个 options 对象和 callback 函数; runLoader 方法执行时主要完成以下工作:

  1. 对 loaderContext 等重要参数做默认参数处理;
  2. 扩展 loaderContext 的属性和能力,其中 loaderIndex 在此时被添加,扩展能力例如 addDependency/adContextDependency 等方法;
  3. 为便于使用,通过 defineProperty 重新定义 loaderContext 的一些属性的取值(get)、赋值(set)行为;
  4. 进入 loader 的 pitch 阶段;
// node_modules/loader-runner/lib/LoaderRunner.js

exports.runLoaders = function runLoaders(options, callback) {
   // 示例 1
   var loaderContext = options.context || {};
   loaders = loaders.map(createLoaderObject);
  
    // 示例 2
   loaderContext.loaderIndex = 0;
   loaderContext.addContextDependency = function addContextDependency(context) {
      contextDependencies.push(context);
   };
   
    // 示例:3
   Object.defineProperty(loaderContext, "remainingRequest", {
      enumerable: true,
      get: function() {
         if(loaderContext.loaderIndex >= loaderContext.loaders.length - 1 && !loaderContext.resource)
            return "";
         return loaderContext.loaders.slice(loaderContext.loaderIndex + 1).map(function(o) {
            return o.request;
         }).concat(loaderContext.resource || "").join("!");
      }
   });

    // 进入 piching 阶段
   iteratePitchingLoaders(processOptions, loaderContext, function(err, result) {});
};

3.7 loader.pitch & normal & loaderContext.loaderIndex

众所周知的是 loader 的执行顺序恰好是与我们定义 loader 的顺序相反,即定义顺序为 A -> B -> C,则执行顺序为 C -> B -> A。这其实是描述的 loader 的 normal 执行阶段的顺序;每个 loader 运行阶段有 2 个部分组成,即 pitchingnormal ;其中 pitching 阶段是执行定义在 loader 上的 pitch 方法上,当然 pitch 是可选的。

在 loader 执行之前,有一个 loadLoader 的方法调用过程,该方法去加载这些 loader 模块,并把 loader 本身赋值为 loader.normal,pitch 函数赋值为 loader.pitch。待分离完成后,会执行该 loader.pitch,如果 pitch 的没有返回结果,则递归去加载下一个 loader,这个过程会维护 loaderContext.loaderIndex 累加,所以这个过程是个顺序的,一直到 loaderIndex >= loaderContext.loaders.length 时退出递归再去执行 loader 的 normal;而执行 normal 的时候是以 loaderIndex-- 为索引,从 loaders 集合中取出并执行,一直到 loaderIndex <= 0,所以这个过程是个倒序的;

当然,如果在某个 pitch 函数中 return 一些内容,将会 -越过- 剩余 loader 的解析,直接进入已经解析过的 loader 的 normal 执行阶段

  • 一个有 pitch 的 loader 示例
// some-loader.js
module.exports = function (cnt) { return someSyncOps(cnt, this.data.value )}

// 定义一个 pitch
module.exports.pitch = function (remainRequest, precedingRequest, data) {
  data.value = 42
}
  • pitch/normal 简化代码示例:
// node_modules/loader-runner/lib/LoaderRunner.js

exports.runLoaders = function runLoaders(options, callback) {
    // 进入 piching 阶段
   iteratePitchingLoaders(processOptions, loaderContext, function(err, result) {});
};

function iteratePitchingLoaders(options, loaderContext, callback) {
   // 加载完最后一个 loader 后终止该递归,进入normal 阶段
   if(loaderContext.loaderIndex >= loaderContext.loaders.length) return processResource(options, loaderContext, callback);

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

   // 前一个 loader 的 pitch 执行过后 pitchExecuted 为 true,维护 loaderIndex++,递归下一个
   if(currentLoaderObject.pitchExecuted) {
      loaderContext.loaderIndex++;
      return iteratePitchingLoaders(options, loaderContext, callback);
   }

   // 加载 loader 模块(分组)
   loadLoader(currentLoaderObject, function(err) {
      var fn = currentLoaderObject.pitch; 
      currentLoaderObject.pitchExecuted = true;
        // 如果没有 pitch 直接递归下一个:
      if(!fn) return iteratePitchingLoaders(options, loaderContext, callback);

        // 聪明的小伙伴都已经发现没有调用过程。。调用过程呢?在 runSyncOrAsync
      runSyncOrAsync(
         fn,
         loaderContext, [loaderContext.remainingRequest, loaderContext.previousRequest, currentLoaderObject.data = {}],
         function(err) {
            var args = Array.prototype.slice.call(arguments, 1);
            if(args.length > 0) {
                    // args 是调用 pitch 时的返回结果,若不为空则终止处理后面的 loader 进入到 normal 阶段
               loaderContext.loaderIndex--;
                    // 见后面 function iterateNormalLoaders
               iterateNormalLoaders(options, loaderContext, args, callback);
            } else {
                    // 顺利执行完一个 pitch 后递归下一个 pitch
               iteratePitchingLoaders(options, loaderContext, callback);
            }
         }
      );
   });
}

function iterateNormalLoaders(options, loaderContext, args, callback) {
    // loaderIndex < 0 则直接退出递归执行后续逻辑,
    // 所以可以在 loader 中通过设置 loaderIndex 为一个负数达到终止 run loader 的目的
   if(loaderContext.loaderIndex < 0) return callback(null, args);

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

   // 维护 loaderIndex--
   if(currentLoaderObject.normalExecuted) {
      loaderContext.loaderIndex--;
      return iterateNormalLoaders(options, loaderContext, args, callback);
   }

   var fn = currentLoaderObject.normal;
   currentLoaderObject.normalExecuted = true;
   if(!fn) return iterateNormalLoaders(options, loaderContext, args, callback);

   runSyncOrAsync(fn, loaderContext, args, function(err) {
        // 一个 loader 顺利执行完会执行该回调递归执行下一个
      iterateNormalLoaders(options, loaderContext, args, callback);
   });
}

// node_modules/loader-runner/lib/loadLoader.js
module.exports = function loadLoader(loader, callback) {
    var module = require(loader.path);
    loader.normal = typeof module === "function" ? module : module.default;
    loader.pitch = module.pitch;
}

3.8 异步 loader 顺序管控

同步 loader 的顺序靠执行顺序即可正确保证,那么在使用异步 loader 时,webpack 是如何保证 loader 仍然以预期的顺序正确执行?

这个小技巧在上文出现 runSyncOrAsync 方法,这个方法用于跑 loader.normal 或者 loader.pitch,其中处理了异步管控流程。核心实现是通过 扩展的loaderContext.async() 方法维护 isSync 标识符来实异步串行。调用 loader 时,会将 loaderContext 同 loader 函数中的 this 绑定,loaderContext.async 即异步 loader 中的 this.async

调用该 async 方法得到一个 callback,当异步 loader 中的异步逻辑完成时需要调用这个 this.async 方法返回的 callback,意在告知 webpack 这个异步 loader 跑完了,跑下一个吧~;

  • 简化代码
// node_modules/loader-runner/lib/LoaderRunner.js
function runSyncOrAsync(fn, context, args, callback) {
    var isSync = true;
    context.async = function async() {
        isSync = false;
        return innerCallback;
    };
    
    var innerCallback = context.callback = function() {
      isSync = false;
      callback.apply(null, arguments);
    };
    
    var result = (function LOADER_EXECUTION() {
        // fn 即为 loader 函数,用 apply 将 loaderContext 绑定 this
        return fn.apply(context, args);
    }());
    if(isSync) {
      isDone = true;
      if(result && typeof result === "object" && typeof result.then === "function") {
        return result.then(function(r) {callback(null, r);}, callback);
    }
      return callback(null, result);
    }
}