Vue CLI 是一个基于 Vue.js 进行快速开发的完整系统,提供了终端命令行工具、零配置脚手架、插件体系、图形化管理界面等。本文暂且只分析项目初始化部分,也就是终端命令行工具的实现。
- 用法
用法很简单,每个 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]
选项:
-p, --preset
-d, --default 忽略提示符并使用默认预设选项
-i, --inlinePreset
-m, --packageManager
-r, --registry
-g, --git [message] 强制 / 跳过 git 初始化,并可选的指定初始化提交信息
-n, --no-git 跳过 git 初始化
-f, --force 覆写目标目录可能存在的配置
-c, --clone 使用 git clone 获取远程预设选项
-x, --proxy 使用指定的代理创建项目
-b, --bare 创建项目时省略默认组件中的新手指导信息
-h, --help 输出使用帮助信息
具体的用法大家感兴趣的可以尝试一下,这里就不展开了,后续在源码分析中会有相应的部分提到。
- 入口文件
本文中的 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
// 获取额外参数
const options = cleanArgs(cmd);
// 执行 create 方法
require('../lib/create')(name, options);
});
cleanArgs 是获取 vue create 后面通过 - 传入的参数,通过 vue create --help 可以获取执行的参数列表。
获取参数之后就是执行真正的 create 方法了,等等仔细展开。
不得不说,Vue CLI 对于代码模块的管理非常细,每个模块基本上都是单一功能模块,可以任意地拼装和使用。每个文件的代码行数也都不会很多,阅读起来非常舒服。
- 输入命令有误,猜测用户意图
Vue CLI 中比较有意思的一个地方,如果用户在终端中输入 vue creat xxx 而不是 vue create xxx,会怎么样呢?理论上应该是报错了。
如果只是报错,那我就不提了。看看结果:
终端上输出了一行很关键的信息 Did you mean create,Vue CLI 似乎知道用户是想使用 create 但是手速太快打错单词了。
这是如何做到的呢?我们在源代码中寻找答案:
const leven = require('leven');
// 如果不是当前已挂载的命令,会猜测用户意图
program.arguments('
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 这里使用了这个包,来分别计算输入的命令和当前已挂载的所有命令的编辑举例,从而猜测用户实际想输入的命令是哪个。
小而美的一个功能,用户体验极大提升。
- 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 strongly 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(手动狗头)。
- 判断是否在当前路径
在执行 vue create 的时候,是必须指定一个 app-name ,否则会报错: Missing required argument
那如果用户已经自己创建了一个目录,想在当前这个空目录下创建一个项目呢?当然,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,这个逻辑是可以拿来即用的。
- 检查应用名
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
- 若目标文件夹已存在,是否覆盖
这段代码比较简单,就是判断 target 目录是否存在,然后通过交互询问用户是否覆盖(对应的是操作是删除原目录):
// 是否 vue create -mif (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);
}
}
}
}
- 整体错误捕获
在 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);
}
});
};
- 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 返回的是所有用于终端交互的模块,其中会调用 injectFeature 和 injectPrompt 来将交互配置插入进去,并且会通过 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 的时候时候就会把所有用于交互的配置注册好了。
这里我们注意到,在构造函数中出现了四种 prompt: presetPrompt,featurePrompt, injectedPrompts, outroPrompts,具体有什么区别呢?下文有有详细展开。
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' }); // 初始化 gitthis.emit('creation', { event: 'plugins-install' }); // 安装插件this.emit('creation', { event: 'invoking-generators' }); // 调用 generatorthis.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 中的方法触发这些事件。
- 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。