6. 模块构建之loader执行:loader-runner@2.4.0源码分析

2022-11-16 17:31:31 浏览数 (1)

通过一个demo带你深入进入webpack@4.46.0源码的世界,分析构建原理,专栏地址,共有十篇。

  • 1. 从构建前后产物对比分析webpack做了些什么?
  • 2. webpack构建的基石: tapable@1.1.3源码分析
  • 3. webpack构建整体流程的组织:webpack -> Compiler -> Compilation
  • 4. 创建模块实例,为模块解析准备
  • 5. 路径解析:enhanced-resolve@4.5.0源码分析
  • 6. 模块构建之loader执行:loader-runner@2.4.0源码分析
  • 7. 模块构建之解析_source获取dependencies
  • 8. 从dependency graph 到 chunk graph
  • 9. 从chunk到最终的文件内容到最后的文件输出?
  • 10. webpack中涉及了哪些设计模式呢?

第四节创建完模式实例后,进入模块的构建工作,本节重点说loaders的执行。


看下a.js在执行runLoaders是控制台的日志:

看到先是执行所有的module.exports.pitch指向的函数函数,然后再执行module.exports指向的函数。实际上webpack将loader的执行分为两个阶段:pitching和normal。

再关注下在某个阶段不同类型(pre、post、normal、inline四种类型)的loader的执行顺序:

  • pitching阶段:post -> inline -> normal -> pre
  • normal阶段:pre -> normal -> inline -> post

下面分析下具体的执行过程:NormalModuleFactory.resolve 解析获取loader后

代码语言:javascript复制
// Compilation.js
buildModule(module, optional, origin, dependencies, thisCallback){
    module.build(..., error => {/*...*/})
}

// NormalModule.js
const { getContext, runLoaders } = require("loader-runner"); // 调用的loader-runner包

build(options, compilation, resolver, fs, callback) {
    return this.doBuild(..., fs, err => {/*...*/})
}

doBuild(options, compilation, resolver, fs, callback) {
    // 创建loader的执行上下文
    const loaderContext = this.createLoaderContext(/*...*/);

    runLoaders({
        resource: this.resource,
        loaders: this.loaders, // 传入loaders ,NomalModleFactory new NormalModule()传入的
        context: loaderContext,
        readResource: fs.readFile.bind(fs)
    }, (err, result) => { /*...*/ })
}

并且经过xxxResolver.resolve拿到了loader和resouce的本地路径

LoadRunner

loader-runner@2.4.0目录结构如下:

主要是LoaderRunner.js和loadLoader.js,前者执行loader,后者通过指定本地路径加载js代码到内存。

runLoaders(loader-runner包)

LoaderRunner.js

这个文件主要包含三个部分内容

1. 给loaderContext对象添加部分属性和方法

代码语言:javascript复制
// read options
var resource = options.resource || ""; // 资源信息,将上面截图
var loaders = options.loaders || [];

// 来自NormalModule创建的context,所以每个module的执行loader的loaderContext是独立的
// 但是该module的所有loaders共享这个上线文
var loaderContext = options.context || {}; 
var readResource = options.readResource || readFile; // 文件读取方法

// 拆分resource为path和query单独存储
var splittedResource = resource && splitQuery(resource);
var resourcePath = splittedResource ? splittedResource[0] : undefined;
var resourceQuery = splittedResource ? splittedResource[1] : undefined;
var contextDirectory = resourcePath ? dirname(resourcePath) : null; // 资源所在目录

// execution state
var requestCacheable = true; // 是否要缓存当前模块
var fileDependencies = []; // 依赖的文件
var contextDependencies = []; // 依赖的目录

loaders = loaders.map(createLoaderObject);

loaderContext.context = contextDirectory;
loaderContext.loaderIndex = 0; // 用于记录当前执行的loader的索引,查找当前执行的loader
loaderContext.loaders = loaders; // 保存这次所有的loaders
loaderContext.resourcePath = resourcePath; 
loaderContext.resourceQuery = resourceQuery;
loaderContext.async = null; // 异步loader标识
loaderContext.callback = null; // 异步loader回调
loaderContext.cacheable = function cacheable(flag) { // 缓存
    if(flag === false) {
        requestCacheable = false;
    }
};

当前demo中以解析a.js为例,原始的资源路径是:/Users/.../src/simple/a.js?c=d'

代码语言:javascript复制
resourcePath: '/Users/.../src/simple/a.js'
resourceQuery: '?c=d'
  • asynccallback使用为支持异步loader,后面分析loader执行时会说到。
  • fileDependenciescontextDependencies:表示依赖的文件和文件夹,可以通过下面的方法addDependency和addContextDependency进行添加,getDependencies和getContextDependencies获取。关于fileDependencies和contextDependencies的具体作用后面会单独说。
代码语言:javascript复制
// -------- 文件依赖、目录依赖 添加、获取、清理----------
// NormalModule保存了fileDependencies、contextDependencies,猜测变更时使用?
// 当前文件的processResource方法有用到
loaderContext.dependency = loaderContext.addDependency = ...
loaderContext.addContextDependency = //...
loaderContext.getDependencies = //...;
loaderContext.getContextDependencies = //...
loaderContext.clearDependencies = //...
  • cacheable(boolean)用来设置是否需要缓存当前模块的构建结果。后面会细说
  • 通过Object.defineProperty设置get/set,动态计算属性结果。
代码语言:javascript复制
// 下面的xxx: request、remainingRequest、currentRequest、previousRequest、query、data
Object.defineProperty(loaderContext, `${xxx}`, ...);

// finish loader context
// 冻结,不让扩展loaderContext
if(Object.preventExtensions) {
    Object.preventExtensions(loaderContext);
}

requestremainingRequestcurrentRequestpreviousRequestloadersresource通过!拼接的结果,这几个属性的主要区别在于包含的loaders不一样;request属性包含所有的loaders,remainingRequest包含剩余未执行的loaders,currentRequest包含当前正在执行及后面未执行的loaders,previousRequest包含已经执行过的loaders

loaderContext.request在当前demo中的结果

代码语言:javascript复制
// loaderContext.request
/Users/.../src/simple/custom-loaders/custom-post-loader.js!/Users/.../src/simple/custom-loaders/custom-inline-loader.js??share-opts!/Users/.../src/simple/custom-loaders/custom-normal-loader.js??share-opts!/Users/.../src/simple/custom-loaders/custom-pre-loader.js!/Users/.../src/simple/a.js?c=d

loaderContext.query:当前正在执行的loader的options或者query;假设当前这在执行custom-inline-loader,loaderContext.query的结果是

代码语言:javascript复制
{ a: 'b' } // options

2. 给每一个loader创建一个运行时对象,用来存储该loader的执行状态

代码语言:javascript复制
loaders = loaders.map(createLoaderObject); 
// createLoaderObject返回一个对象包含以下属性用于记录loader执行是的一些状态
{
   path: null,  // loader路径,通过Object.defineProperty定义request属性,然后 obj.request = loader;
   query: null, // 如果loader设置了options则返回options,否则返回laoder的query即‘?’及其后面的字符串
   options: null, // 定义loader可以是字符串,也可以是对象,对象的化可以提供该选项
   ident: null,   // 标识符,可以通过该标志查找loader
   normal: null,  // normal类型的loader - 函数
   pitch: null,   // pitch类型的loader - 函数
   raw: null,     // 是否返回二进制资源
   data: null,    // loader间共享的数据
   pitchExecuted: false, // 标识pitch类型的loader是否执行过,一个loader可以提供normal,pitch
   normalExecuted: false // 标识normal类型的loader是否执行过 
};

上面注释很清楚了,不在赘述

3. pitching阶段执行所有loaders

代码语言:javascript复制
var processOptions = {
    resourceBuffer: null,
    readResource: readResource // 静态资源读取的方法
};

// 开始遍历loader并执行,执行每个loader文件中的module.exports.pitch函数
iteratePitchingLoaders(processOptions, loaderContext, function(err, result) {
    //...
    callback(null, { //返回给 NormalModule.doBuild的回调
        result: result, // result结构: [source, sourceMap, {webpackAST}] 
        resourceBuffer: processOptions.resourceBuffer,
        cacheable: requestCacheable,
        fileDependencies: fileDependencies,
        contextDependencies: contextDependencies
    });
});

在前面章节有说到loader的有两个执行阶段:pitching 和 normal;首先会进入pitching阶段,即这里的iteratePitchingLoaders,用于遍历所有的loader上pitch函数。

另外这里的callback参数是最终交给NormalModule.runLoaders的回调的。

piching阶段:iteratePitchingLoaders

iteratePitchingLoaders

代码语言:javascript复制
// 这里面的执行逻辑都是 pitch函数 的
function iteratePitchingLoaders(options, loaderContext, callback) {
    // 如果pichingLoader已经执行完,则走 processResource
    // processResource 读取资源,然后进入normalLoader的遍历
    if (loaderContext.loaderIndex >= loaderContext.loaders.length)
        return processResource(options, loaderContext, callback);

    // 获取当前loader的属性
    var currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];

    // iterate
    // 当前loader的pitchingLoader是否执行过了
    // 已经执行过,索引加一,进入下一个pitchingLoader的执行
    if (currentLoaderObject.pitchExecuted) {
        loaderContext.loaderIndex  ;
        return iteratePitchingLoaders(options, loaderContext, callback);
    }

    // load loader module
    // 如果当前pitchingLoader没有执行过,则执行
    // loaderLoader类似于 reuqire或者import,根据loader.path加载loader模块
    // 然后设置currentLoaderObject的pitch、normal,raw等属性
    loadLoader(currentLoaderObject, function (err) {
        //...
        var fn = currentLoaderObject.pitch; // 获取pitchingLoader
        currentLoaderObject.pitchExecuted = true; // 设置当前pitchingLoader执行过的标志
        // 如果不是函数,则进入下一个pichingLoader的执行
        if (!fn) return iteratePitchingLoaders(options, loaderContext, callback);

        // 开始执行loader(支持同步和异步方式调用)
        runSyncOrAsync(fn,
            // 参数同时传递了 remainingRequest、previousRequest,loader.data引用
            loaderContext, [loaderContext.remainingRequest, loaderContext.previousRequest, currentLoaderObject.data = {}],
            function (err) {
                //...

                // **注意**:这里的参数会被传递给normalLoader的入参
                // 应该是字符串或者数组,字符串或者数组的第一个元素当做normalLoader的入参:source
                // 即资源的内容
                // 解释了没有走 processResource 的source 该如何获取的问题
                // 如果所有的pitchLoader都没有返回参数则调用processResource读取,如果任意一个返回
                // 则从返回值获取source,传递给normalLoader
                var args = Array.prototype.slice.call(arguments, 1);

                // 如果你的pitchingLoader返回了结果,则会忽略后面所有的loader(不论是normal还是pitch)
                // 直接从从上一个loader.nomal开始执行
                if (args.length > 0) {
                    loaderContext.loaderIndex--; // 从上一个loader开始,即当前loader.normal不会被执行了
                    // 注意:有传递args给normalLoader
                    iterateNormalLoaders(options, loaderContext, args, callback);
                } else { // 否则继续执行下一个pitchingLoader
                    iteratePitchingLoaders(options, loaderContext, callback);
                }
            }
        );
    });
}
  • loader的执行顺序是通过loaderIndex控制的,默认是0。pitching阶段,loaderIndex一直自增直到执行完所有的loader即loaderIndex>=loaders.length
    • 当满足该条件后则开始资源的读取即执行processResource,该方法进入normal loader的执行。
    • 如果没有,判断当前loader是否执行过(pitchExecuted),
      • 如果已经执行过,则loaderIndex 后递归调用iteratePitchingLoaders,进入下一个loader的执行。
      • 如果没有执行过,则调用loadLoader本地路径中加载loader,这个加载的过程可能是异步的,加载成功后在回调中开始执行该loader.pitch,设置该loader.pitchExecuted=true标识该loader的pitch被执行过
        • 如果没有loader.pitch则递归调用iteratePitchingLoaders执行下一个loader
        • 如果有loader.pitch则调用runSyncOrAsync来执行loader.pitch函数(runSyncOrAsync可以让为同步函数动态添加异步能力,同步和异步由当前函数的执行过程动态决定),当执行完pitch函数后进入回调根据当前pitch的返回结果判断进入normal阶段还是继续pitching阶段的执行,如果返回了参数,则进入normal阶段执行loader即执行iterateNormalLoaders,注意这里会将返回值传递给iterateNormalLoaders

简单说下loadLoader,加载并执行对应的本地js资源,读取默认值currentLoaderObject.normal属性,读取pitch属性给currentLoaderObject.pitch,并记录raw的值

思考:只有执行完所有的loader.pitch才会进入资源的读取,那如果没有执行完怎么办❓

下面看下资源读取的方法processResource

processResource

代码语言:javascript复制
function processResource(options, loaderContext, callback) {
    // set loader index to last loader
    // 从最后一个loader掉头反向执行所有的normalLoader
    loaderContext.loaderIndex = loaderContext.loaders.length - 1;

    var resourcePath = loaderContext.resourcePath;
    if(resourcePath) {
        // 添加文件依赖
        loaderContext.addDependency(resourcePath);
        // 根据资源地址读取文件内容
        options.readResource(resourcePath, function(err, buffer) {
            //...
            options.resourceBuffer = buffer;
            // 进入normalLoader的执行,将资源内容作为参数传递进去
            iterateNormalLoaders(options, loaderContext, [buffer], callback);
        });
    } else {
        // 如果没有文件路径,直接进入loader.normal的执行
        iterateNormalLoaders(options, loaderContext, [null], callback);
    }
}

逻辑很简单,获取resourcePath如这里的/Users/.../src/simple/a.js,如果没有这个地址,直接进入normal阶段的执行。如果有会先调用options.readSource(由NormalModuel调用runLoaders时传入)读取该文件,并且会将文件添加到文件依赖fileDependencies中,然后会进入到normal阶段的执行,注意这里将读取的文件内容传给了iterateNormalLoaders

normal阶段:iterateNormalLoaders

代码语言:javascript复制
function iterateNormalLoaders(options, loaderContext, args, callback) {
    if (loaderContext.loaderIndex < 0) // 显然      
        // callback到runLoader方法调用iteratePitchingLoaders的回调
        return callback(null, args);

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

    // iterate
    // 如果当前执行过,则执行下一个normalLoader - 递归
    if (currentLoaderObject.normalExecuted) {
        loaderContext.loaderIndex--;
        return iterateNormalLoaders(options, loaderContext, args, callback);
    }

    var fn = currentLoaderObject.normal;
    currentLoaderObject.normalExecuted = true;
    if (!fn) { // 如果没有提供normalLoader,则进入下一个normalLoader的执行    
        return iterateNormalLoaders(options, loaderContext, args, callback);
    }

    // 将上一个loader返回的source,根据raw值来决定是:string | buffer
    // 当前normalLoaderh会通过暴露一个raw属性来决定传入的source是什么类型
    // 通过图片等静态资源要求是buffer
    convertArgs(args, currentLoaderObject.raw);

    // 开始执行normalLoader
    runSyncOrAsync(fn, loaderContext, args, function (err) {
        if (err) return callback(err);

        // 将当前函数的参数的err移除,取后面所有参数传递给下一个normalLoader
        var args = Array.prototype.slice.call(arguments, 1);
        iterateNormalLoaders(options, loaderContext, args, callback);
    });
}

显然,这里的流程和iteratePitchingLoaders基本上是对应的,判断normal loaders是否执行完;判断当前normal loader是否执行过;区别如下:

  1. 多了一个convertArgs,会根据raw的值转换原始资源的内容,raw=true转为二进制,否则转为字符串
  2. 这里不需要loadLoader,因此该loader已经加载过了,只是之前是获取pitch属性,现在是获取默normal属性。,

runSyncOrAsync

runSyncOrAsync可以让为同步函数动态添加异步能力,同步和异步由当前函数的执行过程动态决定

如果loader调用this.async()则会动态的支持接收当前函数的异步结果;如果不调用,默认是同步的;

代码语言:javascript复制
function runSyncOrAsync(fn, context, args, callback) {
    var isSync = true; // 当前loader是同步的还是异步的
    var isDone = false; // 当前loader是否执行完成
    var isError = false; // internal error
    var reportedError = false;
    context.async = function async() {
        if (isDone) //... 调用该函数时,如果loader已经执行完成则报错
        isSync = false; // 置为异步loader
        return innerCallback; // 返回异步loader需要的回调,可以通过该回调将异步结果返回给下一个loader
    };
    var innerCallback = context.callback = function () { // 异步回调
        if (isDone) //... 如果已经完成,则报错           
        isDone = true; // 异步loader调用函数传递结果,说明该loader执行完成
        isSync = false; // 置为异步的
        try {
            callback.apply(null, arguments); // 调用者的回调
        } catch (e) //...
    };
    try {
        // 注意:同步执行loader
        var result = (function LOADER_EXECUTION() { return fn.apply(context, args); }());
        // 在上面同步执行的过程中,如果没有发生调用 this.async()
        // 则说明这是一个同步loader
        if (isSync) {
            isDone = true; // 同步loader执行完成
            if (result === undefined) return callback();
            if (result &amp;&amp; typeof result === "object" &amp;&amp; typeof result.then === "function") {
                // 兼容loader返回Promise实现异步,可以提供一个案例
                return result.then(function (r) { callback(null, r); }, callback);
            }
            return callback(null, result);
        }
    } catch (e) // ...
}

默认是同步的,即在没有调用this.async情况下,loader执行完成后会直接调用runSyncOrAsync入参中的callback,结束当前loader的执行。

看到上面方法中给context添加了两个方法asynccallback,如果执行的loader函数中同步调用了这async(),则会设置isSync = false;那么在执行完LOADER_EXECUTION()后,由于isSync是false,所以不会立即调用runSyncOrAsync入参中的callback结束来结束当前loader的执行;当前loader的完成状态由async返回的innerCallback决定,需要调用者主动结束。

另外LOADER_EXECUTION()同步执行过程中可以直接调用this.callbackinnerCallback来结束当前loader的执行。

总结

loader的执行过程如下两种情况:

思考:看到loader执行完成后会返回cacheable、fileDependencies、contextDependencies三个参数的作用❓

代码语言:javascript复制
// NormalModule.js 
// doBuild()
runLoaders({ /*...*/ }, (err, result) => {
      if (result) {
         this.buildInfo.cacheable = result.cacheable;
         this.buildInfo.fileDependencies = new Set(result.fileDependencies);
         this.buildInfo.contextDependencies = new Set(
            result.contextDependencies
         );
      }
      //...
})

0 人点赞