原文链接 http://axuebin.com/articles/fe-solution/cli/vuecli.html 转载请联系。
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 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(手动狗头)。
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
返回的是所有用于终端交互的模块,其中会调用 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' }); // 初始化 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) => {});
};
单独看 injectFeature
和 injectPrompt
的对象是不是和 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(
`🎉 Preset ${chalk.yellow(answers.saveName)} saved in ${chalk.yellow(
rcPath
)}`
);
}
}
在调用 savePreset
之前还会对预设进行解析、校验等,就不展开了,直接来看一下 savePreset
方法:
exports.saveOptions = toSave => {
const options = Object.assign(cloneDeep(exports.loadOptions()), toSave);
for (const key in options) {
if (!(key in exports.defaults)) {
delete options[key];
}
}
cachedOptions = options;
try {
fs.writeFileSync(rcPath, JSON.stringify(options, null, 2));
return true;
} catch (e) {
error(
`Error saving preferences: ` +
`make sure you have write access to ${rcPath}.\n` +
`(${e.message})`
);
}
};
exports.savePreset = (name, preset) => {
const presets = cloneDeep(exports.loadOptions().presets || {});
presets[name] = preset;
return exports.saveOptions({ presets });
};
代码很简单,先深拷贝一份 Preset(这里直接用的 lodash 的 clonedeep),然后进过一些 merge
的操作之后就 writeFileSync
到上文有提到的 .vuerc
文件了。
10. 自定义配置加载
这四种 prompt
分别对应的是预设选项、自定义 feature 选择、具体 feature 选项和其它选项,它们之间存在互相关联、层层递进的关系。结合这四种 prompt
,就是 Vue CLI 展现开用户面前的所有交互了,其中也包含自定义配置的加载。
10.1 presetPrompt: 预设选项
也就是最初截图里看到的哪三个选项,选择 Vue2 还是 Vue3 还是自定义 feature
:
如果选择了 Vue2
或者 Vue3
,则后续关于 preset
所有的 prompt
都会终止。
10.2 featurePrompt: 自定义 feature 选项
**
如果在 presetPrompt
中选择了 Manually
,则会继续选择 feature
:
featurePrompt
就是存储的这个列表,对应的代码是这样的:
const isManualMode = answers => answers.preset === '__manual__';
const featurePrompt = {
name: 'features',
when: isManualMode,
type: 'checkbox',
message: 'Check the features needed for your project:',
choices: [],
pageSize: 10,
};
在代码中可以看到,在 isManualMode
的时候才会弹出这个交互。
10.3 injectedPrompts: 具体 feature 选项
featurePrompt
只是提供了一个一级列表,当用户选择了 Vue Version
/ Babel
/ TypeScript
等选项之后,会弹出新的交互,比如 Choose Vue version
:
injectedPrompts
就是存储的这些具体选项的列表,也就是上文有提到通过 getPromptModules
方法在 promptModules
目录获取到的那些 prompt
模块:
对应的代码可以再回顾一下:
cli.injectPrompt({
name: 'vueVersion',
when: answers => answers.features.includes('vueVersion'),
message: 'Choose a version of Vue.js that you want to start the project with',
type: 'list',
choices: [
{
name: '2.x',
value: '2',
},
{
name: '3.x (Preview)',
value: '3',
},
],
default: '2',
});
可以看到,在 answers => answers.features.includes('vueVersion')
,也就是 featurePrompt
的交互结果中如果包含 vueVersion
就会弹出具体选择 Vue Version
的交互。
10.4 outroPrompts: 其它选项
**
这里存储的就是一些除了上述三类选项之外的选项目前包含三个:
Where do you prefer placing config for Babel, ESLint, etc.? Babel,ESLint 等配置文件如何存储?
- In dedicated config files。单独保存在各自的配置文件中。
- In package.json。统一存储在 package.json 中。
Save this as a preset for future projects? 是否保存这次 Preset 以便之后直接使用。
如果你选择了 Yes,则会再出来一个交互:Save preset as 输入 Preset 的名称。
10.5 总结:Vue CLI 交互流程
这里总结一下 Vue CLI 的整体交互,也就是 prompt
的实现。
也就是文章最开始的时候提到,Vue CLI 支持默认配置之外,也支持自定义配置(Babel、TS 等),这样一个交互流程是如何实现的。
Vue CLI 将所有交互分为四大类:
从预设选项到具体 feature 选项,它们是一个层层递进的关系,不同的时机和选择会触发不同的交互。
Vue CLI 这里在代码架构上的设计值得学习,将各个交互维护在不同的模块中,通过统一的一个 prmoptAPI
实例在 Creator
实例初始化的时候,插入到不同的 prompt
中,并且注册各自的回调函数。这样设计对于 prompt
而言是完全解耦的,删除某一项 prompt
对于上下文的影响可以忽略不计。
好了,关于预设(Preset)和交互(Prompt)到这里基本分析完了,剩下的一些细节问题就不再展开了。
这里涉及到的相关源码文件有,大家可以自行看一下:
- Creator.js
- PromptModuleAPI.js
- utils/createTools.js
- promptModules
- ...
11. 初始化项目基础文件
当用户选完所有交互之后,CLI 的下一步职责就是根据用户的选项去生成对应的代码了,这也是 CLI 的核心功能之一。
11.1 初始化 package.json 文件
根据用户的选项会挂载相关的 vue-cli-plugin
,然后用于生成 package.json
的依赖 devDependencies
,比如 @vue/cli-service
/ @vue/cli-plugin-babel
/ @vue/cli-plugin-eslint
等。
Vue CLI 会现在创建目录下写入一个基础的 package.json
:
{
"name": "a",
"version": "0.1.0",
"private": true,
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-service": "~4.5.0"
}
}
11.2 初始化 Git
根据传入的参数和一系列的判断,会在目标目录下初始化 Git 环境,简单来说就是执行一下 git init
:
await run('git init');
具体是否初始化 Git 环境是这样判断的:
shouldInitGit(cliOptions) {
// 如果全局没安装 Git,则不初始化
if (!hasGit()) {
return false;
}
// 如果 CLI 有传入 --git 参数,则初始化
if (cliOptions.forceGit) {
return true;
}
// 如果 CLI 有传入 --no-git,则不初始化
if (cliOptions.git === false || cliOptions.git === "false") {
return false;
}
// 如果当前目录下已经有 Git 环境,就不初始化
return !hasProjectGit(this.context);
}
11.3 初始化 README.md
项目的 README.md
会根据上下文动态生成,而不是写死的一个文档:
function generateReadme(pkg, packageManager) {
return [
`# ${pkg.name}\n`,
'## Project setup',
'```',
`${packageManager} install`,
'```',
printScripts(pkg, packageManager),
'### Customize configuration',
'See [Configuration Reference](https://cli.vuejs.org/config/).',
'',
].join('\n');
}
Vue CLI 创建的 README.md
会告知用户如何使用这个项目,除了 npm install
之外,会根据 package.json
里的 scripts
参数来动态生成使用文档,比如如何开发、构建和测试:
const descriptions = {
build: 'Compiles and minifies for production',
serve: 'Compiles and hot-reloads for development',
lint: 'Lints and fixes files',
'test:e2e': 'Run your end-to-end tests',
'test:unit': 'Run your unit tests',
};
function printScripts(pkg, packageManager) {
return Object.keys(pkg.scripts || {})
.map(key => {
if (!descriptions[key]) return '';
return [
`\n### ${descriptions[key]}`,
'```',
`${packageManager} ${packageManager !== 'yarn' ? 'run ' : ''}${key}`,
'```',
'',
].join('\n');
})
.join('');
}
这里可能会有读者问,为什么不直接拷贝一个 README.md
文件过去呢?
- 第一,Vue CLI 支持不同的包管理,对应安装、启动和构建脚本都是不一样的,这个是需要动态生成的;
- 第二,动态生成自由性更强,可以根据用户的选项去生成对应的文档,而不是大家都一样。
11.4 安装依赖
调用 ProjectManage
的 install
方法安装依赖,代码不复杂:
async install () {
if (this.needsNpmInstallFix) {
// 读取 package.json
const pkg = resolvePkg(this.context)
// 安装 dependencies
if (pkg.dependencies) {
const deps = Object.entries(pkg.dependencies).map(([dep, range]) => `${dep}@${range}`)
await this.runCommand('install', deps)
}
// 安装 devDependencies
if (pkg.devDependencies) {
const devDeps = Object.entries(pkg.devDependencies).map(([dep, range]) => `${dep}@${range}`)
await this.runCommand('install', [...devDeps, '--save-dev'])
}
// 安装 optionalDependencies
if (pkg.optionalDependencies) {
const devDeps = Object.entries(pkg.devDependencies).map(([dep, range]) => `${dep}@${range}`)
await this.runCommand('install', [...devDeps, '--save-optional'])
}
return
}
return await this.runCommand('install', this.needsPeerDepsFix ? ['--legacy-peer-deps'] : [])
}
简单来说就是读取 package.json
然后分别安装 npm
的不同依赖。
这里的逻辑深入进去感觉还是挺复杂的,我也没仔细深入看,就不展开说了。。。
11.4.1 自动判断 NPM 源
这里有一个有意思的点,关于安装依赖时使用的 npm 仓库源。如果用户没有指定安装源,Vue CLI 会自动判断是否使用淘宝的 NPM 安装源,猜猜是如何实现的?
function shouldUseTaobao() {
let faster
try {
faster = await Promise.race([
ping(defaultRegistry),
ping(registries.taobao)
])
} catch (e) {
return save(false)
}
if (faster !== registries.taobao) {
// default is already faster
return save(false)
}
const { useTaobaoRegistry } = await inquirer.prompt([
{
name: 'useTaobaoRegistry',
type: 'confirm',
message: chalk.yellow(
` Your connection to the default ${command} registry seems to be slow.\n` +
` Use ${chalk.cyan(registries.taobao)} for faster installation?`
)
}
])
return save(useTaobaoRegistry);
}
Vue CLI 中会通过 Promise.race
去请求默认安装源和淘宝安装源:
**
- 如果先返回的是淘宝安装源,就会让用户确认一次,是否使用淘宝安装源
- 如果先返回的是默认安装源,就会直接使用默认安装源
一般来说,肯定都是使用默认安装源,但是考虑国内用户。。咳咳。。为这个设计点赞。
12. Generator 生成代码
除了 Creator
外,整个 Vue CLI 的第二大重要的类是 Generator
,负责项目代码的生成,来具体看看干了啥。
12.1 初始化插件
在 generate
方法中,最先执行的是一个 initPlugins
方法,代码如下:
async initPlugins () {
for (const id of this.allPluginIds) {
const api = new GeneratorAPI(id, this, {}, rootOptions)
const pluginGenerator = loadModule(`${id}/generator`, this.context)
if (pluginGenerator && pluginGenerator.hooks) {
await pluginGenerator.hooks(api, {}, rootOptions, pluginIds)
}
}
}
在这里会给每一个 package.json
里的插件初始化一个 GeneratorAPI
实例,将实例传入对应插件的 generator
方法并执行,比如 @vue/cli-plugin-babel/generator.js
。
12.2 GeneratorAPI 类
Vue CLI 使用了一套基于插件的架构。如果你查阅一个新创建项目的 package.json,就会发现依赖都是以 @vue/cli-plugin- 开头的。插件可以修改 webpack 的内部配置,也可以向 vue-cli-service 注入命令。在项目创建的过程中,绝大部分列出的特性都是通过插件来实现的。
刚刚提到,会往每一个插件的 generator
中传入 GeneratorAPI
的实例,看看这个类提供了什么。
12.2.1 例子:@vue/cli-plugin-babel
为了不那么抽象,我们先拿 @vue/cli-plugin-babel
来看,这个插件比较简单:
module.exports = api => {
delete api.generator.files['babel.config.js'];
api.extendPackage({
babel: {
presets: ['@vue/cli-plugin-babel/preset'],
},
dependencies: {
'core-js': '^3.6.5',
},
});
};
这里 api
就是一个 GeneratorAPI
实例,这里用到了一个 extendPackage
方法:
// GeneratorAPI.js
// 删减部分代码,只针对 @vue/cli-plugin-babel 分析
extendPackage (fields, options = {}) {
const pkg = this.generator.pkg
const toMerge = isFunction(fields) ? fields(pkg) : fields
// 遍历传入的参数,这里是 babel 和 dependencies 两个对象
for (const key in toMerge) {
const value = toMerge[key]
const existing = pkg[key]
// 如果 key 的名称是 dependencies 和 devDependencies
// 就通过 mergeDeps 方法往 package.json 合并依赖
if (isObject(value) && (key === 'dependencies' || key === 'devDependencies')) {
pkg[key] = mergeDeps(
this.id,
existing || {},
value,
this.generator.depSources,
extendOptions
)
} else if (!extendOptions.merge || !(key in pkg)) {
pkg[key] = value
}
}
}
这时候,默认的 package.json
就变成:
{
"babel": {
"presets": ["@vue/cli-plugin-babel/preset"]
},
"dependencies": {
"core-js": "^3.6.5"
},
"devDependencies": {},
"name": "test",
"private": true,
"version": "0.1.0"
}
看完这个例子,对于 GeneratorAPI
的实例做什么可能有些了解了,我们就来具体看看这个类的实例吧。
12.2.2 重要的几个实例方法
先介绍几个 GeneratorAPI
重要的实例方法,这里就只介绍功能,具体代码就不看了,等等会用到。
- extendPackage:拓展 package.json 配置
- render:通过 ejs 渲染模板文件
- onCreateComplete: 注册文件写入硬盘之后的回调
- genJSConfig: 将 json 文件输出成 js 文件
- injectImports: 向文件中加入 import
- ...
13. @vue/cli-service
上文已经看过一个 @vue/cli-plugin-babel
插件,对于 Vue CLI 的插件架构是不是有点感觉?也了解到一个比较重要的 GeneratorAPI
类,插件中的一些修改配置的功能都是这个类的实例方法。
接下来看一个比较重要的插件 @vue/cli-service
,这个插件是 Vue CLI 的核心插件,和 create react app
的 react-scripts
类似,借助这个插件,我们应该能够更深刻地理解 GeneratorAPI
以及 Vue CLI 的插件架构是如何实现的。
来看一下 @vue/cli-service
这个包下的 generator/index.js
文件,这里为了分析方便,将源码拆解成多段,其实也就是分别调用了 GeneratorAPI
实例的不同方法:
13.1 渲染 template
api.render('./template', {
doesCompile: api.hasPlugin('babel') || api.hasPlugin('typescript'),
});
将 template
目录下的文件通过 render
渲染到内存中,这里用的是 ejs
作为模板渲染引擎。
13.2 写 package.json
通过 extendPackage
往 pacakge.json
中写入 Vue
的相关依赖:
if (options.vueVersion === '3') {
api.extendPackage({
dependencies: {
vue: '^3.0.0',
},
devDependencies: {
'@vue/compiler-sfc': '^3.0.0',
},
});
} else {
api.extendPackage({
dependencies: {
vue: '^2.6.11',
},
devDependencies: {
'vue-template-compiler': '^2.6.11',
},
});
}
通过 extendPackage
往 pacakge.json
中写入 scripts
:
api.extendPackage({
scripts: {
serve: 'vue-cli-service serve',
build: 'vue-cli-service build',
},
browserslist: ['> 1%', 'last 2 versions', 'not dead'],
});
通过 extendPackage
往 pacakge.json
中写入 CSS
预处理参数:
if (options.cssPreprocessor) {
const deps = {
sass: {
sass: '^1.26.5',
'sass-loader': '^8.0.2',
},
'node-sass': {
'node-sass': '^4.12.0',
'sass-loader': '^8.0.2',
},
'dart-sass': {
sass: '^1.26.5',
'sass-loader': '^8.0.2',
},
less: {
less: '^3.0.4',
'less-loader': '^5.0.0',
},
stylus: {
stylus: '^0.54.7',
'stylus-loader': '^3.0.2',
},
};
api.extendPackage({
devDependencies: deps[options.cssPreprocessor],
});
}
13.3 调用 router 插件和 vuex 插件
// for v3 compatibility
if (options.router && !api.hasPlugin('router')) {
require('./router')(api, options, options);
}
// for v3 compatibility
if (options.vuex && !api.hasPlugin('vuex')) {
require('./vuex')(api, options, options);
}
是不是很简单,通过 GeneratorAPI
提供的实例方法,可以在插件中非常方便地对项目进行修改和自定义。
14. 抽取单独配置文件
上文提到,通过 extendPackage
回往 package.json
中写入一些配置。但是,上文也提到有一个交互是 Where do you prefer placing config for Babel, ESLint, etc.? 也就是会将配置抽取成单独的文件。generate
里的 extractConfigFiles
方法就是执行了这个逻辑。
extractConfigFiles(extractAll, checkExisting) {
const configTransforms = Object.assign(
{},
defaultConfigTransforms,
this.configTransforms,
reservedConfigTransforms
);
const extract = (key) => {
if (
configTransforms[key] &&
this.pkg[key] &&
!this.originalPkg[key]
) {
const value = this.pkg[key];
const configTransform = configTransforms[key];
const res = configTransform.transform(
value,
checkExisting,
this.files,
this.context
);
const { content, filename } = res;
this.files[filename] = ensureEOL(content);
delete this.pkg[key];
}
};
if (extractAll) {
for (const key in this.pkg) {
extract(key);
}
} else {
extract("babel");
}
}
这里的 configTransforms
就是一些会需要抽取的配置:
如果 extractAll
是 true
,也就是在上面的交互中选了 Yes,就会将 package.json
里的所有 key
configTransforms
比较,如果都存在,就将配置抽取到独立的文件中。
15. 将内存中的文件输出到硬盘
上文有提到,api.render
会通过 EJS 将模板文件渲染成字符串放在内存中。执行了 generate
的所有逻辑之后,内存中已经有了需要输出的各种文件,放在 this.files
里。 generate
的最后一步就是调用 writeFileTree
将内存中的所有文件写入到硬盘。
到这里 generate
的逻辑就基本都讲完了,Vue CLI 生成代码的部分也就讲完了。
16. 总结
整体看下来,Vue CLI 的代码还是比较复杂的,整体架构条理还是比较清楚的,其中有两点印象最深:
第一,整体的交互流程的挂载。将各个模块的交互逻辑通过一个类的实例维护起来,执行时机和成功回调等也是设计的比较好。
第二,插件机制很重要。插件机制将功能和脚手架进行解耦。
看来,无论是 create-react-app 还是 Vue CLI,在设计的时候都会尽量考虑插件机制,将能力开放出去再将功能集成进来,无论是对于 Vue CLI 本身的核心功能,还是对于社区开发者来说,都具备了足够的开放性和扩展性。
整体代码看下来,最重要的就是两个概念:
- Preset:预设,包括整体的交互流程(Prompt)
- Plugin:插件,整体的插件系统
围绕这两个概念,代码中的这几个类:Creator、PromptModuleAPI、Generator、GeneratorAPI 就是核心。
简单总结一下流程:
- 执行
vue create
- 初始化
Creator
实例creator
,挂载所有交互配置 - 调用
creator
的实例方法create
- 询问用户自定义配置
- 初始化
Generator
实例generator
- 初始化各种插件
- 执行插件的
generator
逻辑,写package.json
、渲染模板等 - 将文件写入到硬盘
这样一个 CLI 的生命周期就走完了,项目已经初始化好了。
附:Vue CLI 中可以直接拿来用的工具方法
看完 Vue CLI 的源码,除了感叹这复杂的设计之外,也发现很多工具方法,在我们实现自己的 CLI 时,都是可以拿来即用的,在这里总结一下。
获取 CLI 参数
解析 CLI 通过 --
传入的参数。
const program = require('commander');
function camelize(str) {
return str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : ''));
}
function cleanArgs(cmd) {
const args = {};
cmd.options.forEach(o => {
const key = camelize(o.long.replace(/^--/, ''));
// if an option is not present and Command has a method with the same name
// it should not be copied
if (typeof cmd[key] !== 'function' && typeof cmd[key] !== 'undefined') {
args[key] = cmd[key];
}
});
return args;
}
检查 Node 版本
通过 semver.satisfies
比较两个 Node 版本:
- process.version: 当前运行环境的 Node 版本
- wanted: package.json 里配置的 Node 版本
const requiredVersion = require('../package.json').engines.node;
function checkNodeVersion(wanted, id) {
if (!semver.satisfies(process.version, wanted, { includePrerelease: true })) {
console.log(
chalk.red(
'You are using Node ' +
process.version +
', but this version of ' +
id +
' requires Node ' +
wanted +
'.\nPlease upgrade your Node version.'
)
);
process.exit(1);
}
}
checkNodeVersion(requiredVersion, '@vue/cli');
读取 package.json
const fs = require('fs');
const path = require('path');
function getPackageJson(cwd) {
const packagePath = path.join(cwd, 'package.json');
let packageJson;
try {
packageJson = fs.readFileSync(packagePath, 'utf-8');
} catch (err) {
throw new Error(`The package.json file at '${packagePath}' does not exist`);
}
try {
packageJson = JSON.parse(packageJson);
} catch (err) {
throw new Error('The package.json is malformed');
}
return packageJson;
}
对象排序
这里主要是在输出 package.json 的时候可以对输出的对象先进行排序,更美观一些。。
module.exports = function sortObject(obj, keyOrder, dontSortByUnicode) {
if (!obj) return;
const res = {};
if (keyOrder) {
keyOrder.forEach(key => {
if (obj.hasOwnProperty(key)) {
res[key] = obj[key];
delete obj[key];
}
});
}
const keys = Object.keys(obj);
!dontSortByUnicode && keys.sort();
keys.forEach(key => {
res[key] = obj[key];
});
return res;
};
输出文件到硬盘
这个其实没啥,就是三步:
- fs.unlink 删除文件
- fs.ensureDirSync 创建目录
- fs.writeFileSync 写文件
const fs = require('fs-extra');
const path = require('path');
// 删除已经存在的文件
function deleteRemovedFiles(directory, newFiles, previousFiles) {
// get all files that are not in the new filesystem and are still existing
const filesToDelete = Object.keys(previousFiles).filter(
filename => !newFiles[filename]
);
// delete each of these files
return Promise.all(
filesToDelete.map(filename => {
return fs.unlink(path.join(directory, filename));
})
);
}
// 输出文件到硬盘
module.exports = async function writeFileTree(dir, files, previousFiles) {
if (previousFiles) {
await deleteRemovedFiles(dir, files, previousFiles);
}
// 主要就是这里
Object.keys(files).forEach(name => {
const filePath = path.join(dir, name);
fs.ensureDirSync(path.dirname(filePath));
fs.writeFileSync(filePath, files[name]);
});
};
判断项目是否初始化 git
其实就是在目录下执行 git status
看是否报错。
const hasProjectGit = cwd => {
let result;
try {
execSync('git status', { stdio: 'ignore', cwd });
result = true;
} catch (e) {
result = false;
}
return result;
};
对象的 get 方法
可以用 lodash,现在可以直接用 a?.b?.c 就好了
function get(target, path) {
const fields = path.split('.');
let obj = target;
const l = fields.length;
for (let i = 0; i < l - 1; i++) {
const key = fields[i];
if (!obj[key]) {
return undefined;
}
obj = obj[key];
}
return obj[fields[l - 1]];
}
个人原创技术文章会发在公众号玩相机的程序员上,用键盘和相机记录生活的公众号。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。