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

启动 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 的启动过程

原文链接:https://juejin.cn/post/7339892484335124517 作者:不过如此a

(0)
上一篇 2024年2月28日 上午10:43
下一篇 2024年2月28日 上午10:53

相关推荐

发表回复

登录后才能评论