深度理解 webpack loader 配置原理 ——webpack5 中的 RuleSetCompiler 类型

138 阅读6分钟

一、前文回顾

上文就讨论一个生成 rule 的插件 —— UseEffectRulePlugin,这个插件是处理 rule.use 配置的,内部通过一系列的操作吧 use 数组变成标准的 effect 对象。

effect 对象,这里是 webpack 源码级别的对象,其中包含 type 和 value 两个属性:{ type, value },这两个属性的意义如下:

  • type:标识当前的 loader 它被应用的时机,即前置、后置、还是普通 loader;
  • value:这里面包含了 loader 所有的信息,包括 loader 本身、传递给 loader 的 options 对象以及当前 loader 的标识 ident

好了,到这里我们说完了 webpack 内部用于标准化 loader 的四个插件,四个插件分别对应不同的规则,最后这个则是处理 use 这个终极 effect 的。

本文我们将会解密,这些 effect 生效的原理!他们的生效依托于一个工具类—— RuleSetCompiler!

二、RuleSetCompiler 类

前面两篇其实一直在讨论 RuleSetCompiler 这个类的参数—— BasicMatcherRulePlugin、ObjectMatcherRulePlugin、BasicEffectRulePlugin、UseEffectRulePlugin 的实例!

现在我们来看看 RuleSetCompiler 这个类!

class RuleSetCompiler {

    constructor () {}
    
    compile () {}
    
    compileRules () {}
    
    compileRule () {}
    
    compileCondition () {}
    
    combineConditionsOr () {}
    
    combineConditionsAnd () {}
    
    error () {}
}

接下来我们将要研究包括构造函数在内的以上这 8 个方法:

2.1 constructor

class RuleSetCompiler {
    constructor(plugins) {
        // 1.
        this.hooks = Object.freeze({
            rule: new SyncHook([
                "path",
                "rule",
                "unhandledProperties",
                "compiledRule",
                "references"
            ])
        });
        
        // 2.
        if (plugins) {
            for (const plugin of plugins) {
                plugin.apply(this);
            }
        }
    }
}

该类型在 NMF 中初始化,接收的一堆插件实例,这些插件实例都是用于处理各种 Rule 的,比如 rule.loader、rule.use 等。

构造函数做的事情很简单:

  1. 初始化了 RuleSetCompiler 实例的 hooks 对象,这个里面只有一个 hook —— rule,同步 hook;
  2. 如果初始化的时候传递了 plugins 数组,则遍历这个 plugins 数组,逐个调用各个插件的 apply 方法,完成插件初始化。这些插件都会向 hooks.rule 添加订阅事件,这些时间就是注册的各个 rule 的处理;

2.2 compile

该方法接受 ruleSet 对象,返回一个带有 exec 属性的对象:

class RuleSetCompiler {
    compile(ruleSet) {
        // 1.
        const refs = new Map();
        
        // 2.
        const rules = this.compileRules("ruleSet", ruleSet, refs);
        
        // 3.
        const execRule = (data, rule, effects) => { /* .... */ };

        // 4.
        return {
            references: refs,
            exec: data => {
             
                const effects = [];
                for (const rule of rules) {
                    execRule(data, rule, effects);
                }
                return effects;
            }
        };
    }

}

2.2.1 参数 ruleSet

先来看看 ruleSet 传入了什么:

class NormalModuleFactory {
    constructor () {
        // ....
        this.ruleSet = ruleSetCompiler.compile([
            {
                rules: options.defaultRules
            },
            {
                rules: options.rules
            }
        ]);
        // ...
    }
}

这个 compile 被调用的过程发生在 NMF 实例化的过程中,从上面的代码我们可以清晰的看出所谓 ruleSet 就是个数组,数组项均为 带有 rules 属性的对象,结合调用我们看看里面都有什么!

image.png

结合前面我们已经讨论过的 rules,从图中我们可以看出第一项是是 webpack 内部提供的 defaultRules,第二项则是我们在 webpack.config.js 中声明的 module.rules 配置项。

2.2.2 整体逻辑

整个过程分为 4 个步骤:

  1. 声明一个 refs 常量,值为 Map 实例对象;
  2. 调用 this.compileRules 方法编译规则,返回值保存在 rules 上;
  3. 声明 execRule 内部方法,该方法是实现 exec 功能的的核心方法,下面重点展开讲;

2.2.3 execRule 方法

该方法是计算某一个 request 或者说一个 path 应该应用那些规则以及这些规则背后对应 loader 的核心实现,从上面的代码可以看出 nmf.ruleSet.exec 内部就是执行的这个方法。

下面我们看看这个方法的核心实现:

 const execRule = (data, rule, effects) => {
    // 1.
    for (const condition of rule.conditions) {
        const p = condition.property;
        
        // 2.
        if (Array.isArray(p)) {
            // 3
            let current = data;
            
            // 4.
            for (const subProperty of p) {
                if ( 
                    current &&
                    typeof current === "object" &&
                    Object.prototype.hasOwnProperty.call(current, subProperty)
                ) {
                    current = current[subProperty];
                } else {
                    
                    current = undefined;
                    break;
                }
            }
            // 5.
            if (current !== undefined) {
                if (!condition.fn(current)) return false;
                continue;
            }
        } else if (p in data) {
            // 6.
            const value = data[p];
            if (value !== undefined) {
                if (!condition.fn(value)) return false;
                continue;
            }
        }
        // 7.
        if (!condition.matchWhenEmpty) {
            return false;
        }
    }
    
    // 8.
    for (const effect of rule.effects) {
 
        if (typeof effect === "function") {
            const returnedEffects = effect(data);
            for (const effect of returnedEffects) {
                effects.push(effect);
            }
        } else {
            effects.push(effect);
        }
    }
    // 9.
    if (rule.rules) {
        for (const childRule of rule.rules) {
            execRule(data, childRule, effects);
        }
    }
    
    // 10.
    if (rule.oneOf) {
        for (const childRule of rule.oneOf) {
            if (execRule(data, childRule, effects)) {
                break;
            }
        }
    }
    
    // 11.
    return true;
};

整个过程分为 16 个步骤:

  1. 先处理 condition, 遍历 rule.conditions,从每个 condition 中取出 property 属性赋值给常量 p;
  2. 判断 p 如果是数组,则进行后续操作;
  3. 缓存当前 data 到 current;
  4. 进一步遍历 p,尝试检测 p 中是否包含当前的规则属性,如果包含则则需要进一步验证,如果有一个不包含则说明不满足条件终止对 p 的遍历,同时置空 current 变量;
  5. 这里说明 current 是个对象,此时调用 condition.fn 这个验证函数进行验证,如果验证没通过则返回 false,说明没有匹配到!
  6. 走到这里说明 data.property 不是数组并且 data 中包含 p 属性,最常见的就是字符串,代码执行走的就是这个分支,此时判断 data 中是否包含这个属性,如果包含则取出对应的值,传给验证函数 condition.fn,如果验证未通过,则返回 false;
  7. 这里说明针对 property 的验证均未匹配到,此时如果 condition 条件中没有允许空之,即没有 matchWhenEmpty 属性,则此时说明为匹配,返回 false;
  8. 从这里开始处理 rule.effects,effect 的处理比较粗暴,直接遍历 rule.effects,判断各个 effect 是否为函数数据类型,如果是函数,则传入 data 尝试调用 effect 并获取其返回值,然后再遍历该返回值,把返回值加入到 effects 中;如果 effect 不是函数数据类型,则直接加入到 effects 中;
  9. 这里开始处理 rule.rules 嵌套 rules,这里就是一个简单的递归调用,为各个子规则调用 execRule 方法;
  10. 这里处理 rule.oneOf,oneOf 和上面的嵌套 rules 不同,oneOf 是有一个命中即命中,所以遍历时一旦命中则需要 break 终止对 oneOf 的进一步处理;
  11. 最后则是 execRule 的返回结果 true,结合前面的 rules 和 oneOf 可知这个方法 返回 boolean 是为控制递归过程中的执行流;

2.3 compileRules 和 compileRule

class RuleSetCompiler {

    compileRules(path, rules, refs) {
        return rules.map((rule, i) =>
            this.compileRule(`${path}[${i}]`, rule, refs)
        );
    }
    
    compileRule(path, rule, refs) {
        // 1.
        const unhandledProperties = new Set(
            Object.keys(rule).filter(key => rule[key] !== undefined)
        );

        // 2.
        const compiledRule = {
            conditions: [],
            effects: [],
            rules: undefined,
            oneOf: undefined
        };

        // 3.
        this.hooks.rule.call(path, rule, unhandledProperties, compiledRule, refs);

        // 4.
        if (unhandledProperties.has("rules")) {
            unhandledProperties.delete("rules");
            const rules = rule.rules;
            if (!Array.isArray(rules))
                throw this.error(path, rules, "Rule.rules must be an array of rules");
            compiledRule.rules = this.compileRules(`${path}.rules`, rules, refs);
        }
    
    
        // 5.
        if (unhandledProperties.has("oneOf")) {
            unhandledProperties.delete("oneOf");
            const oneOf = rule.oneOf;
            if (!Array.isArray(oneOf))
                throw this.error(path, oneOf, "Rule.oneOf must be an array of rules");
            compiledRule.oneOf = this.compileRules(`${path}.oneOf`, oneOf, refs);
        }
        // 6.
        if (unhandledProperties.size > 0) {
            throw this.error(
                path,
                rule,
                `Properties ${Array.from(unhandledProperties).join(", ")} are unknown`
            );
        }

        // 7.
        return compiledRule;
    }
}

1. ruleSetCompiler.compileRules

ruleSetCompiler.compileRules 方法内部很简单,遍历 rules 并且为每一个 rule 调用 ruleSetCompiler.compileRule 方法编译 rule,实现 rule 的标准化。

2. ruleSetCompiler.compileRule

而 ruleSetCompiler.compileRule 方法内部则是做了以下主要工作:

  1. 声明 unhandledProperties 方法,该方法用于获取待处理的属性;
  2. 声明标准的编译后的 rule 结果对象 compiledRule,其中包含 conditions、effects、rules、oneOf 属性;
  3. 触发 this.hooks.rule 钩子,前面说过 BasicMatcherRulePlugin 等插件注册的过程中会向 hooks.rules 钩子上注册事件,用以添加 rule 和 effects;
  4. 处理嵌套的 rules,如果当前的 rule 还有 rules 属性即嵌套 rule,此时则递归调用 this.compileRules 进行处理;
  5. 处理 oneOf,递归调用 this.compileRules 对 rule.oneOf 进行处理;
  6. 针对 webpack 未知属性抛出警告;
  7. 返回经过处理的 compileRule 对象;

2.5 condition 函数编译

compileCondition & combineConditionsOr & combineConditionsAnd,这三个方法都是构建复合的逻辑条件函数用的,所谓条件函数实际上就是处理 rule.test 的,其中:

  1. compileCondition:根据条件的不同,返回不同的函数,比如 condition 是正则,则返回正则的 test 方法调用;
  2. combineConditionsOr:构造能产生”逻辑或“ 的函数;
  3. combineConditionsAnd:构造能产生 ”逻辑与“ 的函数;

三、总结

本文详细介绍了 RuleSetCompiler 类型的工作原理,回顾整个调用过程:

  1. 首先 NMF 实例创建了 RuleSetCompiler 的实例;
  2. 接着调用了 ruleSetCompiler.compileRules 编译 rules,得到带有 exec 方法的 nmf.ruleSet 对象;
  3. 在有了模块路径后调用上一步得到的 ruleSet.exec 方法获取当前模块路径需要应用的 loader;