webpack源码学习-从入口到初始化

235 阅读4分钟

从输入指令开始

平时使用webpack时,大致可以分为如下几种形式:

1、直接在控制台输入webpack指令,这种情况下最直接执行到的文件是./node_modules/bin/webpack.js或者/node_modules/webpack/bin/webpack.js,前者为后者的软链

2、通常写好一个类似dev-server.js/build.js的node脚本,通过npm run dev/npn run build指令去执行,这种情况通常会require('webpack')直接拿到位于./node_modules/webpack/lib/webpack.js的webpack方法传入config,手动调用compiler.run执行

3、第三方库封装好的一些指令,例如create-react-app vue-cli-service,这种情况和第2种类似

接下来我们重点分析第1种情况是怎么调用到./node_modules/webpack/lib/webpack.js的:

在/node_modules/webpack/bin/webpack.js文件中,会调用runCli:

const cli = {
	name: "webpack-cli",
	package: "webpack-cli",
	binName: "webpack-cli",
	installed: isInstalled("webpack-cli"),
	url: "https://github.com/webpack/webpack-cli"
};

if (!cli.installed) {
  // 没有安装cli的情况,可以忽略
} else {
	runCli(cli);
}
const runCli = cli => {
	const path = require("path");
	const pkgPath = require.resolve(`${cli.package}/package.json`);
	// eslint-disable-next-line node/no-missing-require
	const pkg = require(pkgPath);
	// eslint-disable-next-line node/no-missing-require
	require(path.resolve(path.dirname(pkgPath), pkg.bin[cli.binName]));
};

很显然,上面代码中第7行,require的路径为

./node_modules/webpack-cli/bin/cli.js

调用require解析完该文件之后,就会执行里面的runCLI方法:

#!/usr/bin/env node

"use strict";

const importLocal = require("import-local");
const runCLI = require("../lib/bootstrap");

if (!process.env.WEBPACK_CLI_SKIP_IMPORT_LOCAL) {
  // Prefer the local installation of `webpack-cli`
  if (importLocal(__filename)) {
    return;
  }
}

process.title = "webpack";

runCLI(process.argv);

可以进一步去../lib/bootstrap寻找runCLI的定义:

const WebpackCLI = require("./webpack-cli");

const runCLI = async (args) => {
  // Create a new instance of the CLI object
  const cli = new WebpackCLI();

  try {
    await cli.run(args);
  } catch (error) {
    cli.logger.error(error);
    process.exit(2);
  }
};

module.exports = runCLI;

再进一步去./webpack-cli中寻找WebpackCLI.run方法的定义,这个方法非常长,大约有750行,以下是简化后的结果:

  async run(args, parseOptions) {
    // Built-in internal commands
    const buildCommandOptions = {
      name: "build [entries...]",
      alias: ["bundle", "b"],
      description: "Run webpack (default command, can be omitted).",
      usage: "[entries...] [options]",
      dependencies: [WEBPACK_PACKAGE],
    };
    const watchCommandOptions = {
      name: "watch [entries...]",
      alias: "w",
      description: "Run webpack and watch for files changes.",
      usage: "[entries...] [options]",
      dependencies: [WEBPACK_PACKAGE],
    };
    const versionCommandOptions = {
      name: "version [commands...]",
      alias: "v",
      description:
        "Output the version number of 'webpack', 'webpack-cli' and 'webpack-dev-server' and commands.",
    };
    const helpCommandOptions = {
      name: "help [command] [option]",
      alias: "h",
      description: "Display help for commands and options.",
    };
    // Built-in external commands
    const externalBuiltInCommandsInfo = [
      {
        name: "serve [entries...]",
        alias: ["server", "s"],
        pkg: "@webpack-cli/serve",
      },
      {
        name: "info",
        alias: "i",
        pkg: "@webpack-cli/info",
      },
      {
        name: "init",
        alias: ["create", "new", "c", "n"],
        pkg: "@webpack-cli/generators",
      },
      {
        name: "loader",
        alias: "l",
        pkg: "@webpack-cli/generators",
      },
      {
        name: "plugin",
        alias: "p",
        pkg: "@webpack-cli/generators",
      },
      {
        name: "migrate",
        alias: "m",
        pkg: "@webpack-cli/migrate",
      },
      {
        name: "configtest [config-path]",
        alias: "t",
        pkg: "@webpack-cli/configtest",
      },
    ];

    const knownCommands = [
      buildCommandOptions,
      watchCommandOptions,
      versionCommandOptions,
      helpCommandOptions,
      ...externalBuiltInCommandsInfo,
    ];
    const getCommandName = (name) => name.split(" ")[0];
    const isKnownCommand = (name) =>
      knownCommands.find(
        (command) =>
          getCommandName(command.name) === name ||
          (Array.isArray(command.alias) ? command.alias.includes(name) : command.alias === name),
      );
    const isCommand = (input, commandOptions) => {
      const longName = getCommandName(commandOptions.name);

      if (input === longName) {
        return true;
      }

      if (commandOptions.alias) {
        if (Array.isArray(commandOptions.alias)) {
          return commandOptions.alias.includes(input);
        } else {
          return commandOptions.alias === input;
        }
      }

      return false;
    };
    const findCommandByName = (name) =>
      this.program.commands.find(
        (command) => name === command.name() || command.aliases().includes(name),
      );
    const isOption = (value) => value.startsWith("-");
    const isGlobalOption = (value) =>
      value === "--color" ||
      value === "--no-color" ||
      value === "-v" ||
      value === "--version" ||
      value === "-h" ||
      value === "--help";

    const loadCommandByName = async (commandName, allowToInstall = false) => {};

    // Register own exit
    this.program.exitOverride(async (error) => {});

    // Default `--color` and `--no-color` options
    const cli = this;
    this.program.option("--color", "Enable colors on console.");
    this.program.on("option:color", function () {
      const { color } = this.opts();

      cli.isColorSupportChanged = color;
      cli.colors = cli.createColors(color);
    });
    this.program.option("--no-color", "Disable colors on console.");
    this.program.on("option:no-color", function () {
      const { color } = this.opts();

      cli.isColorSupportChanged = color;
      cli.colors = cli.createColors(color);
    });

    // Make `-v, --version` options
    // Make `version|v [commands...]` command
    const outputVersion = async (options) => {};
    this.program.option(
      "-v, --version",
      "Output the version number of 'webpack', 'webpack-cli' and 'webpack-dev-server' and commands.",
    );

    const outputHelp = async (options, isVerbose, isHelpCommandSyntax, program) => {}
    this.program.helpOption(false);
    this.program.addHelpCommand(false);
    this.program.option("-h, --help [verbose]", "Display help for commands and options.");

    let isInternalActionCalled = false;

    // Default action
    this.program.usage("[options]");
    this.program.allowUnknownOption(true);
    this.program.action(async (options, program) => {});

    await this.program.parseAsync(args, parseOptions);
  }

可以看到最后对我们输入的参数进行了解析,虽然我们并不能确定parseAsync这个方法干了什么,但我们可以猜测到,如果我们想要执行开发或生产环境的编译,一定要走到一个加载webpack并传入配置执行的方法里面,这个方法就是runWebpack

  async runWebpack(options, isWatchCommand) {
    // eslint-disable-next-line prefer-const
    let compiler;
    let createJsonStringifyStream;

    if (options.json) {
      const jsonExt = await this.tryRequireThenImport("@discoveryjs/json-ext");

      createJsonStringifyStream = jsonExt.stringifyStream;
    }

    const callback = (error, stats) => {
      if (error) {
        this.logger.error(error);
        process.exit(2);
      }

      if (stats.hasErrors()) {
        process.exitCode = 1;
      }

      if (!compiler) {
        return;
      }

      const statsOptions = compiler.compilers
        ? {
            children: compiler.compilers.map((compiler) =>
              compiler.options ? compiler.options.stats : undefined,
            ),
          }
        : compiler.options
        ? compiler.options.stats
        : undefined;

      // TODO webpack@4 doesn't support `{ children: [{ colors: true }, { colors: true }] }` for stats
      const statsForWebpack4 = this.webpack.Stats && this.webpack.Stats.presetToOptions;

      if (compiler.compilers && statsForWebpack4) {
        statsOptions.colors = statsOptions.children.some((child) => child.colors);
      }

      if (options.json && createJsonStringifyStream) {
        const handleWriteError = (error) => {
          this.logger.error(error);
          process.exit(2);
        };

        if (options.json === true) {
          createJsonStringifyStream(stats.toJson(statsOptions))
            .on("error", handleWriteError)
            .pipe(process.stdout)
            .on("error", handleWriteError)
            .on("close", () => process.stdout.write("\n"));
        } else {
          createJsonStringifyStream(stats.toJson(statsOptions))
            .on("error", handleWriteError)
            .pipe(fs.createWriteStream(options.json))
            .on("error", handleWriteError)
            // Use stderr to logging
            .on("close", () => {
              process.stderr.write(
                `[webpack-cli] ${this.colors.green(
                  `stats are successfully stored as json to ${options.json}`,
                )}\n`,
              );
            });
        }
      } else {
        const printedStats = stats.toString(statsOptions);

        // Avoid extra empty line when `stats: 'none'`
        if (printedStats) {
          this.logger.raw(printedStats);
        }
      }
    };

    const env =
      isWatchCommand || options.watch
        ? { WEBPACK_WATCH: true, ...options.env }
        : { WEBPACK_BUNDLE: true, WEBPACK_BUILD: true, ...options.env };

    options.argv = { ...options, env };

    if (isWatchCommand) {
      options.watch = true;
    }

    compiler = await this.createCompiler(options, callback);

    if (!compiler) {
      return;
    }

    const isWatch = (compiler) =>
      compiler.compilers
        ? compiler.compilers.some((compiler) => compiler.options.watch)
        : compiler.options.watch;

    if (isWatch(compiler) && this.needWatchStdin(compiler)) {
      process.stdin.on("end", () => {
        process.exit(0);
      });
      process.stdin.resume();
    }
  }
}

然后就进入了webpack-cli里面的createCompiler方法,再调用this.webpack创建compiler对象,this.webpack在上面的run方法里已经赋值好,就是将webpack包加载了进来

在webpack这个包中,很容易找到,其调用的方法是:

const webpack = (options, callback) => {
    // create方法定义的部分
    if (callback) {
        try {
                const { compiler, watch, watchOptions } = create();
                if (watch) {
                        compiler.watch(watchOptions, callback);
                } else {
                        compiler.run((err, stats) => {
                                compiler.close(err2 => {
                                        callback(err || err2, stats);
                                });
                        });
                }
                return compiler;
        } catch (err) {
                process.nextTick(() => callback(err));
                return null;
        }
    }
}

注意到webpack方法中通过调用create方法返回了compiler watch等对象,其实create的逻辑较为简单,仅仅是判断传入的参数是数组还是单个对象,从而决定调用createMultiCompiler或者createCompiler,而createMultiCompiler最终也会调用createCompiler,因此我们接下来重点关注createCompiler方法:

const createCompiler = rawOptions => {
    const options = getNormalizedWebpackOptions(rawOptions);
    applyWebpackOptionsBaseDefaults(options);
    const compiler = new Compiler(options.context, options);
    new NodeEnvironmentPlugin({
            infrastructureLogging: options.infrastructureLogging
    }).apply(compiler);
    if (Array.isArray(options.plugins)) {
            for (const plugin of options.plugins) {
                    if (typeof plugin === "function") {
                            plugin.call(compiler, compiler);
                    } else {
                            plugin.apply(compiler);
                    }
            }
    }
    applyWebpackOptionsDefaults(options);
    compiler.hooks.environment.call();
    compiler.hooks.afterEnvironment.call();
    new WebpackOptionsApply().process(options, compiler);
    compiler.hooks.initialize.call();
    return compiler;
}

这是一个很重要的方法,因为我们平时在使用webpack时,插件是我们最关心的话题之一,在这个方法中,就会调用插件的apply方法,进而让插件执行compiler上从Tapable继承来的hook属性的订阅操作

当然,除了插件相关的行为外,还有其他一些事情,大概分为以下几部分:

  • 处理并规整options(getNormalizedWebpackOptions applyWebpackOptionsBaseDefaults)
  • 创建compiler对象
  • 开发者配置在options中的插件的apply方法的执行(里面主要就是往各种对象上订阅方法),执行到此处时肯定是先给compiler上从Tapable继承来的hook属性上订阅了若干方法
  • 根据options收集webpack内置的各种插件,然后执行插件的apply方法,继续给compiler上从Tapable继承来的hook属性上订阅若干方法(new WebpackOptionsApply().process(options, compiler))

在所有这些插件中,有一个需要额外注意一下,因为这个插件是下一步make阶段(也就是从entry模块开始解析,然后遍历所有依赖再生成依赖树):

    new EntryOptionPlugin().apply(compiler);
    compiler.hooks.entryOption.call(options.context, options.entry);

EntryOptionPlugin的apply方法如下:

class EntryOptionPlugin {
	/**
	 * @param {Compiler} compiler the compiler instance one is tapping into
	 * @returns {void}
	 */
	apply(compiler) {
		compiler.hooks.entryOption.tap("EntryOptionPlugin", (context, entry) => {
			EntryOptionPlugin.applyEntryOption(compiler, context, entry);
			return true;
		});
	}

可以看到,它在hooks.entryOption上订阅了一个回调,而且在WebpackOptionsApply中订阅代码的下方立刻进行了发布(compiler.hooks.entryOption.call(options.context, options.entry)),即执行了

EntryOptionPlugin.applyEntryOption(compiler, context, entry);

EntryOptionPlugin.applyEntryOption是EntryOptionPlugin插件的一个静态方法,我们来看下它有干了什么事情:

static applyEntryOption(compiler, context, entry) {
        if (typeof entry === "function") {
                const DynamicEntryPlugin = require("./DynamicEntryPlugin");
                new DynamicEntryPlugin(context, entry).apply(compiler);
        } else {
                const EntryPlugin = require("./EntryPlugin");
                for (const name of Object.keys(entry)) {
                        const desc = entry[name];
                        const options = EntryOptionPlugin.entryDescriptionToOptions(
                                compiler,
                                name,
                                desc
                        );
                        for (const entry of desc.import) {
                                new EntryPlugin(context, entry, options).apply(compiler);
                        }
                }
        }
}

通常来讲,我们传进来的option.entry都是对象类型的,所以大多数情况下会走到else分支中,在这个分支里,可以看到又加载了一个名为EntryPlugin的插件,EntryPlugin的每个实例都有一个对应的entry和其映射,我们打开EntryPlugin的源码:

class EntryPlugin {
    /**
     * An entry plugin which will handle
     * creation of the EntryDependency
     *
     * @param {string} context context path
     * @param {string} entry entry path
     * @param {EntryOptions | string=} options entry options (passing a string is deprecated)
     */
    constructor(context, entry, options) {
            this.context = context;
            this.entry = entry;
            this.options = options || "";
    }

    /**
     * Apply the plugin
     * @param {Compiler} compiler the compiler instance
     * @returns {void}
     */
    apply(compiler) {
            compiler.hooks.compilation.tap(
                    "EntryPlugin",
                    (compilation, { normalModuleFactory }) => {
                            compilation.dependencyFactories.set(
                                    EntryDependency,
                                    normalModuleFactory
                            );
                    }
            );

            const { entry, options, context } = this;
            const dep = EntryPlugin.createDependency(entry, options);

            compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
                    compilation.addEntry(context, dep, options, err => {
                            callback(err);
                    });
            });
    }

可以看到,在apply里面,给hooks.make订阅了一个回调,里面这行代码很关键:

compilation.addEntry(context, dep, options, err => {
        callback(err);
});

因为它就是从初始化进入make阶段的桥梁,从这里开始,webpack将从entry入口文件开始递归分析,其在遍历过程中拿到的每个文件都会经历如下步骤:

  • 引用路径resolve
  • 过滤匹配的loader并对文件进行处理
  • 生成ast树,并从ast树中拿到文件的依赖
  • 对每个文件重复执行上述流程,递归遍历直到叶子结点

在上面的过程中,会在不同阶段分别触发不同的对象之前订阅过的方法

到此,初始化流程基本完毕,接下来进入make阶段