likes
comments
collection
share

webpack5 源码解读(1) —— webpack 及 webpack-cli 的启动过程

作者站长头像
站长
· 阅读数 5

启动 webpack

本文将通过 webpack5 的入口文件源码,解读 webpack 的启动过程。

寻找入口

如下所示的 package.json 文件中,当我们执行 npm run build 命令时,实际执行了后面的 webpack 指令:

{
  // ...
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "serve": "webpack serve",
    "build": "webpack"
  },
  // ...
}

此时 npm 会去寻找 node_modules/.bin 目录下是否存在 webpack.sh 或者 webpack.cmd 文件,最终实际找到 node_modules/webpack/bin/webpack.js 文件作为入口文件去执行。

检查 webpack-cli 是否安装

node_modules/webpack/bin/webpack.js 文件源码如下,首先首行 #!/usr/bin/env node 会告诉系统以用户的环境变量中的 node 去执行这个文件,然后封装了三个方法,初始化了一个 cli 对象,根据 cli.installed 执行不同流程:

#!/usr/bin/env node

// 执行命令
const runCommand = (command, args) => {
  // ...
};

// 检查一个包是否安装
const isInstalled = (packageName) => {
  // ...
};

// 运行 webpack-cli
const runCli = (cli) => {
  // ...
};

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) {
  // ...
} else {
  // ...
}

cli.installed 是执行了 isInstalled('webpack-cli') 方法。我们看一下 isInstalled,它用于判断一个包是否安装。它接收一个 packageName 参数,当处于 pnp 环境时,直接返回 true 表示已安装;否则从当前目录开始向父级目录遍历 node_modules 文件夹下是否有 packageName 对应的文件夹,有则返回 true 表示已安装;直至遍历到顶层目录还未找到则返回 false 表示未安装。

/**
 * @param {string} packageName name of the package
 * @returns {boolean} is the package installed?
 */
const isInstalled = (packageName) => {
  // process.versions.pnp 为 true 时,表示处于 Pnp 环境
  // 提供了 npm 包即插即用的环境而不必安装,所以直接返回 true
  if (process.versions.pnp) {
    return true;
  }

  const path = require('path');
  const fs = require('graceful-fs');

  let dir = __dirname;

  // 逐层向上遍历父级目录,看对应的 package 名的文件夹是否存在,从而判断包是否安装
  do {
    try {
      if (
        fs.statSync(path.join(dir, 'node_modules', packageName)).isDirectory()
      ) {
        return true;
      }
    } catch (_error) {
      // Nothing
    }
  } while (dir !== (dir = path.dirname(dir)));

  return false;
};

未安装

cli.installed 为 false,说明 webpack-cli 未安装,提示用户必须安装 webpack-cli,然后让用户输入 y/n 选择是否安装:用户输入 n 则直接报错退出;用户输入 y 则直接通过上面的 runCommand 方法安装 webpack-cli,安装完毕后通过 runCli 方法引入 webpack-cli 的入口文件执行 webpack-cli:

if (!cli.installed) {
  // webpack-cli 未安装
  const path = require('path');
  const fs = require('graceful-fs');
  const readLine = require('readline');

  // 提示 webpack-cli 必须安装
  const notify =
    'CLI for webpack must be installed.\n' + `  ${cli.name} (${cli.url})\n`;

  console.error(notify);

  let packageManager;

  if (fs.existsSync(path.resolve(process.cwd(), 'yarn.lock'))) {
    packageManager = 'yarn';
  } else if (fs.existsSync(path.resolve(process.cwd(), 'pnpm-lock.yaml'))) {
    packageManager = 'pnpm';
  } else {
    packageManager = 'npm';
  }

  const installOptions = [packageManager === 'yarn' ? 'add' : 'install', '-D'];

  console.error(
    `We will use "${packageManager}" to install the CLI via "${packageManager} ${installOptions.join(
      ' '
    )} ${cli.package}".`
  );
  
  // 询问用户是否安装 webpack-cli
  const question = `Do you want to install 'webpack-cli' (yes/no): `;

  const questionInterface = readLine.createInterface({
    input: process.stdin,
    output: process.stderr,
  });

  // In certain scenarios (e.g. when STDIN is not in terminal mode), the callback function will not be
  // executed. Setting the exit code here to ensure the script exits correctly in those cases. The callback
  // function is responsible for clearing the exit code if the user wishes to install webpack-cli.
  process.exitCode = 1;
  questionInterface.question(question, (answer) => {
    questionInterface.close();

    const normalizedAnswer = answer.toLowerCase().startsWith('y');
    
    // 用户选择不安装,报错退出
    if (!normalizedAnswer) {
      console.error(
        "You need to install 'webpack-cli' to use webpack via CLI.\n" +
          'You can also install the CLI manually.'
      );

      return;
    }
    process.exitCode = 0;

    console.log(
      `Installing '${
        cli.package
      }' (running '${packageManager} ${installOptions.join(' ')} ${
        cli.package
      }')...`
    );
    // 用户选择安装,通过 runCommand 安装 webpack-cli
    runCommand(packageManager, installOptions.concat(cli.package))
      .then(() => {
        // 安装完毕后引入 webpack-cli 的入口文件执行
        runCli(cli);
      })
      .catch((error) => {
        console.error(error);
        process.exitCode = 1;
      });
  });
} else {
  // ...
}

已安装

若已安装 webpack-cli,则直接通过 runCli 方法引入 webpack-cli 的入口文件执行 webpack-cli:

if (!cli.installed) {
  // ...
} else {
  // 若已安装,直接引入 webpack-cli 的入口文件执行
  runCli(cli);
}

可见,webpack 的启动过程最终是去找到 webpack-cli 并执行。

启动 webpack-cli

入口文件

runCli 会找到 webpack-clipackage.json 中的 bin 所指向的文件引入执行,其对应的文件为 webpack-cli/bin/cli.js,下面我们看一下这个文件的内容:

#!/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);

它会引入 webpack-cli/lib/bootstrap.js 文件中的 runCLI 函数,并将 process.argv (即执行 webpack 命令时 webpack xxx 对应的 xxx 参数) 传入去执行。

runCLI 函数中代码如下,总共做了两件事情,首先通过 new WebpackCLI() 创建了一个 WebpackCLI 实例,然后 cli.run(args) 调用了实例的 run 方法:

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;

创建 WebpackCLI 实例

看下 WebpackCLI 这个类, new WebpackCLI() 创建 WebpackCLI 类实例时会执行其构造函数,设置了控制台的打印颜色和各种打印信息,最主要的是从 commander 包中引入了 program,将其挂载到了 this 上,稍后会讲到 Command 类的作用:

const { program, Option } = require("commander");
// ...

class WebpackCLI {
  constructor() {
    // 设置控制台打印颜色
    this.colors = this.createColors();
    // 设置各种类型控制台打印信息(error)
    this.logger = this.getLogger();

    // 将 Command 实例挂载到 this 上
    this.program = program;
    this.program.name("webpack");
    this.program.configureOutput({
      writeErr: this.logger.error,
      outputError: (str, write) =>
        write(`Error: ${this.capitalizeFirstLetter(str.replace(/^error:/, "").trim())}`),
    });
  }
  
  // ...
  
  async run(args, parseOptions) {
    // ...
  }
  
  // ...
}

解析 webpack 指令参数

cli.run(args) 方法中,首先将各个 webpack 命令添加到了数组中,然后解析 webpack xxx 指令中的 xxx 参数,将其挂载到 this.program.args 上。然后根据不同的参数,调用 loadCommandByName 方法执行不同的 webpack 指令:

async run(args, parseOptions) {
  // 执行打包
  const buildCommandOptions = {
    name: "build [entries...]",
    alias: ["bundle", "b"],
    description: "Run webpack (default command, can be omitted).",
    usage: "[entries...] [options]",
    dependencies: [WEBPACK_PACKAGE],
  };
  // 运行 webpack 并监听文件改变
  const watchCommandOptions = {
    name: "watch [entries...]",
    alias: "w",
    description: "Run webpack and watch for files changes.",
    usage: "[entries...] [options]",
    dependencies: [WEBPACK_PACKAGE],
  };
  // 查看 webpack、webpack-cli 和 webpack-dev-server 的版本
  const versionCommandOptions = {
    name: "version [commands...]",
    alias: "v",
    description:
      "Output the version number of 'webpack', 'webpack-cli' and 'webpack-dev-server' and commands.",
  };
  // 输出 webpack 各项命令
  const helpCommandOptions = {
    name: "help [command] [option]",
    alias: "h",
    description: "Display help for commands and options.",
  };
  // 其他的内置命令
  const externalBuiltInCommandsInfo = [
    // 启动 dev server
    {
      name: "serve [entries...]",
      alias: ["server", "s"],
      pkg: "@webpack-cli/serve",
    },
    // 输出相关信息,包括当前系统、包管理工具、运行中的浏览器版本以及安装的 webpack loader 和 plugin
    {
      name: "info",
      alias: "i",
      pkg: "@webpack-cli/info",
    },
    // 生成一份 webpack 配置
    {
      name: "init",
      alias: ["create", "new", "c", "n"],
      pkg: "@webpack-cli/generators",
    },
    // 生成一份 webpack loader 代码
    {
      name: "loader",
      alias: "l",
      pkg: "@webpack-cli/generators",
    },
    // 生成一份 webpack plugin 代码
    {
      name: "plugin",
      alias: "p",
      pkg: "@webpack-cli/generators",
    },
    // 进行 webpack 版本迁移
    {
      name: "migrate",
      alias: "m",
      pkg: "@webpack-cli/migrate",
    },
    // 验证一份 webpack 的配置是否正确
    {
      name: "configtest [config-path]",
      alias: "t",
      pkg: "@webpack-cli/configtest",
    },
  ];

  // 初始化已知的命令数组
  const knownCommands = [
    buildCommandOptions,
    watchCommandOptions,
    versionCommandOptions,
    helpCommandOptions,
    ...externalBuiltInCommandsInfo,
  ];
  
  // ...

  // Register own exit
  // ...

  // Default `--color` and `--no-color` options
  // ...

  // Make `-v, --version` options
  // Make `version|v [commands...]` command
  
  
  // ...

  // Default action
  this.program.usage("[options]");
  this.program.allowUnknownOption(true);
  this.program.action(async (options, program) => {
    if (!isInternalActionCalled) {
      isInternalActionCalled = true;
    } else {
      this.logger.error("No commands found to run");
      process.exit(2);
    }

    // Command and options
    // 解析传入的参数
    const { operands, unknown } = this.program.parseOptions(program.args);
    const defaultCommandToRun = getCommandName(buildCommandOptions.name);
    const hasOperand = typeof operands[0] !== "undefined";
    // 若传入参数,则将 operand 赋值为 webpack 命令后面跟的第一个参数,否则设置为默认的 build 命令
    const operand = hasOperand ? operands[0] : defaultCommandToRun;
    const isHelpOption = typeof options.help !== "undefined";
    // 如果是已知的命令,isHelpCommandSyntax 为 true;否则为 false
    const isHelpCommandSyntax = isCommand(operand, helpCommandOptions);

    if (isHelpOption || isHelpCommandSyntax) {
      // 如果不是已知命令,则输出如何获取 webpack help 信息
      let isVerbose = false;

      if (isHelpOption) {
        if (typeof options.help === "string") {
          if (options.help !== "verbose") {
            this.logger.error("Unknown value for '--help' option, please use '--help=verbose'");
            process.exit(2);
          }

          isVerbose = true;
        }
      }

      this.program.forHelp = true;

      const optionsForHelp = []
        .concat(isHelpOption && hasOperand ? [operand] : [])
        // Syntax `webpack help [command]`
        .concat(operands.slice(1))
        // Syntax `webpack help [option]`
        .concat(unknown)
        .concat(
          isHelpCommandSyntax && typeof options.color !== "undefined"
            ? [options.color ? "--color" : "--no-color"]
            : [],
        )
        .concat(
          isHelpCommandSyntax && typeof options.version !== "undefined" ? ["--version"] : [],
        );

      await outputHelp(optionsForHelp, isVerbose, isHelpCommandSyntax, program);
    }

    const isVersionOption = typeof options.version !== "undefined";
    const isVersionCommandSyntax = isCommand(operand, versionCommandOptions);

    if (isVersionOption || isVersionCommandSyntax) {
      // 如果是版本命令,则输出版本相关信息
      const optionsForVersion = []
        .concat(isVersionOption ? [operand] : [])
        .concat(operands.slice(1))
        .concat(unknown);

      await outputVersion(optionsForVersion, program);
    }

    let commandToRun = operand;
    let commandOperands = operands.slice(1);

    if (isKnownCommand(commandToRun)) {
      // 是已知的 webpack 命令,调用 loadCommandByName 函数执行相关命令
      await loadCommandByName(commandToRun, true);
    } else {
      const isEntrySyntax = fs.existsSync(operand);

      if (isEntrySyntax) {
        // webpack xxx 其中 xxx 文件夹存在,则对 xxx 文件夹下面的内容进行打包
        commandToRun = defaultCommandToRun;
        commandOperands = operands;

        await loadCommandByName(commandToRun);
      } else {
        // webpack xxx 的 xxx 不是已知命令且不是文件夹则报错
        this.logger.error(`Unknown command or entry '${operand}'`);

        const levenshtein = require("fastest-levenshtein");
        const found = knownCommands.find(
          (commandOptions) =>
            levenshtein.distance(operand, getCommandName(commandOptions.name)) < 3,
        );

        if (found) {
          this.logger.error(
            `Did you mean '${getCommandName(found.name)}' (alias '${
              Array.isArray(found.alias) ? found.alias.join(", ") : found.alias
            }')?`,
          );
        }

        this.logger.error("Run 'webpack --help' to see available commands and options");
        process.exit(2);
      }
    }

    await this.program.parseAsync([commandToRun, ...commandOperands, ...unknown], {
      from: "user",
    });
  });

  // 解析指令参数挂载到 this.program.args 上
  await this.program.parseAsync(args, parseOptions);
}

执行打包指令

loadCommandByName 方法中,主要是根据不同的 webpack 指令执行不同的功能,我们主要关注 webpack 打包过程,执行 webpack 打包相关的命令时(build 或 watch),最终运行了 runWebpack 方法:

const loadCommandByName = async (commandName, allowToInstall = false) => {
  const isBuildCommandUsed = isCommand(commandName, buildCommandOptions);
  const isWatchCommandUsed = isCommand(commandName, watchCommandOptions);

  if (isBuildCommandUsed || isWatchCommandUsed) {
    await this.makeCommand(
      isBuildCommandUsed ? buildCommandOptions : watchCommandOptions,
      
      // ...
      
      async (entries, options) => {
        if (entries.length > 0) {
          options.entry = [...entries, ...(options.entry || [])];
        }

        await this.runWebpack(options, isWatchCommandUsed);
      },
    );
  }
  // ...
};

创建 compiler

看一下 runWebpack 的代码,里面主要做的事情就是调用 createCompiler 方法创建 compiler(这是贯穿了 webpack 后续打包过程中的一个重要的对象,后面会详细讲到):

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

  // ...

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

  // ...
}

运行 webpack

createCompiler 方法中,解析 options 中的各项打包配置,然后又回到了引入 webpack 包,运行其 main 入口文件,开始执行打包:

async createCompiler(options, callback) {
  if (typeof options.nodeEnv === "string") {
    process.env.NODE_ENV = options.nodeEnv;
  }

  let config = await this.loadConfig(options);
  config = await this.buildConfig(config, options);

  let compiler;

  try {
    // 运行 webpack 
    compiler = this.webpack(
      config.options,
      callback
        ? (error, stats) => {
            if (error && this.isValidationError(error)) {
              this.logger.error(error.message);
              process.exit(2);
            }

            callback(error, stats);
          }
        : callback,
    );
  } catch (error) {
    if (this.isValidationError(error)) {
      this.logger.error(error.message);
    } else {
      this.logger.error(error);
    }

    process.exit(2);
  }

  // TODO webpack@4 return Watching and MultiWatching instead Compiler and MultiCompiler, remove this after drop webpack@4
  if (compiler && compiler.compiler) {
    compiler = compiler.compiler;
  }

  return compiler;
}

总结

总结一下 webpack5 中执行打包命令时, webpack 和 webpack-cli 的启动过程:

  • 启动 webpack
    • 检查 webpack-cli 是否安装
  • 启动 webpack-cli
    • 解析 webpack 指令参数
    • 执行打包指令
    • 创建 compiler
    • 运行 webpack 主文件

webpack5 源码解读(1) —— webpack 及 webpack-cli 的启动过程