深入webpack4源码(二)—— 基本运行流程

好了。。终于可以开始看源码了,先了解下大体流程。

这里直接开始说webpack的源码,就不再细说webpack-cli了。
这俩的区别就是,webpack核心库,webpack-cli处理webpack的一系列命令行操作。感兴趣的可以看看这篇文章,其实直接看源码也很简单就是常规的那一套使用yargs这个库,然后从命令行接受命令行参数转化为webpack实际的参数。

进入正题,如果你使用过create-react-app或者vue-cli这样的工具,你都发现他们都并没有直接用命令行打包,而是自己写了一个脚本然后require('webpack')然后传入config进行打包,这样的好处是更灵活,更好控制,以下来自create-react-app中react-script的build:

const webpack = require('webpack');
function build(previousFileSizes) {
  let compiler = webpack(config);
  return new Promise((resolve, reject) => {
    compiler.run((err, stats) => {
        // ....
    })  
})
}

先看webpack的package.json:

"main": "lib/webpack.js",

说明我们require的就是lib/webpack.js

const webpack = (options, callback) => {
    const webpackOptionsValidationErrors = validateSchema(
        webpackOptionsSchema,
        options
    );
    if (webpackOptionsValidationErrors.length) {
        throw new WebpackOptionsValidationError(webpackOptionsValidationErrors);
    }
    let compiler;
    if (Array.isArray(options)) {
        compiler = new MultiCompiler(options.map(options => webpack(options)));
    } else if (typeof options === "object") {
        options = new WebpackOptionsDefaulter().process(options);

        compiler = new Compiler(options.context);
        compiler.options = options;
        new NodeEnvironmentPlugin().apply(compiler);
        if (options.plugins && Array.isArray(options.plugins)) {
            for (const plugin of options.plugins) {
                if (typeof plugin === "function") {
                    plugin.call(compiler, compiler);
                } else {
                    plugin.apply(compiler);
                }
            }
        }
        compiler.hooks.environment.call();
        compiler.hooks.afterEnvironment.call();
        compiler.options = new WebpackOptionsApply().process(options, compiler);
    } else {
        throw new Error("Invalid argument: options");
    }
    if (callback) {
        if (typeof callback !== "function") {
            throw new Error("Invalid argument: callback");
        }
        if (
            options.watch === true ||
            (Array.isArray(options) && options.some(o => o.watch))
        ) {
            const watchOptions = Array.isArray(options)
                ? options.map(o => o.watchOptions || {})
                : options.watchOptions || {};
            return compiler.watch(watchOptions, callback);
        }
        compiler.run(callback);
    }
    return compiler;
};

这段代码主要的工作就是初始化complier。


options为数组的情况,目前项目中还真没用过,以后有用到了再补充。

validateSchema

这部分主要就是校验options并且返回报错:

const validateObject = (schema, options) => {
    const validate = ajv.compile(schema);
    const valid = validate(options);
    return valid ? [] : filterErrors(validate.errors);
};

return validateObject(schema, options);

schema是一个json形式的描述文件,描述着各个字段是什么类型,以及对应的错误信息,感觉就像async-validator的rules一样,然后调用了一个叫ajv的库,将秒速文件转为了校验函数校验options。

WebpackOptionsDefaulter

其实webpack以及内置了很多默认的config,这个对象的作用就是合并默认的config和传入的config。但是在源码里这里不叫config,而是options。

class WebpackOptionsDefaulter extends OptionsDefaulter {
  constructor() {
      // this.set(xx, 'xx')
  }
}

set方法就是给默认字段设置对应的值。
更核心的方法都在OptionsDefaulter里,比如webpack.js中的options = new WebpackOptionsDefaulter().process(options);的process
OptionsDefaulter中,一开始就是定了两个方法,很有意思:

const getProperty = (obj, name) => {
    name = name.split(".");
    for (let i = 0; i < name.length - 1; i++) {
        obj = obj[name[i]];
        if (typeof obj !== "object" || !obj || Array.isArray(obj)) return;
    }
    return obj[name.pop()];
};

const setProperty = (obj, name, value) => {
    name = name.split(".");
    for (let i = 0; i < name.length - 1; i++) {
        if (typeof obj[name[i]] !== "object" && obj[name[i]] !== undefined) return;
        if (Array.isArray(obj[name[i]])) return;
        if (!obj[name[i]]) obj[name[i]] = {};
        obj = obj[name[i]];
    }
    obj[name.pop()] = value;
};
  • getProperty:其作用就是拿对象的某个字段的值,但是这里的name可以是'xxx.xxx.xxx'这样的格式,我们如果直接a.b.c.d如果b或者c就是undefined,那么直接就报错了,这里直接巧妙的避免了这样的情况,类似于Experimental的语法 optional-chaining,如let value = a?.b?.c?.d,如果有undefined最终就直接返回undefined了。
  • setProperty:也类似,比如a是{},我们不能直接设置a.b.c = xxx,这样如果b是undefined也会报错,这里就可以直接设置了。
class OptionsDefaulter {
    constructor() {
        this.defaults = {};
        this.config = {};
    }

    process(options) {
        options = Object.assign({}, options);
        for (let name in this.defaults) {
            switch (this.config[name]) {
                case undefined:
                    // ...
                    break;
                case "call":
                    // ...
                    break;
                case "make":
                    // ...
                    break;
                case "append": {
                    // ...
                }
                default:
            }
        }
        return options;
    }

    set(name, config, def) {
        if (def !== undefined) {
            this.defaults[name] = def;
            this.config[name] = config;
        } else {
            this.defaults[name] = config;
            delete this.config[name];
        }
    }
}

我们看到外层的WebpackOptionsDefaulter使用了很多set,其实就是这里的set,参数name代表option的具体名字,config代表具体的值。这里要说下的就是def:如果有def的时候,config就代表具体的合并方法的类型,而def就代表了option的具体值,具体的处理都在process中。

初始化complier

初始化complier就没有太多好说的,主要就是初始化了一系列的勾子,和一系列参数:

this.hooks = { ... };
/** @type {string=} */
this.name = undefined;
/** @type {Compilation=} */
this.parentCompilation = undefined;
/** @type {string} */
this.outputPath = "";

this.outputFileSystem = null;
this.inputFileSystem = null;

/** @type {string|null} */
this.recordsInputPath = null;
/** @type {string|null} */
this.recordsOutputPath = null;
this.records = {};
this.removedFiles = new Set();
/** @type {Map<string, number>} */
this.fileTimestamps = new Map();
/** @type {Map<string, number>} */
this.contextTimestamps = new Map();
/** @type {ResolverFactory} */
this.resolverFactory = new ResolverFactory();

这些参数的具体含义简单的看名字就知道,不懂的其实也要等到用到才明白。
可以讲下的是ResolverFactory,看见resolve就能想到路径解析。这个就是一个路径解析器的工厂,在需要的时候根据option返回一个路径解析器(这个类也有自己的勾子):

const { Tapable, HookMap, SyncHook, SyncWaterfallHook } = require("tapable");
const Factory = require("enhanced-resolve").ResolverFactory;

module.exports = class ResolverFactory extends Tapable {
    constructor() {
        super();
        this.hooks = {
            resolveOptions: new HookMap(
                () => new SyncWaterfallHook(["resolveOptions"])
            ),
            resolver: new HookMap(() => new SyncHook(["resolver", "resolveOptions"]))
        };
        this.cache1 = new WeakMap();
        this.cache2 = new Map();
    }

    get(type, resolveOptions) {
        const cachedResolver = this.cache1.get(resolveOptions);
        if (cachedResolver) return cachedResolver();
        const ident = `${type}|${JSON.stringify(resolveOptions)}`;
        const resolver = this.cache2.get(ident);
        if (resolver) return resolver;
        const newResolver = this._create(type, resolveOptions);
        this.cache2.set(ident, newResolver);
        return newResolver;
    }

    _create(type, resolveOptions) {
        // ....
    }
};

他也是主要调用的enhanced-resolve这个库来解析路径,并对解析器做了缓存。使用案例

应用NodeEnvironmentPlugin插件

该插件的主要作用是给complier初始化输入输出文件系统和监视文件系统。

class NodeEnvironmentPlugin {
    apply(compiler) {
        compiler.inputFileSystem = new CachedInputFileSystem(
            new NodeJsInputFileSystem(),
            60000
        );
        const inputFileSystem = compiler.inputFileSystem;
        compiler.outputFileSystem = new NodeOutputFileSystem();
        compiler.watchFileSystem = new NodeWatchFileSystem(
            compiler.inputFileSystem
        );
        compiler.hooks.beforeRun.tap("NodeEnvironmentPlugin", compiler => {
            if (compiler.inputFileSystem === inputFileSystem) inputFileSystem.purge();
        });
    }
}
module.exports = NodeEnvironmentPlugin;
  • 输出文件系统:非常简单,就是包装了一层node原生的fsapi。
  • 输入文件系统:复杂,目前还没有看见使用的地方。
  • 监视文件系统:复杂,目前还没有看见使用的地方,但是主要的作用就是监视文件改动及热更新等。

应用插件

这里很重要,我们webpack.config.js中的plugins就是在这个阶段挂载上的:

if (options.plugins && Array.isArray(options.plugins)) {
    for (const plugin of options.plugins) {
        if (typeof plugin === "function") {
            plugin.call(compiler, compiler);
        } else {
            plugin.apply(compiler);
        }
    }
}

就像上一节讲过的,在这里只是webpack抽象后的挂载插件,而真正的在勾子上挂载执行函数都是具体在每个plugin里实现的。
之前我们说过我们必须为plugin实现apply方法,实际上这里也展示了挂载动态插件的方式,就是为plugin实现call方法。

WebpackOptionsApply

这个对象做的事情理解起来非常简单,那就是根据options为complier应用插件(但插件本身复杂度,哭了)。

process(options, compiler) {
  let ExternalsPlugin;
  compiler.outputPath = options.output.path;
  compiler.recordsInputPath = options.recordsInputPath ||     options.recordsPath;
  compiler.recordsOutputPath =
    options.recordsOutputPath || options.recordsPath;
  compiler.name = options.name;
  // TODO webpack 5 refactor this to     MultiCompiler.setDependencies() with a WeakMap
  // @ts-ignore TODO
  compiler.dependencies = options.dependencies;
  new xxxPlugin().apply(compiler);
  // ........
}

我们之前说过,webpack是插件机制,那到底是怎么样的插件机制呢?其实就是webpack声明了很多勾子,然后调用了很多勾子。 emmm,所以你看源码的时候你会发现,他就是调用了很多勾子,然后代码就完成打包了,但是你根本找不到处理代码的地方到底在哪里,实际上做处理的是挂载在勾子上的插件,那么为了完成webpack的所有功能,到底挂载了哪些插件?这个问题的答案就在这个对象里。

在这里插一段其他文章里,我很认同的一段话:

  • 联系松散。你可以发现:使用tapable钩子类似事件监听模式,虽然能有效解耦,但钩子的注册与调用几乎完全无关,很难将一个钩子的“创建 - 注册 - 调用”过程有效联系起来。
  • 模块交互基于钩子。webpack内部模块与插件在很多时候,是通过钩子机制来进行联系与调用的。但是,基于钩子的模式是松散的。例如你看到源码里一个模块提供了几个钩子,但你并不知道,在何时、何地该钩子会被调用,又在何时、何地钩子上被注册了哪些方法。这些以往都是需要我们通过在代码库中搜索关键词来解决。
  • 钩子数量众多。webpack内部的钩子非常多,数量达到了180+,类型也五花八门。除了官网列出的compiler与compilation中那些常用的钩子,还存在着众多其他可以使用的钩子。有些有用的钩子你可能无从知晓,例如我最近用到的localVars、requireExtensions等钩子。
  • 内置插件众多。webpack v4+ 本身内置了许多插件。即使非插件,webpack的模块自身也经常使用tapable钩子来交互。甚至可以认为,webpack项目中的各个模块都是“插件化”的。这也使得几乎每个模块都会和各种钩子“打交道”。

我的建议是这个对象可以暂时不管,用到再查。

到现在只讲了complier的初始化,实际上complier.run()后你会发现更多的勾子调用,而且compiler里还会引用其他模块,其他模块还有自己的勾子,所以我们在阅读源码的时候,建议可以看见了某个勾子调用就全局搜下这个勾子的名字加上调用方法,然后再看挂载的执行函数到底做了什么处理。例如:这个勾子赋值给了complier.hooks.run,并且async的勾子,那么全局就搜索hooks.run.taphooks.run.tapAsynchooks.run.tapPromise,然后再看到底做了什么。

推荐阅读更多精彩内容