Vue CLI 是如何实现的 -- 终端命令行工具篇

vue

Vue CLI 是一个基于 Vue.js 进行快速开发的完整系统,提供了终端命令行工具、零配置脚手架、插件体系、图形化管理界面等。本文暂且只分析项目初始化部分,也就是终端命令行工具的实现。

0. 用法

用法很简单,每个 CLI 都大同小异:

npm install -g @vue/cli

vue create vue-cli-test

目前 Vue CLI 同时支持 Vue 2 和 Vue 3 项目的创建(默认配置)。

上面是 Vue CLI 提供的默认配置,可以快速地创建一个项目。除此之外,也可以根据自己的项目需求(是否使用 Babel、是否使用 TS 等)来自定义项目工程配置,这样会更加的灵活。

选择完成之后,敲下回车,就开始执行安装依赖、拷贝模板等命令...

看到 Successfully 就是项目初始化成功了。

vue create  命令支持一些参数配置,可以通过 vue create --help  获取详细的文档:

用法:create [options] <app-name>

选项:

-p, --preset <presetName> 忽略提示符并使用已保存的或远程的预设选项

-d, --default 忽略提示符并使用默认预设选项

-i, --inlinePreset <json> 忽略提示符并使用内联的 JSON 字符串预设选项

-m, --packageManager <command> 在安装依赖时使用指定的 npm 客户端

-r, --registry <url> 在安装依赖时使用指定的 npm registry

-g, --git [message] 强制 / 跳过 git 初始化,并可选的指定初始化提交信息

-n, --no-git 跳过 git 初始化

-f, --force 覆写目标目录可能存在的配置

-c, --clone 使用 git clone 获取远程预设选项

-x, --proxy 使用指定的代理创建项目

-b, --bare 创建项目时省略默认组件中的新手指导信息

-h, --help 输出使用帮助信息

具体的用法大家感兴趣的可以尝试一下,这里就不展开了,后续在源码分析中会有相应的部分提到。

1. 入口文件

本文中的 vue cli 版本为 4.5.9。若阅读本文时存在 break change,可能就需要自己理解一下啦

按照正常逻辑,我们在 package.json 里找到了入口文件:

{

"bin": {

"vue": "bin/vue.js"

}

}

bin/vue.js 里的代码不少,无非就是在 vue  上注册了 create / add / ui  等命令,本文只分析 create  部分,找到这部分代码(删除主流程无关的代码后):

// 检查 node 版本

checkNodeVersion(requiredVersion, \'@vue/cli\');

// 挂载 create 命令

program.command(\'create <app-name>\').action((name, cmd) => {

// 获取额外参数

const options = cleanArgs(cmd);

// 执行 create 方法

require(\'../lib/create\')(name, options);

});

cleanArgs  是获取 vue create  后面通过 -  传入的参数,通过 vue create --help 可以获取执行的参数列表。

获取参数之后就是执行真正的 create  方法了,等等仔细展开。

不得不说,Vue CLI 对于代码模块的管理非常细,每个模块基本上都是单一功能模块,可以任意地拼装和使用。每个文件的代码行数也都不会很多,阅读起来非常舒服。

2. 输入命令有误,猜测用户意图

Vue CLI 中比较有意思的一个地方,如果用户在终端中输入 vue creat xxx  而不是 vue create xxx,会怎么样呢?理论上应该是报错了。

如果只是报错,那我就不提了。看看结果:

终端上输出了一行很关键的信息 Did you mean create,Vue CLI 似乎知道用户是想使用 create  但是手速太快打错单词了。

这是如何做到的呢?我们在源代码中寻找答案:

const leven = require(\'leven\');

// 如果不是当前已挂载的命令,会猜测用户意图

program.arguments(\'<command>\').action(cmd => {

suggestCommands(cmd);

});

// 猜测用户意图

function suggestCommands(unknownCommand) {

const availableCommands = program.commands.map(cmd => cmd._name);

let suggestion;

availableCommands.forEach(cmd => {

const isBestMatch =

leven(cmd, unknownCommand) < leven(suggestion || \'\', unknownCommand);

if (leven(cmd, unknownCommand) < 3 && isBestMatch) {

suggestion = cmd;

}

});

if (suggestion) {

console.log(` ` + chalk.red(`Did you mean ${chalk.yellow(suggestion)}?`));

}

}

代码中使用了 leven 了这个包,这是用于计算字符串编辑距离算法的 JS 实现,Vue CLI 这里使用了这个包,来分别计算输入的命令和当前已挂载的所有命令的编辑举例,从而猜测用户实际想输入的命令是哪个。

小而美的一个功能,用户体验极大提升。

3. Node 版本相关检查

3.1 Node 期望版本

create-react-app  类似,Vue CLI 也是先检查了一下当前 Node 版本是否符合要求:

  • 当前 Node 版本: process.version
  • 期望的 Node 版本: require("../package.json").engines.node

比如我目前在用的是 Node v10.20.1 而 @vue/cli 4.5.9  要求的 Node 版本是 >=8.9,所以是符合要求的。

3.2 推荐 Node LTS 版本

bin/vue.js  中有这样一段代码,看上去也是在检查 Node 版本:

const EOL_NODE_MAJORS = [\'8.x\', \'9.x\', \'11.x\', \'13.x\'];

for (const major of EOL_NODE_MAJORS) {

if (semver.satisfies(process.version, major)) {

console.log(

chalk.red(

`You are using Node ${process.version}.\n` +

`Node.js ${major} has already reached end-of-life and will not be supported in future major releases.\n` +

`It\'s spanly recommended to use an active LTS version instead.`

)

);

}

}

可能并不是所有人都了解它的作用,在这里稍微科普一下。

简单来说,Node 的主版本分为奇数版本和偶数版本。每个版本发布之后会持续六个月的时间,六个月之后,奇数版本将变为 EOL 状态,而偶数版本变为 **Active LTS **状态并且长期支持。所以我们在生产环境使用 Node 的时候,应该尽量使用它的 LTS 版本,而不是 EOL 的版本。

EOL 版本:A End-Of-Life version of Node

LTS 版本: A long-term supported version of Node

这是目前常见的 Node 版本的一个情况:

解释一下图中几个状态:

  • CURRENT:会修复 bug,增加新特性,不断改善
  • ACTIVE:长期稳定版本
  • MAINTENANCE:只会修复 bug,不会再有新的特性增加
  • EOL:当进度条走完,这个版本也就不再维护和支持了

通过上面那张图,我们可以看到,Node 8.x 在 2020 年已经 EOL,Node 12.x 在 2021 年的时候也会进入 **MAINTENANCE **状态,而 Node 10.x 在 2021 年 4、5 月的时候就会变成 EOL。

Vue CLI 中对当前的 Node 版本进行判断,如果你用的是 EOL 版本,会推荐你使用 LTS 版本。也就是说,在不久之后,这里的应该判断会多出一个 10.x,还不快去给 Vue CLI 提个 PR(手动狗头)。

4. 判断是否在当前路径

在执行 vue create  的时候,是必须指定一个 app-name ,否则会报错: Missing required argument <app-name> 。

那如果用户已经自己创建了一个目录,想在当前这个空目录下创建一个项目呢?当然,Vue CLI 也是支持的,执行 vue create .  就 OK 了。

lib/create.js  中就有相关代码是在处理这个逻辑的。

async function create(projectName, options) {

// 判断传入的 projectName 是否是 .

const inCurrent = projectName === \'.\';

// path.relative 会返回第一个参数到第二个参数的相对路径

// 这里就是用来获取当前目录的目录名

const name = inCurrent ? path.relative(\'../\', cwd) : projectName;

// 最终初始化项目的路径

const targetDir = path.resolve(cwd, projectName || \'.\');

}

如果你需要实现一个 CLI,这个逻辑是可以拿来即用的。

5. 检查应用名

Vue CLI 会通过 validate-npm-package-name  这个包来检查输入的 projectName 是否符合规范。

const result = validateProjectName(name);

if (!result.validForNewPackages) {

console.error(chalk.red(`Invalid project name: "${name}"`));

exit(1);

}

对应的 npm 命名规范可以见:Naming Rules

6. 若目标文件夹已存在,是否覆盖

这段代码比较简单,就是判断 target  目录是否存在,然后通过交互询问用户是否覆盖(对应的是操作是删除原目录):

// 是否 vue create -m

if (fs.existsSync(targetDir) && !options.merge) {

// 是否 vue create -f

if (options.force) {

await fs.remove(targetDir);

} else {

await clearConsole();

// 如果是初始化在当前路径,就只是确认一下是否在当前目录创建

if (inCurrent) {

const { ok } = await inquirer.prompt([

{

name: \'ok\',

type: \'confirm\',

message: `Generate project in current directory?`,

},

]);

if (!ok) {

return;

}

} else {

// 如果有目标目录,则询问如何处理:Overwrite / Merge / Cancel

const { action } = await inquirer.prompt([

{

name: \'action\',

type: \'list\',

message: `Target directory ${chalk.cyan(

targetDir

)} already exists. Pick an action:`,

choices: [

{ name: \'Overwrite\', value: \'overwrite\' },

{ name: \'Merge\', value: \'merge\' },

{ name: \'Cancel\', value: false },

],

},

]);

// 如果选择 Cancel,则直接中止

// 如果选择 Overwrite,则先删除原目录

// 如果选择 Merge,不用预处理啥

if (!action) {

return;

} else if (action === \'overwrite\') {

console.log(`\nRemoving ${chalk.cyan(targetDir)}...`);

await fs.remove(targetDir);

}

}

}

}

7. 整体错误捕获

create  方法的最外层,放了一个 catch  方法,捕获内部所有抛出的错误,将当前的 spinner  状态停止,退出进程。

module.exports = (...args) => {

return create(...args).catch(err => {

stopSpinner(false); // do not persist

error(err);

if (!process.env.VUE_CLI_TEST) {

process.exit(1);

}

});

};

8. Creator 类

lib/create.js  方法的最后,执行了这样两行代码:

const creator = new Creator(name, targetDir, getPromptModules());

await creator.create(options);

看来最重要的代码还是在 Creator  这个类中。

打开 Creator.js  文件,好家伙,500+ 行代码,并且引入了 12 个模块。当然,这篇文章不会把这 500 行代码和 12 个模块都理一遍,没必要,感兴趣的自己去看看好了。

本文还是梳理主流程和一些有意思的功能。

8.1 constructor 构造函数

先看一下 Creator  类的的构造函数:

module.exports = class Creator extends EventEmitter {

constructor(name, context, promptModules) {

super();

this.name = name;

this.context = process.env.VUE_CLI_CONTEXT = context;

// 获取了 preset 和 feature 的 交互选择列表,在 vue create 的时候提供选择

const { presetPrompt, featurePrompt } = this.resolveIntroPrompts();

this.presetPrompt = presetPrompt;

this.featurePrompt = featurePrompt;

// 交互选择列表:是否输出一些文件

this.outroPrompts = this.resolveOutroPrompts();

this.injectedPrompts = [];

this.promptCompleteCbs = [];

this.afterInvokeCbs = [];

this.afterAnyInvokeCbs = [];

this.run = this.run.bind(this);

const promptAPI = new PromptModuleAPI(this);

// 将默认的一些配置注入到交互列表中

promptModules.forEach(m => m(promptAPI));

}

};

构造函数嘛,主要就是初始化一些变量。这里主要将逻辑都封装在 resolveIntroPrompts / resolveOutroPrompts  和 PromptModuleAPI  这几个方法中。

主要看一下 PromptModuleAPI 这个类是干什么的。

module.exports = class PromptModuleAPI {

constructor(creator) {

this.creator = creator;

}

// 在 promptModules 里用

injectFeature(feature) {

this.creator.featurePrompt.choices.push(feature);

}

// 在 promptModules 里用

injectPrompt(prompt) {

this.creator.injectedPrompts.push(prompt);

}

// 在 promptModules 里用

injectOptionForPrompt(name, option) {

this.creator.injectedPrompts

.find(f => {

return f.name === name;

})

.choices.push(option);

}

// 在 promptModules 里用

onPromptComplete(cb) {

this.creator.promptCompleteCbs.push(cb);

}

};

这里我们也简单说一下,promptModules  返回的是所有用于终端交互的模块,其中会调用 injectFeatureinjectPrompt 来将交互配置插入进去,并且会通过 onPromptComplete  注册一个回调。

onPromptComplete 注册回调的形式是往 promptCompleteCbs 这个数组中 push 了传入的方法,可以猜测在所有交互完成之后应该会通过以下形式来调用回调:

this.promptCompleteCbs.forEach(cb => cb(answers, preset));

回过来看这段代码:

module.exports = class Creator extends EventEmitter {

constructor(name, context, promptModules) {

const promptAPI = new PromptModuleAPI(this);

promptModules.forEach(m => m(promptAPI));

}

};

Creator  的构造函数中,实例化了一个 promptAPI  对象,并遍历 prmptModules  把这个对象传入了 promptModules  中,说明在实例化 Creator  的时候时候就会把所有用于交互的配置注册好了。

这里我们注意到,在构造函数中出现了四种 promptpresetPromptfeaturePromptinjectedPromptsoutroPrompts,具体有什么区别呢?下文有有详细展开。

8.2 EventEmitter 事件模块

首先, Creator  类是继承于 Node.js 的 EventEmitter 类。众所周知, events  是 Node.js 中最重要的一个模块,而 EventEmitter 类就是其基础,是 Node.js 中事件触发与事件监听等功能的封装。

在这里, Creator  继承自 EventEmitter , 应该就是为了方便在 create  过程中 emit  一些事件,整理了一下,主要就是以下 8 个事件:

this.emit(\'creation\', { event: \'creating\' }); // 创建

this.emit(\'creation\', { event: \'git-init\' }); // 初始化 git

this.emit(\'creation\', { event: \'plugins-install\' }); // 安装插件

this.emit(\'creation\', { event: \'invoking-generators\' }); // 调用 generator

this.emit(\'creation\', { event: \'deps-install\' }); // 安装额外的依赖

this.emit(\'creation\', { event: \'completion-hooks\' }); // 完成之后的回调

this.emit(\'creation\', { event: \'done\' }); // create 流程结束

this.emit(\'creation\', { event: \'fetch-remote-preset\' }); // 拉取远程 preset

我们知道事件 emit  一定会有 on  的地方,是哪呢?搜了一下源码,是在 @vue/cli-ui 这个包里,也就是说在终端命令行工具的场景下,不会触发到这些事件,这里简单了解一下即可:

const creator = new Creator(\'\', cwd.get(), getPromptModules());

onCreationEvent = ({ event }) => {

progress.set({ id: PROGRESS_ID, status: event, info: null }, context);

};

creator.on(\'creation\', onCreationEvent);

简单来说,就是通过 vue ui  启动一个图形化界面来初始化项目时,会启动一个 server 端,和终端之间是存在通信的。 server 端挂载了一些事件,在 create 的每个阶段,会从 cli 中的方法触发这些事件。

9. Preset(预设)

Creator  类的实例方法 create  接受两个参数:

  • cliOptions:终端命令行传入的参数
  • preset:Vue CLI 的预设

9.1 什么是 Preset(预设)

Preset 是什么呢?官方解释是一个包含创建新项目所需预定义选项和插件的 JSON 对象,让用户无需在命令提示中选择它们。比如:

{

"useConfigFiles": true,

"cssPreprocessor": "sass",

"plugins": {

"@vue/cli-plugin-babel": {},

"@vue/cli-plugin-eslint": {

"config": "airbnb",

"lintOn": ["save", "commit"]

}

},

"configs": {

"vue": {...},

"postcss": {...},

"eslintConfig": {...},

"jest": {...}

}

}

在 CLI 中允许使用本地的 preset 和远程的 preset。

9.2 prompt

用过 inquirer 的朋友的对 prompt 这个单词一定不陌生,它有 input / checkbox 等类型,是用户和终端的交互。

我们回过头来看一下在 Creator 中的一个方法 getPromptModules, 按照字面意思,这个方法是获取了一些用于交互的模块,具体来看一下:

exports.getPromptModules = () => {

return [

\'vueVersion\',

\'babel\',

\'typescript\',

\'pwa\',

\'router\',

\'vuex\',

\'cssPreprocessors\',

\'linter\',

\'unit\',

\'e2e\',

].map(file => require(`../promptModules/${file}`));

};

看样子是获取了一系列的模块,返回了一个数组。我看了一下这里列的几个模块,代码格式基本都是统一的::

module.exports = cli => {

cli.injectFeature({

name: \'\',

value: \'\',

short: \'\',

description: \'\',

link: \'\',

checked: true,

});

cli.injectPrompt({

name: \'\',

when: answers => answers.features.includes(\'\'),

message: \'\',

type: \'list\',

choices: [],

default: \'2\',

});

cli.onPromptComplete((answers, options) => {});

};

单独看 injectFeatureinjectPrompt 的对象是不是和 inquirer 有那么一点神似?是的,他们就是用户交互的一些配置选项。那 Feature  和 Prompt  有什么区别呢?

Feature:Vue CLI 在选择自定义配置时的顶层选项:

Prompt:选择具体 Feature 对应的二级选项,比如选择了 Choose Vue version 这个 Feature,会要求用户选择是 2.x 还是 3.x:

onPromptComplete 注册了一个回调方法,在完成交互之后执行。

看来我们的猜测是对的, getPromptModules 方法就是获取一些用于和用户交互的模块,比如:

  • babel:选择是否使用 Babel
  • cssPreprocessors:选择 CSS 的预处理器(Sass、Less、Stylus)
  • ...

先说到这里,后面在自定义配置加载的章节里会展开介绍 Vue CLI 用到的所有 prompt 。

9.3 获取预设

我们具体来看一下获取预设相关的逻辑。这部分代码在 create  实例方法中:

// Creator.js

module.exports = class Creator extends EventEmitter {

async create(cliOptions = {}, preset = null) {

const isTestOrDebug = process.env.VUE_CLI_TEST || process.env.VUE_CLI_DEBUG;

const { run, name, context, afterInvokeCbs, afterAnyInvokeCbs } = this;

if (!preset) {

if (cliOptions.preset) {

// vue create foo --preset bar

preset = await this.resolvePreset(cliOptions.preset, cliOptions.clone);

} else if (cliOptions.default) {

// vue create foo --default

preset = defaults.presets.default;

} else if (cliOptions.inlinePreset) {

// vue create foo --inlinePreset {...}

try {

preset = JSON.parse(cliOptions.inlinePreset);

} catch (e) {

error(

`CLI inline preset is not valid JSON: ${cliOptions.inlinePreset}`

);

exit(1);

}

} else {

preset = await this.promptAndResolvePreset();

}

}

}

};

可以看到,代码中分别针对几种情况作了处理:

  • cli 参数配了 --preset
  • cli 参数配了 --default
  • cli 参数配了 --inlinePreset
  • cli 没配相关参数,默认获取 Preset 的行为

前三种情况就不展开说了,我们来看一下第四种情况,也就是默认通过交互 prompt  来获取 Preset 的逻辑,也就是 promptAndResolvePreset  方法。

先看一下实际用的时候是什么样的:

我们可以猜测这里就是一段 const answers = await inquirer.prompt([])  代码。

 async promptAndResolvePreset(answers = null) {

// prompt

if (!answers) {

await clearConsole(true);

answers = await inquirer.prompt(this.resolveFinalPrompts());

}

debug("vue-cli:answers")(answers);

}

resolveFinalPrompts() {

this.injectedPrompts.forEach((prompt) => {

const originalWhen = prompt.when || (() => true);

prompt.when = (answers) => {

return isManualMode(answers) && originalWhen(answers);

};

});

const prompts = [

this.presetPrompt,

this.featurePrompt,

...this.injectedPrompts,

...this.outroPrompts,

];

debug("vue-cli:prompts")(prompts);

return prompts;

}

是的,我们猜的没错,将 this.resolveFinalPrompts  里的配置进行交互,而 this.resolveFinalPrompts  方法其实就是将在 Creator  的构造函数里初始化的那些 prompts  合到一起了。上文也提到了有这四种 prompt,在下一节展开介绍。

**

9.4 保存预设

在 Vue CLI 的最后,会让用户选择 save this as a preset for future?,如果用户选择了 Yes,就会执行相关逻辑将这次的交互结果保存下来。这部分逻辑也是在 promptAndResolvePreset 中。

async promptAndResolvePreset(answers = null)  {

if (

answers.save &&

answers.saveName &&

savePreset(answers.saveName, preset)

) {

log();

log(

`

以上是 Vue CLI 是如何实现的 -- 终端命令行工具篇 的全部内容, 来源链接: utcz.com/z/375095.html

回到顶部