【webpack】loader深度分析

webpack | 2020-05-11 13:37:56 789次 3次

webpack中一般需要loader将非js模块转化为js模块完成打包工作,其中每一个loader就像一个流,负责单一的任务,通过各个loader的组合,实现任务处理。


一、loader使用

1)、通过webpack.config.js配置

module.exports = {
  module: {
    rules: [
      { test: /\.css$/, use: 'css-loader' },
      { test: /\.ts$/, use: 'ts-loader' }
    ]
  }};
  或
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          { loader: 'style-loader' },
          {
            loader: 'css-loader',
            options: {
              modules: true
            }
          }
        ]
      }
    ]
  }
  或
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [ 'style-loader', 'css-loader', 'less-loader' ]
      }
    ]
  }

2)、内联

// 对test.js使用loader1和loader2
import 'loader1!loader2!./test.js'

符号 !   前缀禁用配置文件中的普通loader,比如:require("!raw!./script.js")

符号 !!  前缀禁用配置文件中所有的loader,比如:require("!!raw!./script.js")

符号 -!  前缀禁用配置文件中的pre loader和普通loader,但是不包括post loader

不应该直接使用行内 loader 和 ! 前缀,因为它们是非标准的。它们可在由 loader 生成的代码中使用。

3)、CLI

webpack --module-bind jade-loader --module-bind 'css=style-loader!css-loader'


二、loader执行顺序

正常的loader执行顺序是:use数组中从右到左执行(...index2 -> index1 ->index0 end)。

同时webpack支持enforce参数,可选值有:"pre" | "post",无值为普通loader,所有 loader 通过 前置, 普通, 行内, 后置 排序,并按此顺序使用。

在定义一个loader函数时,可以导出一个pitch方法,这个方法会在loader函数执行前执行。webpack全部的执行有两个阶段(类似dom事件捕获和冒泡):

Pitching阶段: post,inline,normal,pre
Normal阶段:pre,normal,inline,post

如果pitch阶段有返回值,则会阻断后续的loader执行,直接返回当前loader之前的那个loader的normal阶段,如图:

1589180892428635.png

pre loader 配置:图片压缩
普通loader 配置:coffee-script转换
inline loader 配置:bundle loader
post loader 配置: 代码覆盖率工具


三、loader开发

webpack中提供了丰富的api方便loader开发,同时也提供了loader-utils和schema-utils工具。

1)、常见api介绍

this.async():返回一个callback,用来异步调用。在 Node.js 单线程环境下进行耗时长的计算应该使 loader 异步化。但如果计算量很小,同步 loader 也是可以的。

module.exports = function(content, map, meta) {
  var callback = this.async();
  someAsyncOperation(content, function(err, result) {
    if (err) return callback(err);
    callback(null, result, map, meta);
  });
};

this.callback():如果loader中有多个返回值时使用,只有一个值可以直接return value;使用该方法时,loader必须返回undefined。(注意此 api 和上面介绍的写法,可以直接调用this.callback,或使用this.async返回调用)

this.callback(
  err: Error | null,
  content: string | Buffer,
  sourceMap?: SourceMap,
  meta?: any
);

pitch:如果pitch有返回值(只能为string | buffer),则如第二部分中介绍的loader执行顺序所述,没有返回值,则按照正常顺序执行,不会发生阻断行为。loader根据返回值可以分为两种,一种是返回js代码(一个module的代码,含有类似module.export语句)的loader,还有不能作为最左边loader的其他loader。为了解决这种问题,我们需要在style-loader里执行require(css-loader!resouce), 这会把css-loader跑一遍,也就是说如果按正常顺序执行css-loader会跑两遍(第一遍拿到的js代码用不了), 为了只执行一次,style-loader利用了pitching, 在pitching函数里require(css-loader!resouce)。然后返回js代码(style-loader能够作为最左边loader)

module.exports.pitch = function(remainingRquest, precedingRequest, data){
  const script = (
    `
      const style = document.createElement('style');
      style.innerHTML = require(${loaderUtils.stringifyRequest(this, '!!' + remainingRquest)});
      document.head.appendChild(style);
    `
  )
  return script;
}

raw:结果转为二进制,直接挂载到函数上,loader.raw = true;

this.data:在 pitch 阶段和正常阶段之间共享的 data 对象。这里是直接使用,原理如下:

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

测试案例:
module.exports = function (content) {
  console.log(this.data); // {value: 8421}
  return 'res';
}
module.exports.pitch = (remainingRequest, precedingRequest, data) =>{
  data.value = 8421;
  // 此处没有return  会走到 上面normal阶段
  //如果有return值 则 normal阶段会被忽略
}

2)、工具

loader-utils: 内含各种处理loader的options的各种工具函数

schema-utils:  用于校验loader和plugin的数据结构

loaderUtils.stringifyRequest(this, itemUrl)

loaderUtils.stringifyRequest(this, require.resolve("./test"));
// = "../node_modules/some-loader/lib/test.js"

loaderUtils.getOptions(this)

获取loader的options对象

schemaUtils(schema, options)

校验options的格式

3)、loader插件编写

loader插件作用于生成目标代码的过程中,而webpack的plugin的则是作用于目标结果生成后的一些功能处理。一个最基本的loader插件结构就是导出一个函数,返回一个string|buffer即可。通过编写babel-loader和style-loader、less-loader了解下。

babel-loader:(利用现有的babel库api进行代码版本转化)

const babel = require('@babel/core');
const loaderUtils = require('loader-utils');
const path = require('path');
function loader(inputSource) {
  const loaderOptions = loaderUtils.getOptions(this);
  const options = {
    ...options,
    sourceMap: true, //是否生成映射
    filename: path.basename(this.resourcePath) //从路径中获取目标文件名
  }
  const {code, map, ast} = babel.transform(inputSource, loaderOptions);
  // 将内容传递给webpack
  /**
   * code: 处理后的字符串
   * map: 代码的source-map
   * ast: 生成的AST
   */
  this.callback(null, code, map, ast);
}
module.exports = loader;

less-loader:(利用less插件将less代码转为css代码)

let less = require('less');
function loader(source){
    let css = '';
    //利用less的api转化
    less.render(source, (err, c) => {
     css = c.css;
    });
    css = css.replace(/\n/g, '\\n')
    return css
}
module.exports = loader

style-loader:(将代码插入到html)

const loaderUtils = require('loader-utils');
//这里用到了pitch 因为css-loader 可能包含需要动态执行的函数
module.exports.pitch = function(remainingRquest, precedingRequest, data){
  const script = (
    `
      const style = document.createElement('style');
      style.innerHTML = require(${loaderUtils.stringifyRequest(this, '!!' + remainingRquest)});
      document.head.appendChild(style);
    `
  )
  return script;
}

style-loader 源码的 pitch 方法里面调用了 require('!!.../x.css'),这就会把 require 的 css 文件当作新的入口文件,重新链式调用剩余的 loader 函数进行处理。【!!】是一个标志,表示不会再重复递归调用 style-loader,只会调用 css-loader 处理。

通过一些简单举例,大致了解loader的开发流程,可以根据自己的需求自行开发,可以借助webpack现有的api、babel的api、loader-utils工具等方式开发。同时每个loader的职责应该是单一的,只处理自己负责的功能,这样才能更方便的扩展功能。

4)、loader插件配置本地加载

loader文件夹和src同级,其中分别为style-loader.js less-loader.js 等各个loader文件
module: {
    rules: [{
        test: /\.less$/,
        use: [
            path.resolve(__dirname, 'loader', 'style-loader'),
	    path.resolve(__dirname, 'loader', 'less-loader')
        ]
    }]
},
简化写法:
resolveLoader: {
    alias: {// 绝对路径
        style-loader: path.resolve(__dirname, 'loader', 'style-loader')
    }
},
再或者(不推荐,比较耗时)
resolveLoader: {
    // 先从node_modules中查找,没有从loader文件夹中查找所用的loader
    modules: ['node_modules', 'loaders'] 
},
这样在rules就正常使用即可


四、loader运行原理

1589251968838584.png

此图来源,点我跳转

1)实例化 ruleset

RuleSet相当于一个规则过滤器,会将resourcePath应用于所有的module.rules规则,从而筛选出所需的loader。Ruleset 在内部会有一个默认的 module.defaultRules 配置,在真正加载 module 之前会和 webpack config 配置文件当中的自定义 module.rules 进行合并,然后转化成对应的匹配过滤器,在配置中我们可以写各种格式的规则,Ruleset最终将这些格式统一处理为一致。

其中默认配置(webpack/lib/WebpackOptionsDefaulter.js):

this.set("module.defaultRules", "make", options => [
    {
        type: "javascript/auto",
        resolve: {}
    },
    ...
    {
        test: /\.json$/i,
        type: "json"
    },
    {
        test: /\.wasm$/i,
        type: "webassembly/experimental"
    }
]);

在NormalModuleFactory.js中被实例化:

module.exports = class RuleSet{
    constructor(rules) {
        this.references = Object.create(null);
        this.rules = RuleSet.normalizeRules(rules, this.references, "ref-");
    }
	
    static normalizeRules(rules, refs, ident) {}
    static normalizeRule(rule, refs, ident) {}
    static buildErrorMessage(condition, error) {}
    static normalizeUse(use, ident) {}
    static normalizeUseItemString(useItemString) {}
    static normalizeUseItem(item, ident) {}
    static normalizeCondition(condition) {}
    exec(data) {}
    _run(data, rule, result) {}
    indOptionsByIdent(ident) {}
}

经过 normalizeUse 函数的格式化处理,最终的 rule 结果为一个数组,内部的 object 元素都包含 loader/options 等字段:

config中的配置
[
    {
      loader: 'xxx-loader',
      options: {
        data: 'value'
      }
    }
    ...
]
经过 RuleSet 内部的格式化的处理,最终输出的 rules 为:
rules: [
  {
    resource: [Function],
    resourceQuery: [Function],
    use: [{
      loader: 'xxx-loader',
      options: {
        data: 'value'
      }
    }]
  }
  ...
 ]

2)解析 inline-loader

①处理 inline-loaders格式统一处理,进行解析,获取对应 loader 模块信息

②利用 ruleset 实例上的 exec 匹配过滤方法对 webpack.config 中配置的相关 loaders 进行匹配过滤,获取构建这个 module 所需要的配置的的 loaders,并进行解析

③这个过程完成后,便进行所有 loaders 的拼装工作,并传入创建 module 的回调中

// NormalModuleFactory.js

// 是否忽略 preLoader 以及 normalLoader
const noPreAutoLoaders = requestWithoutMatchResource.startsWith("-!");
// 是否忽略 normalLoader
const noAutoLoaders = noPreAutoLoaders || requestWithoutMatchResource.startsWith("!");
// 忽略所有的 preLoader / normalLoader / postLoader
const noPrePostAutoLoaders = requestWithoutMatchResource.startsWith("!!");

// 首先解析出内联的 loader
let elements = requestWithoutMatchResource
  .replace(/^-?!+/, "")
  .replace(/!!+/g, "!")
  .split("!");
let resource = elements.pop(); // 获取资源的路径
// 获取每个loader及对应的options配置(将inline loader的写法变更为module.rule的写法)
elements = elements.map(identToLoaderRequest);
---------------------------------------------------
格式如下:
[{
  loader: '/workspace/node_modules/less/dist/index.js', //找到绝对路径
  options: undefined  //配置
}, {
  loader: '/workspace/node_modules/css-loader/lib/loader.js',
  options: undefined
}]

3)webpack.config.js规则过滤解析

利用 ruleset 实例上的 exec 进行相关的匹配过滤工作。在 webpack 正常的工作流当中,在加载对应的 module 之前首先需要知道加载这个模块具体使用哪些 loader,调用 ruleset 实例上的 exec 去过滤对应的 loader。

class NormalModuleFactory extends Tapable {
    ...
    const result = this.ruleSet.exec({
	resource: resourcePath,
	realResource:
	  matchResource !== undefined
            ? resource.replace(/\?.*/, "")
            : resourcePath,
	resourceQuery,
	issuer: contextInfo.issuer,
	compiler: contextInfo.compiler
	});
    ...
}
result最终格式为
[{
    type: 'use',
    value: {
      loader: 'css-loader',
      options: {}
    },
    enforce: undefined
  }, {
    type: 'use',
    value: {
      loader: 'style-loader',
      options: {
        data: 'something'
      }
    },
    enforce: undefined  // pre | post
}]

然后根据enforce的值进行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);
        }
    } 
    ... 非 use 类型判断,此处忽略
}

最后,使用neo-aysnc来并行解析三类loader数组:

asyncLib.parallel([
    this.resolveRequestArray.bind(
        this,
        contextInfo,
        this.context,
        useLoadersPost,
        loaderResolver
    ),
    this.resolveRequestArray.bind(
        ...
        useLoaders
    ),
    this.resolveRequestArray.bind(
        ...
        useLoadersPre,
    )
    ],
    (err, results) => {
    //放在下面第4小节
    }
);

4)组合

在上面的代码中,预留了一部分,这里将所有类型的loader组合:

if (err) return callback(err);
	if (matchResource === undefined) {
		loaders = results[0].concat(loaders, results[1], results[2]);
	} else {
		loaders = results[0].concat(results[1], loaders, results[2]);
	}
	process.nextTick(() => {
		...
	});
}

其中 matchResource 跑了些demo,发现都是undefined,暂未了解其作用,所以loaders的值如下:

[results[0], loaders, results[1], results[2]]
对应关系:
[post loader, inline loader, normal loader, pre loader]
这里解释了loader的执行顺序问题

5)运行

loader的绝对路径解析完毕后,在NormalModuleFactory的factory钩子中会创建当前模块的NormalModule对象。下面就是运行各个loader。webpack模块处理会首先进行loader的读取和处理。在compilation中有一个方法.addEntry(),它会调用._addModuleChain()会执行一系列的模块方法。对于未build过的模块,最终会调用到NormalModule对象的.doBuild()方法。

648293120-5c85e9575e1bc_articlex.jpg

此图来源,点我跳转

NormalModule.js
class NormalModule extends Module {
	...
    createLoaderContext(resolver, options, compilation, fs) {
        const loaderContext = {...}
    }
    //执行构建
    doBuild(options, compilation, resolver, fs, callback) {
        //创建 context 对象 loader插件中的this就是这个
        const loaderContext = this.createLoaderContext(
            resolver,
            options,
            compilation,
            fs
        );
        //执行loader
        runLoaders(
          {
            resource: this.resource,
            loaders: this.loaders,
            context: loaderContext,
            readResource: fs.readFile.bind(fs)
          },() => { }
        )
        ...
    }
}

我们在编写插件的时候,会用到this上各种方法,那这个this就是loaderContext,然开始loader的运行阶段,逻辑放在了 loader-runner 这个包中,loader-runner中定义了 loader插件所用的 api、异步执行、pitch执行顺序等,loaderIndex贯穿全局,代表loader的索引。

exports.runLoaders = function runLoaders(options, callback) {
    // 模块的路径
    var resource = options.resource || "";
    // 模块所需要使用的 loaders
    var loaders = options.loaders || [];
    // 在 normalModule 里面创建的 loaderContext
    var loaderContext = options.context || {};
    // fs.readFile.bind(fs) node api  读取文件
    var readResource = options.readResource || readFile;

    var splittedResource = resource && splitQuery(resource);
    // 模块实际路径
    var resourcePath = splittedResource ? splittedResource[0] : undefined;
    // 模块路径 query 参数
    var resourceQuery = splittedResource ? splittedResource[1] : undefined;
    // 模块的父路径
    var contextDirectory = resourcePath ? dirname(resourcePath) : null;
        ...
    // createLoaderObject方法给loader挂载一些属性
    /**
        var obj = {
            path: null,
            query: null,
            ...
        };
        Object.preventExtensions禁止扩展属性
    */
    loaders = loaders.map(createLoaderObject);

    loaderContext.context = contextDirectory;
    // 当前正在执行的 loader 索引
    loaderContext.loaderIndex = 0;
    loaderContext.loaders = loaders;
    loaderContext.resourcePath = resourcePath;
    loaderContext.resourceQuery = resourceQuery;
    //异步 默认都是 null
    loaderContext.async = null;
    loaderContext.callback = null;
    //缓存
    loaderContext.cacheable = function cacheable(flag) {
            if(flag === false) {
                requestCacheable = false;
            }
    };
    //加入一个文件作为产生 loader 结果的依赖,使它们的任何变化可以被监听到。
    //例如,html-loader 就使用了这个技巧,当它发现 src 和 src-set 属性时,
    //就会把这些属性上的 url 加入到被解析的 html 文件的依赖中。
    loaderContext.dependency = loaderContext.addDependency = function addDependency(file) {
            fileDependencies.push(file);
    };
    ...
    //每个 loader 在 pitch 阶段和正常执行阶段都可以共享的 data 数据
    Object.defineProperty(loaderContext, "data", {
                enumerable: true,
        get: function() {
            return loaderContext.loaders[loaderContext.loaderIndex].data;
        }
    });
        ...
    // 开始执行每个 loader 上的 pitch 函数
    iteratePitchingLoaders(processOptions, loaderContext, function(err, result) {
        callback(null, {...});
    });
};

picth运行原理分析,在iteratePitchingLoaders方法中执行loadLoader,loader被加载也是在loadLoader.js中完成:

loadLoader.js
...
var module = require(loader.path);
...


var loadLoader = require("./loadLoader"); //加载loader
...
loadLoader(currentLoaderObject, function(err) {
  var fn = currentLoaderObject.pitch; // 获取 pitch 函数
  currentLoaderObject.pitchExecuted = true;
  // 如果这个loader没有提供pitch函数,跳过,index++,递归的调用这个iteratePitchingLoaders
  if(!fn) return iteratePitchingLoaders(options, loaderContext, callback); 

  // 开始执行 pitch 函数
  runSyncOrAsync(
    fn,
    loaderContext, 
    //pitch回调方法中 传递这三个参数
    //后面的loader+资源路径,loadername!的语法\资源路径\data全局对象
    [
        loaderContext.remainingRequest, 
        loaderContext.previousRequest, 
        currentLoaderObject.data = {}
    ],
    function(err) {
      if(err) return callback(err);
      var args = Array.prototype.slice.call(arguments, 1);
      // 根据是否有参数返回来判断是否向下继续进行 pitch 函数的执行
      var hasArg = args.some(function(value) {
        return value !== undefined;
      });
      if(hasArg) {
        // 执行normal阶段的loader,并且index前移,和第二部分的流程图对应
        loaderContext.loaderIndex--;
        iterateNormalLoaders(options, loaderContext, args, callback);
      } else {
        //无返回值的pitch继续执行下一个pitch
        iteratePitchingLoaders(options, loaderContext, callback);
      }
    }
  );
})

那么什么时候执行loader的normal阶段呢?在iteratePitchingLoaders函数中有个判断,pitch完成后loaderIndex的值会大于当前loader数组的长度,此时会去加载normal阶段的loader资源,按照右到左的顺序执行

if(loaderContext.loaderIndex >= loaderContext.loaders.length){
    return processResource(options, loaderContext, callback);
}
function processResource(options, loaderContext, callback) {
  // 从最右侧开始执行loader  业务逻辑中是没有 loader的  所以 长度可能为0
  loaderContext.loaderIndex = loaderContext.loaders.length - 1;
  var resourcePath = loaderContext.resourcePath;
  if(resourcePath) {
    loaderContext.addDependency(resourcePath);
    //读取业务代码需要loader处理的资源文件  待编译的文件
    options.readResource(resourcePath, function(err, buffer) {
        if(err) return callback(err);
        options.resourceBuffer = buffer;
        iterateNormalLoaders(options, loaderContext, [buffer], callback);
    });
  } else {
    iterateNormalLoaders(options, loaderContext, [null], callback);
  }
}
function iterateNormalLoaders(options, loaderContext, args, callback) {
  ...
  //buffer 和 utf8 string 之间的转化
  convertArgs(args, currentLoaderObject.raw);
  //递归执行
  runSyncOrAsync(fn, loaderContext, args, function(err) {
    var args = Array.prototype.slice.call(arguments, 1);
    iterateNormalLoaders(options, loaderContext, args, callback);
  });
}

最后执行 runSyncOrAsync方法,用来做异步或同步的判断,原理就是如果在loader插件内使用了 async 或 callback方法(这两个方法就是同一个),则判定为异步方法,否则是同步,同步中也做了插件返回值是否是promise判断,如果是则异步调用:

function runSyncOrAsync(fn, context, args, callback) {
  var isSync = true;
  var isDone = false;
  ...
  context.async = function async() {
    ...
    isSync = false;
    return innerCallback;
  };
  // this.async 和 this.callback同一个指向
  var innerCallback = context.callback = function() {
	...
	isDone = true;
	isSync = false;
	try {
            callback.apply(null, arguments);
	}...
  };
  try {
      //loader插件中返回值
	var result = (function LOADER_EXECUTION() {
            return fn.apply(context, args);
	}());
	//同步情况
	if(isSync) {
            isDone = true;
            // 第一个判断感觉有点多余。。
            // 这个函数中的callback是进行下一个loader查找运行
            if(result === undefined)
                return callback();
            if(result && typeof result === "object" && typeof result.then === "function") {
                return result.then(function(r) {
                    callback(null, r);
                }, callback);
            }
            return callback(null, result);
	}
  }...
 }


五、总结

通过本文可以了解到如何配置使用 loader,以及 loader 的执行顺序,然后介绍了 loader 插件常用的api以及编写简单一些插件。最后,从源码层面分析了 loader 的执行原理,经历五个步骤,完成 loader 的调用。这个过程结束之后通过回调,回到normalModule.js的runLloader的回调函数处,把转译之后内容复制给模块的_source属性,然后调用模块源码解析,分析资源依赖。

3人赞

分享到: