Vue CLI 是如何实现的


Vue CLI 是如何实现的

文章插图
Vue CLI 是一个基于 Vue.js 进行快速开发的完整系统 , 提供了终端命令行工具、零配置脚手架、插件体系、图形化管理界面等 。本文暂且只分析项目初始化部分 , 也就是终端命令行工具的实现 。
0. 用法用法很简单 , 每个 CLI 都大同小异:
npm install -g @vue/clivue create vue-cli-test目前 Vue CLI 同时支持 Vue 2 和 Vue 3 项目的创建(默认配置) 。
Vue CLI 是如何实现的

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

文章插图
选择完成之后 , 敲下回车 , 就开始执行安装依赖、拷贝模板等命令...
Vue CLI 是如何实现的

文章插图
看到 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 , 会怎么样呢?理论上应该是报错了 。
如果只是报错 , 那我就不提了 。看看结果:

Vue CLI 是如何实现的

文章插图
终端上输出了一行很关键的信息 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 版本的一个情况:
Vue CLI 是如何实现的

文章插图
解释一下图中几个状态:
  • 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 -mif (fs.existsSync(targetDir) && !options.merge) {// 是否 vue create -fif (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 / Cancelconst { 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 persisterror(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  的时候时候就会把所有用于交互的配置注册好了 。
这里我们注意到 , 在构造函数中出现了四种 promptpresetPrompt , 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 中的方法触发这些事件 。
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 在选择自定义配置时的顶层选项:
Vue CLI 是如何实现的

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

文章插图
onPromptComplete 注册了一个回调方法 , 在完成交互之后执行 。
看来我们的猜测是对的 ,  getPromptModules 方法就是获取一些用于和用户交互的模块 , 比如:
  • babel:选择是否使用 Babel
  • cssPreprocessors:选择 CSS 的预处理器(Sass、Less、Stylus)
  • ...
先说到这里 , 后面在自定义配置加载的章节里会展开介绍 Vue CLI 用到的所有 prompt  。
9.3 获取预设我们具体来看一下获取预设相关的逻辑 。这部分代码在 create  实例方法中:
// Creator.jsmodule.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 barpreset = await this.resolvePreset(cliOptions.preset, cliOptions.clone);} else if (cliOptions.default) {// vue create foo --defaultpreset = 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  方法 。
先看一下实际用的时候是什么样的:
Vue CLI 是如何实现的

文章插图
我们可以猜测这里就是一段 const answers = await inquirer.prompt([])  代码 。
async promptAndResolvePreset(answers = null) {// promptif (!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 中 。
【Vue CLI 是如何实现的】async promptAndResolvePreset(answers = null){if (answers.save &&answers.saveName &&savePreset(answers.saveName, preset)) {log();log(`