【前端】webpack loader配置全流程详解

3,341 阅读7分钟

前言

1.主要目的为稍微梳理从配置到loader的流程。另外详解当然要加点源码提升格调(本人菜鸟,有错还请友善指正)

2.被webpack打包的文件,都被转化为一个module,比如import './xxx/x.jpg'require('./xxx/x.js')。至于具体实际怎么转化,交由loader处理

3.下文会使用typescript(劝退警告?)以方便说明有哪些选项和各个选项的值类型

配置语法解析

module属性

module.exports = {
    ...
    module: {
        noParse: /jquery/,
        rules: [
            {
                test: /\.js/,
                exclude: /node_modules/,
                use:[
                    {
                        loader: './loader1.js?num=1',
                        options: {myoptions:false},
                    },
                    "./loader2.js?num=2",
                ]
            },
            {
                test: /\.js/,
                include: /src/,
                loader: './loader1.js!./loader2.js',
            },
        ]
    }
}

上述是展示常见的配置写法。webpack为其选项都编写了typescript声明,这个module属性的声明在 webpack/declarations 中可见:

export interface ModuleOptions {
    // 一般下面这两个
    noParse?: RegExp[] | RegExp | Function | string[] | string;
    rules?: RuleSetRules;
    
    // 这些...已被废弃,即将被删除,不用看
    defaultRules?: RuleSetRules;
    exprContextCritical?: boolean;
    exprContextRecursive?: boolean;
    exprContextRegExp?: boolean | RegExp;
    exprContextRequest?: string;
    strictExportPresence?: boolean;
    strictThisContextOnImports?: boolean;
    unknownContextCritical?: boolean;
    unknownContextRecursive?: boolean;
    unknownContextRegExp?: boolean | RegExp;
    unknownContextRequest?: string;
    unsafeCache?: boolean | Function;
    wrappedContextCritical?: boolean;
    wrappedContextRecursive?: boolean;
    wrappedContextRegExp?: RegExp;
}

noParse 用于让webpack跳过对这些文件的转化,也就是他们不会被loader所处理(但还是会被打包并输出到dist目录)

rules 核心配置,见下文

module.rules属性

module.rules类型是RuleSetRule[],请继续 webpack/declarations 查看其typescript,有哪些属性、属性类型一目了然。

注意RuleSetConditionsRecursive 这个东西在另外一个文件声明,是interface RuleSetConditionsRecursive extends Array<import("./declarations/WebpackOptions").RuleSetCondition> {} ,其实就是export type RuleSetConditionsRecursive = RuleSetCondition[];,代表一个RuleSetCondition数组

意义直接贴中文文档:模块

好了,上面基本是搬运typescript声明,结合文档基本能知道有哪些属性、属性的类型和含义。下面结合源码对文档一些难以理解的地方补充说明。

正文

RuleSet

Rule的规范化(类型收敛)

由上可知一个rule对象,其属性类型有多种可能,所以应该对其规范化,减少底层代码的大量typeof等判断。这是由 RuleSet.js 进行规范化的。下面是经过RuleSet处理后的一个rule对象大致形式:

// rule 对象规范化后的形状应该是:
{
	resource: function(),
	resourceQuery: function(),
	compiler: function(),
	issuer: function(),
	use: [
		{
			loader: string,
			options: string | object, // 源码的注释可能是历史遗留原因,options也可为object类型
			<any>: <any>
		} // 下文称呼这个为use数组的单个元素为 loader对象,规范化后它一般只有loader和options属性
	],
	rules: [<rule>],
	oneOf: [<rule>],
	<any>: <any>,
}

rulesoneOf 是用来嵌套的,里面的也是规范过的rule对象。

它这里的四个函数是webpack用来判断是否需要把文件内容交给loader处理的。如webpack遇到了import './a.js',那么rule.resource('f:/a.js')===true时会才把文件交由rule中指定的loader去处理,resourceQuery等同理。

这里的传入的参数'f:/a.js'就是官网所说的

条件有两种输入值:
resource:请求文件的绝对路径。它已经根据 resolve 规则解析。
issuer: 被请求资源(requested the resource)的模块文件的绝对路径。是导入时的位置。

首先要做的是把Rule.loaderRule.optionsRule.query(已废弃,但尚未删除),全部移动到Rule.use数组元素的对象里。这主要由static normalizeRule(rule, refs, ident)函数处理,代码主要是处理各种“简写”,把值搬运到loader对象,做一些报错处理,难度不大看一下即可,下面挑它里面的“条件函数”规范化来说一说。

Rule.resource 规范化

由上可知这是一个“条件函数”,它是根据我们的配置中的testincludeexcluderesource规范化而生成的。源码180多行中:

...
if (rule.test || rule.include || rule.exclude) {
    checkResourceSource("test + include + exclude");
    condition = {
        test: rule.test,
        include: rule.include,
        exclude: rule.exclude
    };
    try {
        newRule.resource = RuleSet.normalizeCondition(condition);
    } catch (error) {
        throw new Error(RuleSet.buildErrorMessage(condition, error));
    }
}

if (rule.resource) {
    checkResourceSource("resource");
    try {
        newRule.resource = RuleSet.normalizeCondition(rule.resource);
    } catch (error) {
        throw new Error(RuleSet.buildErrorMessage(rule.resource, error));
    }
}

文档中说Rule.testRule.resource.test的简写,实际就是这串代码。

checkResourceSource 用来检查是否重复配置,即文档中提到的:如果你提供了一个Rule.test选项,就不能再提供 Rule.resource


最后 RuleSet.normalizeCondition 生成一个“条件函数”,如下:

...
static normalizeCondition(condition) {
    if (!condition) throw new Error("Expected condition but got falsy value");
    if (typeof condition === "string") {
        return str => str.indexOf(condition) === 0;
    }
    if (typeof condition === "function") {
        return condition;
    }
    if (condition instanceof RegExp) {
        return condition.test.bind(condition);
    }
    if (Array.isArray(condition)) {
        const items = condition.map(c => RuleSet.normalizeCondition(c));
        return orMatcher(items);
    }
    if (typeof condition !== "object") {
        throw Error(
            "Unexcepted " +
                typeof condition +
                " when condition was expected (" +
                condition +
                ")"
        );
    }

    const matchers = [];
    Object.keys(condition).forEach(key => {
        const value = condition[key];
        switch (key) {
            case "or":
            case "include":
            case "test":
                if (value) matchers.push(RuleSet.normalizeCondition(value));
                break;
            case "and":
                if (value) {
                    const items = value.map(c => RuleSet.normalizeCondition(c));
                    matchers.push(andMatcher(items));
                }
                break;
            case "not":
            case "exclude":
                if (value) {
                    const matcher = RuleSet.normalizeCondition(value);
                    matchers.push(notMatcher(matcher));
                }
                break;
            default:
                throw new Error("Unexcepted property " + key + " in condition");
        }
    });
    if (matchers.length === 0) {
        throw new Error("Excepted condition but got " + condition);
    }
    if (matchers.length === 1) {
        return matchers[0];
    }
    return andMatcher(matchers);
}

这串代码主要就是根据string、RegExp、object、function类型来生成不同的“条件函数”,难度不大。

notMatcherorMatcherandMatcher 这三个是辅助函数,看名字就知道了,实现上非常简单,不贴源码了。有什么不明白的逻辑,代入进去跑一跑就知道了

Rule.use 规范化

接下来我们要把Rule.use给规范成上面提到的那种形式,即让loader对象只保留loaderoptions这两个属性(当然,并不是它一定只有这两个属性)。源码如下:

...
static normalizeUse(use, ident) {
    if (typeof use === "function") {
        return data => RuleSet.normalizeUse(use(data), ident);
    }
    if (Array.isArray(use)) {
        return use
            .map((item, idx) => RuleSet.normalizeUse(item, `${ident}-${idx}`))
            .reduce((arr, items) => arr.concat(items), []);
    }
    return [RuleSet.normalizeUseItem(use, ident)];
}

static normalizeUseItemString(useItemString) {
    const idx = useItemString.indexOf("?");
    if (idx >= 0) {
        return {
            loader: useItemString.substr(0, idx),
            options: useItemString.substr(idx + 1)
        };
    }
    return {
        loader: useItemString,
        options: undefined
    };
}

static normalizeUseItem(item, ident) {
    if (typeof item === "string") {
        return RuleSet.normalizeUseItemString(item);
    }

    const newItem = {};

    if (item.options && item.query) {
        throw new Error("Provided options and query in use");
    }

    if (!item.loader) {
        throw new Error("No loader specified");
    }

    newItem.options = item.options || item.query;

    if (typeof newItem.options === "object" && newItem.options) {
        if (newItem.options.ident) {
            newItem.ident = newItem.options.ident;
        } else {
            newItem.ident = ident;
        }
    }

    const keys = Object.keys(item).filter(function(key) {
        return !["options", "query"].includes(key);
    });

    for (const key of keys) {
        newItem[key] = item[key];
    }

    return newItem;
}

这几个函数比较绕,但总体来说难度不大。

这里再稍微总结几点现象:

  1. loader: './loader1!./loader2',如果在Rule.loader指明了两个以以上loader,那么不可设置Rule.options,因为不知道该把这个options传给哪个loader,直接报错
  2. -loader不可省略,如babel!./loader是非法的,因为在webpack/lib/NormalModuleFactory.js440行左右,已经不再支持这种写法,直接报错叫你写成babel-loader
  3. loader: './loader1?num1=1&num2=2'将被处理成{loader: './loader', options: 'num=1&num=2'},以?进行了字符串分割,最终处理成规范化loader对象

RuleSet规范化到此结束,有兴趣的可以继续围观源码的exec方法和构造函数

loader

接下来算是番外,讨论各种loader如何读取我们配置的对象。

options属性在webpack的传递与处理

首先一个loader就是简单的导出一个函数即可,比如上面举例用到的loader1.js:

module.exports = function (content){
    console.log(this)
    console.log(content)
    return content
}

这个函数里面的this被绑定到一个loaderContext(loader上下文)中,官方api: loader API

直接把这个loader1.js加入到配置文件webpack.config.js里面即可,在编译时他就会打印出一些东西。

简单而言,就是在loader中,我们可以通过this.query来访问到规范化loader对象options属性。比如{loader: './loader1.js', options: 'num1=1&num=2'} ,那么this.query === '?num1=1&num=2'

问题来了,这个问号哪里来的?如果它是一个对象?
webpack通过loader-runner来执行loader,这个问题可以去loader-runner/lib/LoaderRunner.js,在createLoaderObject函数中有这么一段:

...
if (obj.options === null)
    obj.query = "";
else if (obj.options === undefined)
    obj.query = "";
else if (typeof obj.options === "string")
    obj.query = "?" + obj.options;
else if (obj.ident) {
    obj.query = "??" + obj.ident;
}
else if (typeof obj.options === "object" && obj.options.ident)
    obj.query = "??" + obj.options.ident;
else
    obj.query = "?" + JSON.stringify(obj.options);

以及在runLoaders函数里面的这段:

Object.defineProperty(loaderContext, "query", {
    enumerable: true,
    get: function() {
        var entry = loaderContext.loaders[loaderContext.loaderIndex];
        return entry.options && typeof entry.options === "object" ? entry.options : entry.query;
    }
});

总结来说,当options存在且是一个对象时,那么this.query就是这个对象;如果options是一个字符串,那么this.query等于一个问号+这个字符串

多数loader读取options的方法

const loaderUtils=require('loader-utils')
module.exports = function (content){
    console.log(loaderUtils.getOptions(this))
    return content
}

借助loader-utils读取。那么接下来走进loaderUtils.getOptions看看:

const query = loaderContext.query;
if (typeof query === 'string' && query !== '') {
  return parseQuery(loaderContext.query);
}
if (!query || typeof query !== 'object') {
  return null;
}
return query;

这里只复制了关键代码,它主要是做一些简单判断,对字符串的核心转换在parseQuery上,接着看:

const JSON5 = require('json5');
function parseQuery(query) {
  if (query.substr(0, 1) !== '?') {
    throw new Error(
      "A valid query string passed to parseQuery should begin with '?'"
    );
  }
  query = query.substr(1);
  if (!query) {
    return {};
  }
  if (query.substr(0, 1) === '{' && query.substr(-1) === '}') {
    return JSON5.parse(query);
  }
  const queryArgs = query.split(/[,&]/g);
  const result = {};
  queryArgs.forEach((arg) => {
    const idx = arg.indexOf('=');
    if (idx >= 0) {
      let name = arg.substr(0, idx);
      let value = decodeURIComponent(arg.substr(idx + 1));

      if (specialValues.hasOwnProperty(value)) {
        value = specialValues[value];
      }
      if (name.substr(-2) === '[]') {
        name = decodeURIComponent(name.substr(0, name.length - 2));
        if (!Array.isArray(result[name])) {
          result[name] = [];
        }
        result[name].push(value);
      } else {
        name = decodeURIComponent(name);
        result[name] = value;
      }
    } else {
      if (arg.substr(0, 1) === '-') {
        result[decodeURIComponent(arg.substr(1))] = false;
      } else if (arg.substr(0, 1) === '+') {
        result[decodeURIComponent(arg.substr(1))] = true;
      } else {
        result[decodeURIComponent(arg)] = true;
      }
    }
  });
  return result;
}

使用了json5库,以及自己的一套参数的转换。

总结来说,只要你能确保你使用的loader是通过loader-utils来获取options对象的,那么你可以直接给options写成如下字符串(inline loader中常用,如import 'loader1?a=1&b=2!./a.js'):

options: "{a: '1', b: '2'}" // 非json,是json5格式字符串,略有出入,请右转百度

options: "list[]=1&list=2[]&a=1&b=2" // http请求中常见的url参数部分

更多示例可在 webpack/loader-utils 中查看

最后

🙃舒服,再也不怕查资料时遇到的各种奇葩写法了