webpack | loader二三事 | loader是如何执行的

1,925 阅读3分钟

前言

承接上文(介绍babel类别及如何实现,有兴趣的同学可以戳它,webpack | loader二三事 | loader类别),继续我们的babel话题吧

核心:在webpack中,对于loader的处理是基于一个第三方库loader-runner的,其模块内部导出了一个函数runLoaders,webpack将loader地址及处理完成后回调传递给它,它则将经过loader处理过后的结果返回给webpack(即回调参数)。

特点:看文档来呗

image-20201222161532582

总结而言

image-20201222170341597

特性
  • loader是运行在node环境的,也就意味着可以使用所有node的API
  • Plugins可以结合loader使用
  • loader都有options选项,可以传递参数

目标

阅读本文期待你能收获什么
  • webpack对loader是如何进行处理的
  • loader中如果存在异步逻辑(比如请求接口),如何实现中断迭代
  • 实现一个babel-loader
需实现
  • loader其实就是函数,loader身上会有个pitch方法,执行时会先执行所有loader的pitch函数,执行完就触发读取资源的操作,然后将资源交给loader函数,执行所有loader函数

  • 当pitch函数有返回值时,则终止pitch的迭代,开始loader的逆向迭代

  • loader支持异步处理操作

    • 效果:可以在loader中执行异步(如setTimeout),然后将继续执行的控制权交由loader

    • 使用:即loader或pitch执行时的上下文对象(this)存在async函数,调用之后会返回一个innnerCallback,loader的迭代将不再执行,直到用户调用innerCallback

    • 例子

      function loader(source) {
          let callback = this.async();
          console.log(new Date());
          setTimeout(()=>{
           callback(
               null,
               source + "//async1"
           )
          },3000)
      }
      

正文

第一阶段:实现pitch无返回值且loader无异步逻辑场景

先看使用
webpack.config.js
module: {
        rules: [
            {
                enforce: 'pre',
                test: /\.js$/,
                use: ["pre-loader","pre-loader2"]
            },
            {
                test: /\.js$/,
                use: ["normal-loader","normal-loader2"]
            },
            {
                enforce: 'post',
                test: /\.js$/,
                use: ["post-loader","post-loader2"]
            }
        ]
    }
loader(每个loader都是这样的)
function loader(source) {
    console.log('pre-loader1');
    return source+'//pre-loader1'
}
loader.pitch = function (){
    console.log('pitch pre-loader1');
}
module.exports = loader
index.js
debugger;
let sum = (a,b) => {
    return a + b;
}
执行后控制台打印
pitch pre-loader1
pitch pre-loader1
pitch pre-loader2
pitch pre-loader2
pitch normal-loader1
pitch normal-loader1
pitch normal-loader2

可以看出,执行顺序是:先loader的pitch-》loader本身

核心问题

没啥核心问题,迭代嘛

解决思路
定义处理loader的入口函数runLoaders
  • 入参:

    • opts

       resource:path.join(__dirname, resource), // 加载资源的绝对路径
       loaders, // loaders的数组  也是绝对路径的数组
       readResource:fs.readFile.bind(fs)   // 读取文件的方法  默认是readFile
      
    • callback

      (err,data)=>{
          if(err){
              console.log(err);
              return;
          }
          let {
            result: [ ], // index.js(入口文件)的文件内容
            resourceBuffer: null, // index.js 的 buffer格式
            ...
          } =  data;
      }
      
  • 返回:(即传给callback的data)

    {
      result: [
        'debugger;\r\n' +
          'let sum = (a,b) => {\r\n' +
          '    return a + b;\r\n' +
          '}//inline-loader2//inline-loader1//pre-loader2//pre-loader1'
      ],
      resourceBuffer: <Buffer 64 65 62 75 67 67 65 72 3b 0d 0a 6c 65 74 20 73 75 6d 20 3d 20 28 61 2c 62 29 20 3d 3e 20 7b 0d 0a 	20 20 20 20 72 65 74 75 72 6e 20 61 20 2b 20 62 3b ... 3 more bytes>
    }
    
实现

传参阶段:1. 整合inlineloader和config文件中的loader 2. 组装loaders数组,由loader文件的绝对路径组成

函数实现阶段:

  • 根据loader地址数组创建loader对象数组

    {
            path: '', // loader绝对路径
            query: '', // 查询参数
            fragment: '', // 标识
            normal: '', // normal函数
            pitch: '',  // pitch函数
            raw: '', // 是否是buffer
            data: '', // 自定义对象, 每个loader都会有一个data自定义对象
            pitchExecuted: '', // 当前 loader的pitch函数已经执行过了 不需要在执行
            normalExecuted: '' // 当前 loader的normal函数已经执行过了 不需要在执行
        }
    且存在监听器
    Object.defineProperty(obj,'request', {
            get(){
                return obj.path + obj.query
            }
    }
    
  • 整合loader的上下文对象,在调用loader或者pitch时作为this,关键属性:

    loaders // loader对象组成的数组
    context  // 指向要加载的资源的目录  (即index.js的父文件夹的绝对路径)
    loaderIndex // 当前处理的loader索引  从0开始
    resourcePath // 资源地址 (即index.js的绝对路径)
    resourceQuery // 查询参数
    async// 是一个方法,可以设定loader的执行从同步到异步
    //======= 以下均是一definePropery的形式定义 ==========//
    resource // 资源绝对地址+查询参数+标识(多数情况为空)
    request  // 所有loader的request结合资源绝对路径以!拼接成的字符串
    remainingRequest // 当前处理loader之后的所有loader的request结合资源绝对路径以!拼接成的字符串
    currentRequest // 当前处理loader及其所有loader的request结合资源绝对路径以!拼接成的字符串 (与remainingRequest相比就多了个本身)
    previousRequest // 已加载过的loader的request结合资源绝对路径以!拼接成的字符串
    query // 如果用户配了options则用options  否则用用户的query【即inline】
    data // 当前loader的data
    
  • 定义迭代pitch的函数

    /**
     * 迭代loader的patch函数
     * @param {*} options  webpack自定义的对象 存在两个参数  resourceBuffer 存储资源原始数据  readSource  读取文件的函数
     * @param {*} loaderContext   loader的上下文对象
     * @param {*} callback   包裹用户定义回调函数的函数 执行时会执行用户的回调  包裹一层的意义是如果抛出异常,会将err对象传给用户cb,而不是直接报错  这个callback其实是在迭代loader时才会执行
     */
    function iteratePitchingLoaders(opts,loaderContext,callback) {
        // 如果patch执行完了
        if(loaderContext.loaderIndex >= loaderContext.loaders.length){
    		// 则:先读取文件 再迭代执行loader函数
            return processResource(opts,loaderContext,callback);
        }
    
        let currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];
        if(currentLoaderObject.pitchExecuted){
            loaderContext.loaderIndex++;
        }
        // 进行loader对象的装载,主要是三个属性的赋值
                    //  normal(loader本身)会通过require函数进行获取
                    //  pitch(pitch函数本身)  从normal上获取
                    //  raw (设置返回的文件原始数据的数据类型,默认false 为true时设为buffer)
        loadLoader(currentLoaderObject);
    
        let pitchFunction = currentLoaderObject.pitch;
        currentLoaderObject.pitchExecuted = true;
        // 如果当前loader不存在pitch函数 则继续向下执行
        if(!pitchFunction){
            return iteratePitchingLoaders(opts,loaderContext,callback);
        }
        // 执行pitch函数,并传递参数
        let result = fn.apply(context,[
                loaderContext.remainingRequest,
                loaderContext.previousRequest,
                loaderContext.data={}
            ]);
        // 递归
        iteratePitchingLoaders(opts,loaderContext,callback)
    }
    

    其实逻辑上来看还是蛮简单的,就是以loaderIndex为开关,先++,在执行完所有pitch后就先读取文件 再迭代执行loader函数(loaderIndex--);

    但要注意,这里是特地简化了的,因为缺少了两个场景,异步、和pitch有返回值的情况;它们分别代表着:将继续迭代的执行权交由loader,以及不迭代之后的pitch而是直接转为迭代对应的loader前一个loader;后文详谈;接下来,我们这个场景就差两步了:资源文件的读取,loader的迭代

第二阶段:资源文件的读取

那就进入到processResource函数吧

/**
 * patch执行完成后,先读取文件 再迭代执行loader函数
 * @param {*} options
 * @param {*} loaderContext
 * @param {*} callback
 */
function processResource(options,loaderContext,callback) {
    loaderContext.loaderIndex--;
    let resourcePath = loaderContext.resourcePath;
    options.readSource(resourcePath,function(err,buffer) {
        if(err){
            return callback(err)
        }
        //  resourceBuffer 代表 资源原始内容
        options.resourceBuffer = buffer;
        iterateNormalLoaders(
            options,
            loaderContext,
            [buffer],
            callback
        )
    });
}

第三阶段:loader的迭代

loader函数和pitch的处理过程是相似的,就不加赘述了,show the code

/**
 * 迭代loader函数本身
 * @param {*} options  webpack自定义的对象 存在两个参数  resourceBuffer 存储资源原始数据  readSource  读取文件的函数
 * @param {*} loaderContext   loader的上下文对象
 * @param {*} args   包裹资源原始数据的数组
 * @param {*} callback   包裹用户定义回调函数的函数 执行时会执行用户的回调  包裹一层的意义是如果抛出异常,会将err对象传给用户cb,而不是直接报错
 */
function iterateNormalLoaders(options,loaderContext,args,callback) {

    // 当执行完所有loader 则执行用户定义回调并将读取结果返回
    if(loaderContext.loaderIndex < 0){
        return callback(null,args);
    }

    let currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];
    if(currentLoaderObject.normalExecuted){
        loaderContext.loaderIndex = loaderContext.loaderIndex - 1;
        return iterateNormalLoaders(options,loaderContext,args,callback);
    }
    let normalFn = currentLoaderObject.normal;
    currentLoaderObject.normalExecuted = true;
    // 设置返回数据的格式 buffer or not
    convertArgs(args,currentLoaderObject.raw);
    runSyncOrAsync(normalFn,loaderContext,args,function(err){
        if(err) return callback(err);
        // 注意第一个是error 需要去掉
        let args = Array.prototype.slice.call(arguments,1);
        iterateNormalLoaders(options,loaderContext,args,callback);
    })

}

唯一需要注意的就是,要在结束时调用用户传过来的回调,将读取到的文件资源交还;至此,我们就走通了正常场景下的loader执行啦。

第四阶段:实现异步

为了实现异步的处理,我们可以联想到koa中的TJ大神写的co库,应用于对promise的递归处理,其本质是对next函数的把控,有兴趣的同学可见此文KOA核心解析,不去看也没关系啦,原理和本文实现其实一样的,主要在于两点

  1. 不直接调用函数,而是用一个函数去包裹,从而进行判断
  2. 设定开关,默认开启,开启时函数的递归执行正常调用,当用户调用了async函数后则将开关设为false,再将迭代的逻辑包裹在另一个函数中,这样,只有用户调用了这个内部函数,迭代才会继续向下执行,就实现了“控制权交还”的逻辑了

话不多说,看代码+注释才清晰明了

执行处的改变
 // 原先
 // 执行pitch函数,并传递参数
    let result = fn.apply(context,[
            loaderContext.remainingRequest,
            loaderContext.previousRequest,
            loaderContext.data={}
        ]);
    // 递归
    iteratePitchingLoaders(opts,loaderContext,callback)
// ================ 之后
// 开始执行pitch函数  为了支持异步  webpack定义了runSyncOrAsync函数
    runSyncOrAsync(
        pitchFunction, // 要执行的pitch函数
        loaderContext, // 上下文对象
        // 要传递给pitchFunction的参数数组
        [
            loaderContext.remainingRequest,
            loaderContext.previousRequest,
            loaderContext.data={}
        ],
        function(err,args) {
            // 如果args存在,说明这个pitch有返回值
            if(args){
                loaderContext.loaderIndex--;
                processResource(opts,loaderContext,callback)
            }else{// 如果没有返回值则执行下一个loader的pitch函数
                iteratePitchingLoaders(opts,loaderContext,callback)
            }

        }
    )

包裹函数runSyncOrAsync
/**
 * 为了支持异步  webpack定义了runSyncOrAsync函数  在上下文对象上挂载async函数  返回值是一个函数innerCallBack 当innerCallBack执行时pitch的迭代才会向下执行
 *      实现效果:当loader函数在pitch(loader本身迭代时同样适用)中调用this.async()时 pitch函数的迭代被中断 直到用户主动调用innerCallBack才会继续执行,并且会将innerCallBack的参数传递给callback
 *      实现逻辑:1. 执行pitch函数 获得pitch返回值
 *                2. 定义开关isSync控制同异步 默认为true
 *                      2.1 为true时直接执行回调,执行时则会继续pitch函数的迭代
 *                      2.2 为false时(即用户调用了async)将执行回调逻辑交给innerCallback
 * @param {*} fn 要执行的pitch函数
 * @param {*} context 上下文对象
 * @param {*} args 要传递给pitchFunction的参数数组
 * @param {*} callback  回调,执行时则会继续pitch函数的迭代
 */
function runSyncOrAsync(fn,context,args,callback) {
    let isSync = true; // 默认是同步
    let isDone = false; // 是否完成,是否执行过此函数了
    // 调用context.async  this.async  可以吧同步编程异步,表示这个loader里的代码是异步的
    context.async = function(){
        isSync = false;
        return innerCallback;
    }
    // 返回给用户  调用时才继续向下执行
    const innerCallback = context.callback = function(){
        isDone = true;
        isSync = false;
        callback.apply(null,arguments); // 执行callback
    }
	// 执行pitch
    let result = fn.apply(context,args);
	// 如果同步开关开启才继续迭代执行 否则中断
    if(isSync){
        isDone = true;

        return callback.apply(null,result && [null,result]);
    }
}

image-20201223222853194

实现pitch有返回值时中断返回

有了上面的基础,这就更简单了,在包裹函数上肯定可以拿到pitch的返回值,进行判断,如果有值则loaderIndex--,并直接调用proceeResource函数进行读取资源、loader迭代

loaderContext.loaderIndex--;
processResource(opts,loaderContext,callback)

第四阶段:实现babel-loader

使用自己实现的loader

webpack.config.js 中存在配置resolveLoader,其值是个数组,作用是定义寻找loader的目录的优先级

resolveLoader:{
        modules:[
            'node_modules',
            path.join(__dirname,'./loaders')
        ]
    },
module: {
        rules: [
            {
                test: /\.js$/,
                use: [
                    {
                        loader: 'babel-loader',
                        options: {}
                    }
                ]
            }
        ]
    },
开始实现babel-loader
loader其实就是个函数,所以我们可以导出个函数
//在loader执行的时候this会指向loaderContext对象,它上面有一个callback方法
function loader(source){
 。。。
}
module.exports = loader;
内部因为babel核心库,进行编译
let babel = require('@babel/core');
//在loader执行的时候this会指向loaderContext对象,它上面有一个callback方法
function loader(source){
  let options={
    presets:["@babel/preset-env"],//配置预设,它是一个插件包,这里面插件
    sourceMap:true,//生成sourcemap文件 才可以调试真正的源码
    filename:this.resourcePath.split('/').pop()  // 代码调试时可以显示源文件名
  };
  //转换后的es5代码  新的source-map文件 ast抽象语法树
  let {code,map,ast} = babel.transform(source,options);
  //如果babel转换后提供了ast抽象语法树,那么webpack会直接 使用你这个loader提供 的语法树
  //而不再需要自己把code再转成语法树了
  //内置的
  //当这个loader 返回一个值的时候可以直接 return
  //如果想返回多个值 callback();
  return this.callback(null,code,map,ast);
}
module.exports = loader;

结尾

言尽于此,loader的处理的大致脉络我们也就了解的差不多啦(反正不止于尬聊面试zzz),困了困了,打完收工

感谢阅读,希望对社区有微薄贡献