webpack-cli 性能优化了解一下?

avatar
@滴滴出行

作者:马宾

1. 背景

前一段时间偶然间注意到伴随着 webpack 版本从 V4 升级到 V5, webpack-cli 也发生了大版本升级,但当时重心不是 webpack-cli 所以只是草草看了一下。

恰逢组内有一场有关 CLI 的讨论,我想到了 webpack-cli 的升级,随着深入发现 webpack-cli 经历了 V3 - V5 的升级,这些升级给我带来了不小的惊喜,主要体现在两方面:

  • 生成式的 CLI 实现方式;
  • 懒加载思想在 CLI 上的实践;

webpack-cli 从 V3 升级到 V5 中间有一个过渡版本 V4,V5 主要做了限制 webpack 及其依赖包的最低版本号等工作,目前 webpack-cli V5 仅支持 5.0 以上的 webpack。整体实现上,与 V4 与 V5 没有特性层面的升级,本文以 V5 代码为例进行讨论。

2. webpack-cli 安装及使用

2.1 webpack-cli 安装

  1. 执行安装命令
npm install -D webpack webpack-cli 
  1. bin 命令注册

webpack/webpack-cli npm 包的 package.json 中包含以下 bin 字段:

  "bin": {
    "webpack-cli": "bin/cli.js"
  },

在本地安装时,执行 npm install 的过程中会将 bin 对应的可执行文件软链接到 **node_modules/.bin** 目录中,作为一个可以通过以下两种方式之一调用的命令:

  • **package.json.scrips + npm run**
  • **npx** 

image.png

2.2 用法

# 用法
 npx webpack watch [options]
# 示例
npx webpack watch --mode development

3. webpack 实现

webpack 命令行由 node_modules/.bin/webpack 实现(下称 webpack),该可执行文件是 webpack/bin/webpack.js 的软链。

3.1 工作原理

webpack/bin/webpack.js 内部依赖 webpack-cli 包(下称 webpack-cli),webpack 并不负责具体的 CLI 命令的实现和处理等具体的工作,具体工作均由 webpack-cli 实现。

webpack 主要就 webpack-cli 的安装情况进行检测,如安装了则执行调用 webpack-cli ,若未安装则进行安装引导,用户同意后进行安装,安装结束后再调用 webpack-cli 执行具体的动作。

webpack 命令工作原理如下图:

image (1).png  

3.2 实现细节

根据上面的工作原理图,现就图中的各个步骤的实现细节进行详细讨论。

3.2.1 webpack-cli 包安装检测

首先,脚本内创建 cli 对象,存储 webpack-cli 包括安装情况在内的信息:

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

webpack 脚本内部采用 isInstalled 方法检查安装情况,其原理是:

fs.statSync 获取 stat 对象,再通过 stat.isDierectory() 判断 webpack-cli 目录是否存在:

const isInstalled = packageName => {
  if (process.versions.pnp) {
		return true;
  }
  
  const path = require("path");
  const fs = require("graceful-fs");
  
  let dir = __dirname;
  
  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;
};

while 循环从 node_modules/webpack/bin 下面这个目录开始逐级的向上查找,一直找到根目录下的 node_mdules 的过程:

while (dir !== (dir = path.dirname(dir)))

dir 的初始值是 webpack 脚本文件所在目录,即 /user/didi/Document/Proj/node_modules/webpack/bin ,通过 dir = path.dirname(dir)  逐级找到 / 根目录,以下即为查找的最长链路:

  • /user/didi/Document/Proj/node_modules/webpack/bin
  • /user/didi/Document/Proj/node_modules/webpack
  • /user/didi/Document/Proj/node_modules
  • /user/didi/Document/Proj
  • /user/didi/
  • /user
  • /

当找到 / 时,path.dirname('/') == '/' 返回 true,此时相当于查找到了根目录,如果没有找到则认定没有。

之所以有找到全局这个动作,是因为 webpack-cli 支持全局安装,因此查找路径到需要到根目录。这里其实对应的背后是 Node.js 查找依赖包的规则。

3.2.2 处理安装检测结果

根据 cli.installed 属性得出 webpack-cli 安装情况,若安装则调用 cli,未安装引导安装;

if (!cli.installed) {
   // 引导安装
} else {
  // 调用
}

3.2.2.1 已经安装

已经安装调用 runCli 方法并传入 cli 对象;

runCli(cli);

runCli 方法是 webpack 脚本内部提供的加载 webpack-cli 模块的方法,其核心实现就是 require webpack-cli 模块:

const runCli = cli => {
  // ....
  require(path.resolve(path.dirname(pkgPath), pkg.bin[cli.binName]));
};

3.2.2.2 未安装

当检测所得结果是 webpack-cli 尚未安装时,webpack 会引导用户进行安装,具体工作如下:

  1. 包管理器检查

根据 yarn.lock 判定 yarn,根据 pnpm-lock.yaml 判定 pnpm ,否则兜底采用 npm;

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";
}
  1. 创建 REPL 接口

webpack 通过 Node.js 提供的 readline 模块完成交互式命令行界面,传送门readline

const questionInterface = readLine.createInterface({
 input: process.stdin,
 output: process.stderr
});
  1. REPL 询问用户并处理用户输入

下面会询问用户是否打算安装 webpack

const question = `Do you want to install 'webpack-cli' (yes/no): `;

发起询问通过 questionInterface.question 方法:

// 询问
questionInterface.question(question, answer => {
   // 这个回调用户接受用户输入		
});
  1. 处理用户输入

如果用户输入不是 y/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;

如果是 y/Y 开头的,认定用户输入需要安装,此时调用 runCommand 方法,结合上面的包管理器检测结论进行自动安装 webpack-cli;  

runCommand(packageManager, installOptions.concat(cli.package))

runCommand 内部使用 Node.js 原生模块 child_process 创建子进程共享当前管道进行安装:

const runCommand = (command, args) => {
 const cp = require("child_process");
 return new Promise((resolve, reject) => {
  const executedCommand = cp.spawn(command, args, {
   stdio: "inherit",
   shell: true
  });

  executedCommand.on("error", error => {
   reject(error);
  });

  executedCommand.on("exit", code => {
   if (code === 0) {
    resolve();
   } else {
    reject();
   }
  });
 });
}

成功安装后同样采用 runCli 调用 webpack-cli 处理 webpack 的子命令。

runCommand(packageManager, installOptions.concat(cli.package))
 .then(() => {
  runCli(cli);
 })

4. webpack-cli 实现

结合上面的 webpack 实现中可以得知,webpack 脚本最后通过 runCli 方法 加载 webpack-cli/bin/cli.js。

下面我们来看 webpack-cli 工作流程: 

image (2).png

4.1 webpack-cli/bin/cli.js

cli.js 内部就做了一件事,导入 ../lib/bootstrap 模块,并且执行该模块的导入传入 process.argv 进程参数;

#!/usr/bin/env node
const runCLI = require("../lib/bootstrap");

process.title = "webpack";

runCLI(process.argv)

4.2 webpack-cli/lib/bootstrap.js

该模块导出了一个函数 runCLI: 

const WebpackCLI = require("./webpack-cli");
const runCLI = async (args) => {
 
    const cli = new WebpackCLI();
    try {
        await cli.run(args);
    }
    catch (error) {
    }
};
module.exports = runCLI;

runCLI 函数内部创建 WebpackCLI 实例 cli,然后调用 cli.run() 方法。run 方法是 WebpackCLI 类型的入口方法。  

4.3 webpack-cli/lib/webpack-cli.js

该模块是整个 webpack CLI 界面实现核心部分,这个类型使用 comamnder 包在运行时解析用户输入创建并执行相应命令。

class WebpackCLI {
  constructor () {}
  run (args, parsOptions) {}
}
module.exports = WebpackCLI

webpack-cli 内置了以下四个命令,这些命令开箱即用:

此外,剩下的命令,webpack-cli 做了“特殊处理”,即 webpack-cli 里面的 "exteralBuildInCommands" 即 【外置内建命令】,这些命令包括:

以上命令在 webpack-cli 内部称为 "knownCommands "【已知命令】

4.3.1 contructor 

构造函数内部通过 commander 创建了 program 对象并挂在到 WebpackCLI 实例之上:

    constructor() {
        this.colors = this.createColors();
        this.logger = this.getLogger();
        // Initialize program
        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())}`),
        });
    }

4.3.2 run 方法

run 方法是 WebpackCLI 的主入口

  1. exitOverride 改写退出

这是由于 comander 在声明式的命令行有一些默认的退出机制,比如没有找到命令等情况,但是在 webpack-cli 这种生成式的 CLI 中,有些命令可能是运行时生成的,所以不能直接退出,需要做一些拦截动作,然后自定义退出过程。

this.program.exitOverride(async (error) => {....})
  1. 注册 color/no-color options
this.program.option("--color", "Enable colors on console.");
this.program.on("option:color", function () {
    // @ts-expect-error shadowing 'this' is intended
    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 () {
    // @ts-expect-error shadowing 'this' is intended
    const { color } = this.opts();
    cli.isColorSupportChanged = color;
    cli.colors = cli.createColors(color);
});

让 webpack 命令行的输出五颜六色的,这些不作为重点讨论!

  1. 注册 version option
const outputVersion = async (options) => {})
 this.program.option("-v, --version", "Output the version number of 'webpack', 'webpack-cli' and 'webpack-dev-server' and commands.");

这里有个 outputVersion 方法,该方法内部输出 webpack 包的版本信息;

  1. 处理 help option

注意,webpack-cli 同样静默了 commander 默认的 help 命令支持;

.addHelpCommand()

A help command is added by default if your command has subcommands. You can explicitly turn on or off the implicit help command with .addHelpCommand() and .addHelpCommand(false).

this.program.helpOption(false);
this.program.addHelpCommand(false);
this.program.option("-h, --help [verbose]", "Display help for commands and options.");

在稍后的生成式命令中,webpack-cli 自己处理 help 命令具体动作。

  1. 注册 action handler
this.program.action(async (options, program) => {})

action handler 是 webpack-cli 生成式 CLI 的大脑,在 action handler 内部主要做了以下工作:

5.1 解析进程参数获取 operands, options

// Command and options
const { operands, unknown } = this.program.parseOptions(program.args);
const defaultCommandToRun = getCommandName(buildCommandOptions.name);
const hasOperand = typeof operands[0] !== "undefined";
const operand = hasOperand ? operands[0] : defaultCommandToRun;

5.2 判断是否是 help

判断如果是 --help 及相关 help 语法则调用前文注册的 outputHelp 方法输出帮助信息!

const isHelpOption = typeof options.help !== "undefined";
const isHelpCommandSyntax = isCommand(operand, helpCommandOptions);
if (isHelpOption || isHelpCommandSyntax) {
    await outputHelp(optionsForHelp, isVerbose, isHelpCommandSyntax, program);
}

5.3 判断是否是 version

与上面 help 类似,如果是 --version 类似语法,则输出 version 相关信息

const isVersionOption = typeof options.version !== "undefined";
const isVersionCommandSyntax = isCommand(operand, versionCommandOptions);
if (isVersionOption || isVersionCommandSyntax) {
    await outputVersion(optionsForVersion);
}

5.4 处理非 help 或 version 的语法

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

operand 在前面判断过,如果没有传递则默认使用 build 命令:

const defaultCommandToRun = getCommandName(buildCommandOptions.name);
const operand = hasOperand ? operands[0] : defaultCommandToRun;

commandOperands 则是webpack 子命令的操作数;

5.5 判断 commandToRun 是否为已知命令

所谓已知命令就是前面提到的 "knownCommands",如果是则直接进行加载并执行的的动作

if (isKnownCommand(commandToRun)) {
    await loadCommandByName(commandToRun, true);
}

loadingCommandByName 方法用于加载并创建命令,然后执行执行命令,该方法将外部传入的已知命名分为以下四种情况处理:

  • commandToRun 是 build 或者 watch 命令
  • commandToRun 是 help 命令
  • commandToRun 是 version 命令
  • commandToRun 是 externalBuiltIn 命令
const loadCommandByName = async (commandName, allowToInstall = false) => {
    const isBuildCommandUsed = isCommand(commandName, buildCommandOptions);
    const isWatchCommandUsed = isCommand(commandName, watchCommandOptions);
    if (isBuildCommandUsed || isWatchCommandUsed) {
        // 处理 webpack build/watch
    }
    else if (isCommand(commandName, helpCommandOptions)) {
        // 处理 help
        this.makeCommand(helpCommandOptions, [], () => { });
    }
    else if (isCommand(commandName, versionCommandOptions)) {
        // 处理 version
        this.makeCommand(versionCommandOptions, [], () => { });
    }
    else {
        // 处理 externalBuiltInCommand loading
    }
};

前三种直接调用 WebpackCLI.prototype.makeCommand 创建本次要运行的子命令(详情见下面 4.3.3 makeCommand);makeCommand 结束后,需要运行的命令就生成,静待触发。

这里以 webpack build/watch 为例看下:

this.makeCommand(isBuildCommandUsed ? buildCommandOptions : watchCommandOptions, async () => {
    // 这个函数是 webpack 运行所支持的 options
    this.webpack = await this.loadWebpack(); // 加载 webpack 包
    return isWatchCommandUsed
        ? this.getBuiltInOptions().filter((option) => option.name !== "watch")
        : this.getBuiltInOptions();
}, async (entries, options) => {
    // 这个就是 webpack build/watch 的 action handler 函数
    // 当用户执行 npx webpack watch 就会执行这个命令
    if (entries.length > 0) {
        options.entry = [...entries, ...(options.entry || [])];
    }
    await this.runWebpack(options, isWatchCommandUsed);
});

除了上面的三种情况快,最后一种就是 externalBuiltInCommand 加载过程。这个名字着实让人很迷惑,为什么叫“外置”,还“内建”?

所谓外置,是因为 webpack-cli 这个包只包含了基础的 build/watch/help/version 的实现,剩下命令的实现被拆分到了其他的包中。

对于webpack-cli 来说,命令确实是支持了不额外扩展,这所谓“内建”,但是真真正正实现这个命令的脚本文件在另一个依赖包中,这就是“外置”。

 const builtInExternalCommandInfo = externalBuiltInCommandsInfo.find((externalBuiltInCommandInfo) => getCommandName(externalBuiltInCommandInfo.name) === commandName ||
        (Array.isArray(externalBuiltInCommandInfo.alias)
            ? externalBuiltInCommandInfo.alias.includes(commandName)
            : externalBuiltInCommandInfo.alias === commandName));
    let pkg;
    if (builtInExternalCommandInfo) {
        ({ pkg } = builtInExternalCommandInfo);
    }
    else {
        pkg = commandName;
    }
    if (pkg !== "webpack-cli" && !this.checkPackageExists(pkg)) {
        if (!allowToInstall) {
            return;
        }
        pkg = await this.doInstall(pkg, {
            preMessage: () => {
                this.logger.error(`For using this command you need to install: '${this.colors.green(pkg)}' package.`);
            },
        });
    }
    let loadedCommand;
    try {
        loadedCommand = await this.tryRequireThenImport(pkg, false);
    }
    catch (error) {
        // Ignore, command is not installed
        return;
    }
    let command;
    try {
        command = new loadedCommand();
        await command.apply(this);
    }
    catch (error) {
        this.logger.error(`Unable to load '${pkg}' command`);
        this.logger.error(error);
        process.exit(2);
    }
}

5.6 处理未知命令

前文中介绍过 webpack 内置了 11 个命令,除此之外的都算作未知命令。处理未知命令有两种情况:

5.6.1 entry 语法

webpack CLI 支持 entry 语法:

$ npx webpack <entry> --output-path <output-path>

处理 webpack enry 语法时首先检测传入的 入口文件是否存在,若存在则按照webpack 的默认命令 build 进行加载。 

image (3).png

5.6.2 错误命令

如果是未知命令切不是入口语法的情况下,webpack CLI 认定我们的输入有误,CLI 此时会尝试查找与输入单词最接近的命令并提示到命令行;

webpack-cli 内部使用 fastest-levenshtein 找到与输入最接近的命令;

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);

5.7 调用 program.parseAsyanc 执行新创建的命令

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

下面我们简单了解一下 webpack-cli 用于动态生成命令行的方法 makeCommand 以及 cli 内部自动安装的 doInstall 方法!

4.3.3 makeCommand

  1. 签名:
  • commandOptions:创建命令所需 option
  • options:命令执行所需 options
  • action:处理命令的 action handler

1.1 函数工作流

  1. 判断是否是已经加载过的命令,若已加载则不再 make
  2. 调用 program.comman() 注册新的子命令
  3. 注册 command.description() 描述信息
  4. 注册 command.usage() 用法信息
  5. 注册 command.alias() 别名信息
  6. 检查命令的依赖包的安装信息
  7. 为新增的 command 注册传入的 options
  8. 最后为新 command 注册 aciton handler
async makeCommand(commandOptions, options, action) {
    // 校验是否已经 make 过
    const alreadyLoaded = this.program.commands.find((command) => command.name() === commandOptions.name.split(" ")[0] ||
        command.aliases().includes(commandOptions.alias));
    if (alreadyLoaded) {
    // 已经 make 过了终止
        return;
    }


    // 注册新命令
    const command = this.program.command(commandOptions.name, {
        noHelp: commandOptions.noHelp,
        hidden: commandOptions.hidden,
        isDefault: commandOptions.isDefault,
    });


    // 注册 options
    if (commandOptions.description) {
        command.description(commandOptions.description, commandOptions.argsDescription);
    }
    
    // 注册 usase
    if (commandOptions.usage) {
        command.usage(commandOptions.usage);
    }
     
    // 注册别名
    if (Array.isArray(commandOptions.alias)) {
        command.aliases(commandOptions.alias);
    }
    else {
        command.alias(commandOptions.alias);
    }


    if (commandOptions.pkg) {
        command.pkg = commandOptions.pkg;
    }
    else {
        command.pkg = "webpack-cli";
    }
    const { forHelp } = this.program;
    let allDependenciesInstalled = true;


    // 检查依赖并安装缺失依赖
    if (commandOptions.dependencies && commandOptions.dependencies.length > 0) {
        for (const dependency of commandOptions.dependencies) {
            // 校验依赖是否在 webpack 目录下存在
            const isPkgExist = this.checkPackageExists(dependency);
            if (isPkgExist) {
                continue;
            }
            else if (!isPkgExist && forHelp) {
                allDependenciesInstalled = false;
                continue;
            }
            let skipInstallation = false;
        
            // Allow to use `./path/to/webpack.js` outside `node_modules`
            if (dependency === WEBPACK_PACKAGE && fs.existsSync(WEBPACK_PACKAGE)) {
                skipInstallation = true;
            }
            // Allow to use `./path/to/webpack-dev-server.js` outside `node_modules`
            if (dependency === WEBPACK_DEV_SERVER_PACKAGE && fs.existsSync(WEBPACK_PACKAGE)) {
                skipInstallation = true;
            }
            if (skipInstallation) {
                continue;
            }
            // 自动安装缺失的依赖
            await this.doInstall(dependency, {
                preMessage: () => { 输出警告信息  },
            });
        }
    }
    if (options) {
      // 注册 options
    }
     
    // 注册 action handler
    command.action(action);
    return command

4.3.4 doInstall

doInstall 方法和前面的 webpack 脚本引导安装 webpack-cli 的实现思路异曲同工,不再展开,大致工作如下:

  1. 获取包管理器
  2. 创建 REPL 引导用户输入
  3. 创建子进程执行安装命令
async doInstall(packageName, options = {}) {
    // 获取包管理器i
    const packageManager = this.getDefaultPackageManager();
    if (!packageManager) {
        this.logger.error("Can't find package manager");
        process.exit(2);
    }
    if (options.preMessage) {
        options.preMessage();
    }
    // 创建 REPL 
    const prompt = ({ message, defaultResponse, stream }) => {
        const readline = require("readline");
        const rl = readline.createInterface({
            input: process.stdin,
            output: stream,
        });
        return new Promise((resolve) => {
            rl.question(`${message} `, (answer) => {
                // Close the stream
                rl.close();
                const response = (answer || defaultResponse).toLowerCase();
                // Resolve with the input response
                if (response === "y" || response === "yes") {
                    resolve(true);
                }
                else {
                    resolve(false);
                }
            });
        });
    };
    // yarn uses 'add' command, rest npm and pnpm both use 'install'
    const commandArguments = [packageManager === "yarn" ? "add" : "install", "-D", packageName];
    const commandToBeRun = `${packageManager} ${commandArguments.join(" ")}`;
    let needInstall;
    try {
        needInstall = await prompt({
            message: `[webpack-cli] Would you like to install '${this.colors.green(packageName)}' package? (That will run '${this.colors.green(commandToBeRun)}') (${this.colors.yellow("Y/n")})`,
            defaultResponse: "Y",
            stream: process.stderr,
        });
    }
    catch (error) {
        this.logger.error(error);
        process.exit(error);
    }
    if (needInstall) {
        // 子进程执行安装命令
        const { sync } = require("cross-spawn");
        try {
            sync(packageManager, commandArguments, { stdio: "inherit" });
        }
        catch (error) {
            this.logger.error(error);
            process.exit(2);
        }
        return packageName;
    }
    process.exit(2);
}

5. 总结

5.1 整体流程核心设计

下图是一张 webpack-cli 的整体工作流程:

image (4).png

整体来看 webpack-cli 通过以下过程实现整个 webpack-cli 工作:

  1. 接收从 webpack 命令的整体输入 process.argv
  2. 解析 process.argv 得到 operands 和 options
  3. 假设 options 是 help/version 语法并处理,即 --help/-h 或者 --version/-v;
  4. 如果不是 help/version 的 options 语法则注册 actioon handler,在 action handler 主要生成子命令并执行,主要包含以下过程:
    1. 判断是否为 help/version 命令语法,即 webpack help/version 
    2. 调用 loadCommandByName 通过名字加载并调用 this.makeCommand 注册子命令或者加载外置的命令再注册,最终得到生成的子命令
    3. 调用 this.program.parseAsync([子命令]) 执行新生成的子命令
  5. 调用 this.program.parseAsync(process.argv) 解析外部输入

5.2 对比 V3

对比 V3,V5 在架构层面做了以下升级:

  1. 从 optiosn 向子命令转型,V3 中以 --flgs 为主,V5则是子命令;
  2. 剥离与常用构建无关的命令,即 externalBuiltInCommand 的实现;
  3. 转用生成式的CLI 命令创建和执行;

那这里最值得玩味的是最后两点,现在大家开始思考为什么这么做?

5.3 为什么这么做?

5.3.1 外置内建命令设计

要回答为什么的问题首先需要回忆大家日常最常用的命令:

  • webpack 或(webpack watch、webpack build)
  • webpack help 或(webpack -h)
  • webpack version 或者(webpack -v)

有没有发现,这些命令刚好是 webpack 的内建且内置的命令,其余的都拆分到了其他 npm 包里面。

这个思路和我们常见的优化较大规模应用加载性能的思路一样——懒加载

其核心是根据用户使用场景,用的多的命令内建内置,所见即所得的体验。其他不常用的命令不用不安装、不加载,待用时则自动安装!

5.3.2 生成式命令行设计

说完了 externalBuiltInCommands 设计的优势,再看第二点生成式 CLI 的好处是什么。

先来看一个声明式的命令行工具:

import { Command } from 'commander';

const program = new Command();

program.version(require('../package').version)
	.usage('<command> [options]')
	.command('q', 'get challenge sms code from didifarm')
	.command('add', 'add some alias:phone to config.json')
	.command('cddfarm', 'modify didifarm config')
	.command('ls', 'list cfg.json in ~ dir')
	.command('cp', 'cp an phone of an alias')
	.parse(process.argv)

这个命令行工具是我们内部实现的一个查询工具

这个工具在命令行启动的一瞬间会把这个命令实现的 q、add、cddfarm、ls、cp 所有命令的相关模块全部加载。

webpack 的生成式命令行伪代码可以简化成以下的条件语句:

// 获取用户输出 npx webpack build
const { operands, options } = this.program.parseOptions(process.argv);

if (operands === helpSyntax) {
  // 注册 help 命令
 this.makeCommand(helpOps)
} else if (operands === versionSyntax) {
  // 注册 version 命令
 this.makeCommand(versionOps)
} else if (operands === buildSyntax) {
  // 注册 build/watch 命令
 this.makeCommand(buildWathcOpts)
} else if (...) {
   this.makeCommand(.....)
}

这样我们会发现 webpack-cli 内部只注册当前输入要执行的命令,这样也就只加载这 1 个被注册命令相关模块,其余的命令 webpack 并不注册,模块也就不会被加载!

通过上面的分析比对我们可以发现,整个设计都是在尽可能减少命令行启动时需要加载的模块数量

这种设计的优势也是不言而喻的,通过直接减少运行加载的模块数量降低运行时内存开销,提升 webpack-cli 运行时性能!