Vue CLI 是如何實現的 -- 終端命令行工具篇

Vue CLI 是一個基於 Vue.js 進行快速開發的完整系統,提供了終端命令行工具、零配置腳手架、插件體系、圖形化管理界面等。本文暫且只分析項目初始化部分,也就是終端命令行工具的實現。

  1. 用法

用法很簡單,每個 CLI 都大同小異:

1npm install -g @vue/cli
2vue create vue-cli-test
3
4

目前 Vue CLI 同時支持 Vue 2 和 Vue 3 項目的創建(默認配置)。

上面是 Vue CLI 提供的默認配置,可以快速地創建一個項目。除此之外,也可以根據自己的項目需求(是否使用 Babel、是否使用 TS 等)來自定義項目工程配置,這樣會更加的靈活。

選擇完成之後,敲下回車,就開始執行安裝依賴、拷貝模板等命令...

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

vue create  命令支持一些參數配置,可以通過 vue create --help  獲取詳細的文檔:

 1用法:create [options] <app-name>
 2
 3選項:
 4  -p, --preset <presetName>       忽略提示符並使用已保存的或遠程的預設選項
 5  -d, --default                   忽略提示符並使用默認預設選項
 6  -i, --inlinePreset <json>       忽略提示符並使用內聯的 JSON 字符串預設選項
 7  -m, --packageManager <command>  在安裝依賴時使用指定的 npm 客戶端
 8  -r, --registry <url>            在安裝依賴時使用指定的 npm registry
 9  -g, --git [message]             強制 / 跳過 git 初始化,並可選的指定初始化提交信息
10  -n, --no-git                    跳過 git 初始化
11  -f, --force                     覆寫目標目錄可能存在的配置
12  -c, --clone                     使用 git clone 獲取遠程預設選項
13  -x, --proxy                     使用指定的代理創建項目
14  -b, --bare                      創建項目時省略默認組件中的新手指導信息
15  -h, --help                      輸出使用幫助信息
16

具體的用法大家感興趣的可以嘗試一下,這裏就不展開了,後續在源碼分析中會有相應的部分提到。

  1. 入口文件

本文中的 vue cli 版本爲 4.5.9。若閱讀本文時存在 break change,可能就需要自己理解一下啦

按照正常邏輯,我們在 package.json 裏找到了入口文件:

1{
2  "bin": {
3    "vue": "bin/vue.js"
4  }
5}
6
7

bin/vue.js 裏的代碼不少,無非就是在 vue  上註冊了 create / add / ui  等命令,本文只分析 create  部分,找到這部分代碼(刪除主流程無關的代碼後):

 1// 檢查 node 版本
 2checkNodeVersion(requiredVersion, '@vue/cli');
 3
 4// 掛載 create 命令
 5program.command('create <app-name>').action((name, cmd) => {
 6  // 獲取額外參數
 7  const options = cleanArgs(cmd);
 8  // 執行 create 方法
 9  require('../lib/create')(name, options);
10});
11

cleanArgs  是獲取 vue create  後面通過 -  傳入的參數,通過 vue create --help 可以獲取執行的參數列表。

獲取參數之後就是執行真正的 create  方法了,等等仔細展開。

不得不說,Vue CLI 對於代碼模塊的管理非常細,每個模塊基本上都是單一功能模塊,可以任意地拼裝和使用。每個文件的代碼行數也都不會很多,閱讀起來非常舒服。

  1. 輸入命令有誤,猜測用戶意圖

Vue CLI 中比較有意思的一個地方,如果用戶在終端中輸入 vue creat xxx  而不是 vue create xxx,會怎麼樣呢?理論上應該是報錯了。

如果只是報錯,那我就不提了。看看結果:

終端上輸出了一行很關鍵的信息 Did you mean create,Vue CLI 似乎知道用戶是想使用 create  但是手速太快打錯單詞了。

這是如何做到的呢?我們在源代碼中尋找答案:

 1const leven = require('leven');
 2
 3// 如果不是當前已掛載的命令,會猜測用戶意圖
 4program.arguments('<command>').action(cmd => {
 5  suggestCommands(cmd);
 6});
 7
 8// 猜測用戶意圖
 9function suggestCommands(unknownCommand) {
10  const availableCommands = program.commands.map(cmd => cmd._name);
11
12  let suggestion;
13
14  availableCommands.forEach(cmd => {
15    const isBestMatch =
16      leven(cmd, unknownCommand) < leven(suggestion || '', unknownCommand);
17    if (leven(cmd, unknownCommand) < 3 && isBestMatch) {
18      suggestion = cmd;
19    }
20  });
21
22  if (suggestion) {
23    console.log(`  ` + chalk.red(`Did you mean ${chalk.yellow(suggestion)}?`));
24  }
25}
26

代碼中使用了 leven 了這個包,這是用於計算字符串編輯距離算法的 JS 實現,Vue CLI 這裏使用了這個包,來分別計算輸入的命令和當前已掛載的所有命令的編輯舉例,從而猜測用戶實際想輸入的命令是哪個。

小而美的一個功能,用戶體驗極大提升。

  1. Node 版本相關檢查

3.1 Node 期望版本

和 create-react-app  類似,Vue CLI 也是先檢查了一下當前 Node 版本是否符合要求:

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

3.2 推薦 Node LTS 版本

在 bin/vue.js  中有這樣一段代碼,看上去也是在檢查 Node 版本:

 1const EOL_NODE_MAJORS = ['8.x', '9.x', '11.x', '13.x'];
 2for (const major of EOL_NODE_MAJORS) {
 3  if (semver.satisfies(process.version, major)) {
 4    console.log(
 5      chalk.red(
 6        `You are using Node ${process.version}.\n` +
 7          `Node.js ${major} has already reached end-of-life and will not be supported in future major releases.\n` +
 8          `It's strongly recommended to use an active LTS version instead.`
 9      )
10    );
11  }
12}
13
14

可能並不是所有人都瞭解它的作用,在這裏稍微科普一下。

簡單來說,Node 的主版本分爲奇數版本偶數版本。每個版本發佈之後會持續六個月的時間,六個月之後,奇數版本將變爲 EOL 狀態,而偶數版本變爲 **Active LTS ** 狀態並且長期支持。所以我們在生產環境使用 Node 的時候,應該儘量使用它的 LTS 版本,而不是 EOL 的版本。

EOL 版本:A End-Of-Life version of Node LTS 版本: A long-term supported version of Node

這是目前常見的 Node 版本的一個情況:

解釋一下圖中幾個狀態:

通過上面那張圖,我們可以看到,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(手動狗頭)。

  1. 判斷是否在當前路徑

在執行 vue create  的時候,是必須指定一個 app-name ,否則會報錯: Missing required argument <app-name> 。

那如果用戶已經自己創建了一個目錄,想在當前這個空目錄下創建一個項目呢?當然,Vue CLI 也是支持的,執行 vue create .  就 OK 了。

lib/create.js  中就有相關代碼是在處理這個邏輯的。

 1async function create(projectName, options) {
 2  // 判斷傳入的 projectName 是否是 .
 3  const inCurrent = projectName === '.';
 4  // path.relative 會返回第一個參數到第二個參數的相對路徑
 5  // 這裏就是用來獲取當前目錄的目錄名
 6  const name = inCurrent ? path.relative('../', cwd) : projectName;
 7  // 最終初始化項目的路徑
 8  const targetDir = path.resolve(cwd, projectName || '.');
 9}
10
11

如果你需要實現一個 CLI,這個邏輯是可以拿來即用的。

  1. 檢查應用名

Vue CLI 會通過 validate-npm-package-name  這個包來檢查輸入的 projectName 是否符合規範。

1const result = validateProjectName(name);
2if (!result.validForNewPackages) {
3  console.error(chalk.red(`Invalid project name: "${name}"`));
4  exit(1);
5}
6
7

對應的 npm 命名規範可以見:Naming Rules

  1. 若目標文件夾已存在,是否覆蓋

這段代碼比較簡單,就是判斷 target  目錄是否存在,然後通過交互詢問用戶是否覆蓋(對應的是操作是刪除原目錄):

 1// 是否 vue create -m
 2if (fs.existsSync(targetDir) && !options.merge) {
 3  // 是否 vue create -f
 4  if (options.force) {
 5    await fs.remove(targetDir);
 6  } else {
 7    await clearConsole();
 8    // 如果是初始化在當前路徑,就只是確認一下是否在當前目錄創建
 9    if (inCurrent) {
10      const { ok } = await inquirer.prompt([
11        {
12          name: 'ok',
13          type: 'confirm',
14          message: `Generate project in current directory?`,
15        },
16      ]);
17      if (!ok) {
18        return;
19      }
20    } else {
21      // 如果有目標目錄,則詢問如何處理:Overwrite / Merge / Cancel
22      const { action } = await inquirer.prompt([
23        {
24          name: 'action',
25          type: 'list',
26          message: `Target directory ${chalk.cyan(
27            targetDir
28          )} already exists. Pick an action:`,
29          choices: [
30            { name: 'Overwrite', value: 'overwrite' },
31            { name: 'Merge', value: 'merge' },
32            { name: 'Cancel', value: false },
33          ],
34        },
35      ]);
36      // 如果選擇 Cancel,則直接中止
37      // 如果選擇 Overwrite,則先刪除原目錄
38      // 如果選擇 Merge,不用預處理啥
39      if (!action) {
40        return;
41      } else if (action === 'overwrite') {
42        console.log(`\nRemoving ${chalk.cyan(targetDir)}...`);
43        await fs.remove(targetDir);
44      }
45    }
46  }
47}
48
49
  1. 整體錯誤捕獲

在 create  方法的最外層,放了一個 catch  方法,捕獲內部所有拋出的錯誤,將當前的 spinner  狀態停止,退出進程。

 1module.exports = (...args) => {
 2  return create(...args).catch(err => {
 3    stopSpinner(false); // do not persist
 4    error(err);
 5    if (!process.env.VUE_CLI_TEST) {
 6      process.exit(1);
 7    }
 8  });
 9};
10
11
  1. Creator 類

在 lib/create.js  方法的最後,執行了這樣兩行代碼:

1const creator = new Creator(name, targetDir, getPromptModules());
2await creator.create(options);
3
4

看來最重要的代碼還是在 Creator  這個類中。

打開 Creator.js  文件,好傢伙,500+ 行代碼,並且引入了 12 個模塊。當然,這篇文章不會把這 500 行代碼和 12 個模塊都理一遍,沒必要,感興趣的自己去看看好了。

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

8.1 constructor 構造函數

先看一下 Creator  類的的構造函數:

 1module.exports = class Creator extends EventEmitter {
 2  constructor(name, context, promptModules) {
 3    super();
 4
 5    this.name = name;
 6    this.context = process.env.VUE_CLI_CONTEXT = context;
 7    // 獲取了 preset 和 feature 的 交互選擇列表,在 vue create 的時候提供選擇
 8    const { presetPrompt, featurePrompt } = this.resolveIntroPrompts();
 9    this.presetPrompt = presetPrompt;
10    this.featurePrompt = featurePrompt;
11
12    // 交互選擇列表:是否輸出一些文件
13    this.outroPrompts = this.resolveOutroPrompts();
14
15    this.injectedPrompts = [];
16    this.promptCompleteCbs = [];
17    this.afterInvokeCbs = [];
18    this.afterAnyInvokeCbs = [];
19
20    this.run = this.run.bind(this);
21
22    const promptAPI = new PromptModuleAPI(this);
23    // 將默認的一些配置注入到交互列表中
24    promptModules.forEach(m => m(promptAPI));
25  }
26};
27

構造函數嘛,主要就是初始化一些變量。這裏主要將邏輯都封裝在 resolveIntroPrompts / resolveOutroPrompts  和 PromptModuleAPI  這幾個方法中。

主要看一下 PromptModuleAPI 這個類是幹什麼的。

 1module.exports = class PromptModuleAPI {
 2  constructor(creator) {
 3    this.creator = creator;
 4  }
 5  // 在 promptModules 裏用
 6  injectFeature(feature) {
 7    this.creator.featurePrompt.choices.push(feature);
 8  }
 9  // 在 promptModules 裏用
10  injectPrompt(prompt) {
11    this.creator.injectedPrompts.push(prompt);
12  }
13  // 在 promptModules 裏用
14  injectOptionForPrompt(name, option) {
15    this.creator.injectedPrompts
16      .find(f => {
17        return f.name === name;
18      })
19      .choices.push(option);
20  }
21  // 在 promptModules 裏用
22  onPromptComplete(cb) {
23    this.creator.promptCompleteCbs.push(cb);
24  }
25};
26
27

這裏我們也簡單說一下,promptModules  返回的是所有用於終端交互的模塊,其中會調用 injectFeature 和 injectPrompt 來將交互配置插入進去,並且會通過 onPromptComplete  註冊一個回調。

onPromptComplete 註冊回調的形式是往 promptCompleteCbs 這個數組中 push 了傳入的方法,可以猜測在所有交互完成之後應該會通過以下形式來調用回調:

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

回過來看這段代碼:

1module.exports = class Creator extends EventEmitter {
2  constructor(name, context, promptModules) {
3    const promptAPI = new PromptModuleAPI(this);
4    promptModules.forEach(m => m(promptAPI));
5  }
6};
7
8

在 Creator  的構造函數中,實例化了一個 promptAPI  對象,並遍歷 prmptModules  把這個對象傳入了 promptModules  中,說明在實例化 Creator  的時候時候就會把所有用於交互的配置註冊好了。

這裏我們注意到,在構造函數中出現了四種 prompt: presetPromptfeaturePrompt, injectedPrompts, outroPrompts,具體有什麼區別呢?下文有有詳細展開。

8.2 EventEmitter 事件模塊

首先, Creator  類是繼承於 Node.js 的 EventEmitter 類。衆所周知, events  是 Node.js 中最重要的一個模塊,而 EventEmitter 類就是其基礎,是 Node.js 中事件觸發與事件監聽等功能的封裝。

在這裏, Creator  繼承自 EventEmitter , 應該就是爲了方便在 create  過程中 emit  一些事件,整理了一下,主要就是以下 8 個事件:

 1this.emit('creation', { event: 'creating' }); // 創建
 2this.emit('creation', { event: 'git-init' }); // 初始化 git
 3this.emit('creation', { event: 'plugins-install' }); // 安裝插件
 4this.emit('creation', { event: 'invoking-generators' }); // 調用 generator
 5this.emit('creation', { event: 'deps-install' }); // 安裝額外的依賴
 6this.emit('creation', { event: 'completion-hooks' }); // 完成之後的回調
 7this.emit('creation', { event: 'done' }); // create 流程結束
 8this.emit('creation', { event: 'fetch-remote-preset' }); // 拉取遠程 preset
 9
10

我們知道事件 emit  一定會有 on  的地方,是哪呢?搜了一下源碼,是在 @vue/cli-ui 這個包裏,也就是說在終端命令行工具的場景下,不會觸發到這些事件,這裏簡單瞭解一下即可:

1const creator = new Creator('', cwd.get(), getPromptModules());
2onCreationEvent = ({ event }) => {
3  progress.set({ id: PROGRESS_ID, status: event, info: null }, context);
4};
5creator.on('creation', onCreationEvent);
6
7

簡單來說,就是通過 vue ui  啓動一個圖形化界面來初始化項目時,會啓動一個 server 端,和終端之間是存在通信的。 server 端掛載了一些事件,在 create 的每個階段,會從 cli 中的方法觸發這些事件。

  1. Preset(預設)

Creator  類的實例方法 create  接受兩個參數:

9.1 什麼是 Preset(預設)

Preset 是什麼呢?官方解釋是一個包含創建新項目所需預定義選項和插件的 JSON 對象,讓用戶無需在命令提示中選擇它們。比如:

 1{
 2  "useConfigFiles": true,
 3  "cssPreprocessor": "sass",
 4  "plugins": {
 5    "@vue/cli-plugin-babel": {},
 6    "@vue/cli-plugin-eslint": {
 7      "config": "airbnb",
 8      "lintOn": ["save", "commit"]
 9    }
10  },
11  "configs": {
12    "vue": {...},
13    "postcss": {...},
14    "eslintConfig": {...},
15    "jest": {...}
16  }
17}
18
19

在 CLI 中允許使用本地的 preset 和遠程的 preset。

9.2 prompt

用過 inquirer 的朋友的對 prompt 這個單詞一定不陌生,它有 input / checkbox 等類型,是用戶和終端的交互。

我們回過頭來看一下在 Creator 中的一個方法 getPromptModules, 按照字面意思,這個方法是獲取了一些用於交互的模塊,具體來看一下:

 1exports.getPromptModules = () => {
 2  return [
 3    'vueVersion',
 4    'babel',
 5    'typescript',
 6    'pwa',
 7    'router',
 8    'vuex',
 9    'cssPreprocessors',
10    'linter',
11    'unit',
12    'e2e',
13  ].map(file => require(`../promptModules/${file}`));
14};
15
16

看樣子是獲取了一系列的模塊,返回了一個數組。我看了一下這裏列的幾個模塊,代碼格式基本都是統一的::

 1module.exports = cli => {
 2  cli.injectFeature({
 3    name: '',
 4    value: '',
 5    short: '',
 6    description: '',
 7    link: '',
 8    checked: true,
 9  });
10
11  cli.injectPrompt({
12    name: '',
13    when: answers => answers.features.includes(''),
14    message: '',
15    type: 'list',
16    choices: [],
17    default: '2',
18  });
19
20  cli.onPromptComplete((answers, options) => {});
21};
22

單獨看 injectFeature 和 injectPrompt 的對象是不是和 inquirer 有那麼一點神似?是的,他們就是用戶交互的一些配置選項。那 Feature  和 Prompt  有什麼區別呢?

Feature:Vue CLI 在選擇自定義配置時的頂層選項:

Prompt:選擇具體 Feature 對應的二級選項,比如選擇了 Choose Vue version 這個 Feature,會要求用戶選擇是 2.x 還是 3.x:

onPromptComplete 註冊了一個回調方法,在完成交互之後執行。

看來我們的猜測是對的, getPromptModules 方法就是獲取一些用於和用戶交互的模塊,比如:

先說到這裏,後面在自定義配置加載的章節裏會展開介紹 Vue CLI 用到的所有 prompt 。

9.3 獲取預設

我們具體來看一下獲取預設相關的邏輯。這部分代碼在 create  實例方法中:

 1// Creator.js
 2module.exports = class Creator extends EventEmitter {
 3  async create(cliOptions = {}, preset = null) {
 4    const isTestOrDebug = process.env.VUE_CLI_TEST || process.env.VUE_CLI_DEBUG;
 5    const { run, name, context, afterInvokeCbs, afterAnyInvokeCbs } = this;
 6
 7    if (!preset) {
 8      if (cliOptions.preset) {
 9        // vue create foo --preset bar
10        preset = await this.resolvePreset(cliOptions.preset, cliOptions.clone);
11      } else if (cliOptions.default) {
12        // vue create foo --default
13        preset = defaults.presets.default;
14      } else if (cliOptions.inlinePreset) {
15        // vue create foo --inlinePreset {...}
16        try {
17          preset = JSON.parse(cliOptions.inlinePreset);
18        } catch (e) {
19          error(
20            `CLI inline preset is not valid JSON: ${cliOptions.inlinePreset}`
21          );
22          exit(1);
23        }
24      } else {
25        preset = await this.promptAndResolvePreset();
26      }
27    }
28  }
29};
30

可以看到,代碼中分別針對幾種情況作了處理:

前三種情況就不展開說了,我們來看一下第四種情況,也就是默認通過交互 prompt  來獲取 Preset 的邏輯,也就是 promptAndResolvePreset  方法。

先看一下實際用的時候是什麼樣的:

我們可以猜測這裏就是一段 const answers = await inquirer.prompt([])  代碼。

 1 async promptAndResolvePreset(answers = null) {
 2    // prompt
 3    if (!answers) {
 4      await clearConsole(true);
 5      answers = await inquirer.prompt(this.resolveFinalPrompts());
 6    }
 7    debug("vue-cli:answers")(answers);
 8 }
 9
10 resolveFinalPrompts() {
11    this.injectedPrompts.forEach((prompt) => {
12      const originalWhen = prompt.when || (() => true);
13      prompt.when = (answers) => {
14        return isManualMode(answers) && originalWhen(answers);
15      };
16    });
17
18    const prompts = [
19      this.presetPrompt,
20      this.featurePrompt,
21      ...this.injectedPrompts,
22      ...this.outroPrompts,
23    ];
24    debug("vue-cli:prompts")(prompts);
25    return prompts;
26 }
27

是的,我們猜的沒錯,將 this.resolveFinalPrompts  裏的配置進行交互,而 this.resolveFinalPrompts  方法其實就是將在 Creator  的構造函數里初始化的那些 prompts  合到一起了。上文也提到了有這四種 prompt,在下一節展開介紹。**

9.4 保存預設

在 Vue CLI 的最後,會讓用戶選擇 save this as a preset for future?,如果用戶選擇了 Yes,就會執行相關邏輯將這次的交互結果保存下來。這部分邏輯也是在 promptAndResolvePreset 中。

 1async promptAndResolvePreset(answers = null)  {
 2  if (
 3    answers.save &&
 4    answers.saveName &&
 5    savePreset(answers.saveName, preset)
 6  ) {
 7    log();
 8    log(
 9      `🎉  Preset ${chalk.yellow(answers.saveName)} saved in ${chalk.yellow(
10        rcPath
11      )}`
12    );
13  }
14}
15
16

在調用 savePreset 之前還會對預設進行解析、校驗等,就不展開了,直接來看一下 savePreset 方法:

 1exports.saveOptions = toSave => {
 2  const options = Object.assign(cloneDeep(exports.loadOptions()), toSave);
 3  for (const key in options) {
 4    if (!(key in exports.defaults)) {
 5      delete options[key];
 6    }
 7  }
 8  cachedOptions = options;
 9  try {
10    fs.writeFileSync(rcPath, JSON.stringify(options, null, 2));
11    return true;
12  } catch (e) {
13    error(
14      `Error saving preferences: ` +
15        `make sure you have write access to ${rcPath}.\n` +
16        `(${e.message})`
17    );
18  }
19};
20
21exports.savePreset = (name, preset) => {
22  const presets = cloneDeep(exports.loadOptions().presets || {});
23  presets[name] = preset;
24  return exports.saveOptions({ presets });
25};
26

代碼很簡單,先深拷貝一份 Preset(這裏直接用的 lodash 的 clonedeep),然後進過一些 merge 的操作之後就 writeFileSync 到上文有提到的 .vuerc 文件了。

  1. 自定義配置加載

這四種 prompt  分別對應的是預設選項、自定義 feature 選擇、具體 feature 選項和其它選項,它們之間存在互相關聯、層層遞進的關係。結合這四種 prompt,就是 Vue CLI 展現開用戶面前的所有交互了,其中也包含自定義配置的加載。

10.1 presetPrompt: 預設選項

也就是最初截圖裏看到的哪三個選項,選擇 Vue2 還是 Vue3 還是自定義 feature

如果選擇了 Vue2  或者 Vue3 ,則後續關於 preset  所有的 prompt  都會終止。

10.2 featurePrompt: 自定義 feature 選項

** 如果在 presetPrompt  中選擇了 Manually,則會繼續選擇 feature

featurePrompt  就是存儲的這個列表,對應的代碼是這樣的:

 1const isManualMode = answers => answers.preset === '__manual__';
 2
 3const featurePrompt = {
 4  name: 'features',
 5  when: isManualMode,
 6  type: 'checkbox',
 7  message: 'Check the features needed for your project:',
 8  choices: [],
 9  pageSize: 10,
10};
11

在代碼中可以看到,在 isManualMode  的時候纔會彈出這個交互。

10.3 injectedPrompts: 具體 feature 選項

featurePrompt  只是提供了一個一級列表,當用戶選擇了 Vue Version / Babel / TypeScript  等選項之後,會彈出新的交互,比如 Choose Vue version

injectedPrompts  就是存儲的這些具體選項的列表,也就是上文有提到通過 getPromptModules 方法在 promptModules  目錄獲取到的那些 prompt  模塊:

對應的代碼可以再回顧一下:

 1cli.injectPrompt({
 2  name: 'vueVersion',
 3  when: answers => answers.features.includes('vueVersion'),
 4  message: 'Choose a version of Vue.js that you want to start the project with',
 5  type: 'list',
 6  choices: [
 7    {
 8      name: '2.x',
 9      value: '2',
10    },
11    {
12      name: '3.x (Preview)',
13      value: '3',
14    },
15  ],
16  default: '2',
17});
18
19

可以看到,在 answers => answers.features.includes('vueVersion'),也就是 featurePrompt 的交互結果中如果包含 vueVersion  就會彈出具體選擇 Vue Version  的交互。

10.4 outroPrompts: 其它選項

** 這裏存儲的就是一些除了上述三類選項之外的選項目前包含三個:

**Where do you prefer placing config for Babel, ESLint, etc.? **Babel,ESLint 等配置文件如何存儲?

**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)到這裏基本分析完了,剩下的一些細節問題就不再展開了。

這裏涉及到的相關源碼文件有,大家可以自行看一下:

  1. 初始化項目基礎文件

當用戶選完所有交互之後,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 :

 1{
 2  "name": "a",
 3  "version": "0.1.0",
 4  "private": true,
 5  "devDependencies": {
 6    "@vue/cli-plugin-babel": "~4.5.0",
 7    "@vue/cli-plugin-eslint": "~4.5.0",
 8    "@vue/cli-service": "~4.5.0"
 9  }
10}
11
12

11.2 初始化 Git

根據傳入的參數和一系列的判斷,會在目標目錄下初始化 Git 環境,簡單來說就是執行一下 git init

1await run('git init');
2
3

具體是否初始化 Git 環境是這樣判斷的:

 1shouldInitGit(cliOptions) {
 2  // 如果全局沒安裝 Git,則不初始化
 3  if (!hasGit()) {
 4    return false;
 5  }
 6  // 如果 CLI 有傳入 --git 參數,則初始化
 7  if (cliOptions.forceGit) {
 8    return true;
 9  }
10  // 如果 CLI 有傳入 --no-git,則不初始化
11  if (cliOptions.git === false || cliOptions.git === "false") {
12    return false;
13  }
14  // 如果當前目錄下已經有 Git 環境,就不初始化
15  return !hasProjectGit(this.context);
16}
17
18

11.3 初始化 README.md

項目的 README.md  會根據上下文動態生成,而不是寫死的一個文檔:

 1function generateReadme(pkg, packageManager) {
 2  return [
 3    `# ${pkg.name}\n`,
 4    '## Project setup',
 5    '```',
 6    `${packageManager} install`,
 7    '```',
 8    printScripts(pkg, packageManager),
 9    '### Customize configuration',
10    'See [Configuration Reference](https://cli.vuejs.org/config/).',
11    '',
12  ].join('\n');
13}
14
15

Vue CLI 創建的 README.md  會告知用戶如何使用這個項目,除了 npm install  之外,會根據 package.json  裏的 scripts  參數來動態生成使用文檔,比如如何開發、構建和測試:

 1const descriptions = {
 2  build: 'Compiles and minifies for production',
 3  serve: 'Compiles and hot-reloads for development',
 4  lint: 'Lints and fixes files',
 5  'test:e2e': 'Run your end-to-end tests',
 6  'test:unit': 'Run your unit tests',
 7};
 8
 9function printScripts(pkg, packageManager) {
10  return Object.keys(pkg.scripts || {})
11    .map(key => {
12      if (!descriptions[key]) return '';
13      return [
14        `\n### ${descriptions[key]}`,
15        '```',
16        `${packageManager} ${packageManager !== 'yarn' ? 'run ' : ''}${key}`,
17        '```',
18        '',
19      ].join('\n');
20    })
21    .join('');
22}
23

這裏可能會有讀者問,爲什麼不直接拷貝一個 README.md  文件過去呢?

11.4 安裝依賴

調用 ProjectManage 的 install 方法安裝依賴,代碼不復雜:

 1 async install () {
 2   if (this.needsNpmInstallFix) {
 3     // 讀取 package.json
 4     const pkg = resolvePkg(this.context)
 5     // 安裝 dependencies
 6     if (pkg.dependencies) {
 7       const deps = Object.entries(pkg.dependencies).map(([dep, range]) => `${dep}@${range}`)
 8       await this.runCommand('install', deps)
 9     }
10     // 安裝 devDependencies
11     if (pkg.devDependencies) {
12       const devDeps = Object.entries(pkg.devDependencies).map(([dep, range]) => `${dep}@${range}`)
13       await this.runCommand('install', [...devDeps, '--save-dev'])
14     }
15     // 安裝 optionalDependencies
16     if (pkg.optionalDependencies) {
17       const devDeps = Object.entries(pkg.devDependencies).map(([dep, range]) => `${dep}@${range}`)
18       await this.runCommand('install', [...devDeps, '--save-optional'])
19     }
20     return
21   }
22   return await this.runCommand('install', this.needsPeerDepsFix ? ['--legacy-peer-deps'] : [])
23 }
24
25

簡單來說就是讀取 package.json 然後分別安裝 npm 的不同依賴。

這裏的邏輯深入進去感覺還是挺複雜的,我也沒仔細深入看,就不展開說了。。。

11.4.1 自動判斷 NPM 源

這裏有一個有意思的點,關於安裝依賴時使用的 npm 倉庫源。如果用戶沒有指定安裝源,Vue CLI 會自動判斷是否使用淘寶的 NPM 安裝源,猜猜是如何實現的?

 1function shouldUseTaobao() {
 2  let faster
 3  try {
 4    faster = await Promise.race([
 5      ping(defaultRegistry),
 6      ping(registries.taobao)
 7    ])
 8  } catch (e) {
 9    return save(false)
10  }
11
12  if (faster !== registries.taobao) {
13    // default is already faster
14    return save(false)
15  }
16
17  const { useTaobaoRegistry } = await inquirer.prompt([
18    {
19      name: 'useTaobaoRegistry',
20      type: 'confirm',
21      message: chalk.yellow(
22        ` Your connection to the default ${command} registry seems to be slow.\n` +
23          `   Use ${chalk.cyan(registries.taobao)} for faster installation?`
24      )
25    }
26  ])
27  return save(useTaobaoRegistry);
28}
29

Vue CLI 中會通過 Promise.race 去請求默認安裝源淘寶安裝源: **

一般來說,肯定都是使用默認安裝源,但是考慮國內用戶。。咳咳。。爲這個設計點贊。

  1. Generator 生成代碼

除了 Creator  外,整個 Vue CLI 的第二大重要的類是 Generator,負責項目代碼的生成,來具體看看幹了啥。

15.1 初始化插件

在 generate  方法中,最先執行的是一個 initPlugins  方法,代碼如下:

 1async initPlugins () {
 2  for (const id of this.allPluginIds) {
 3    const api = new GeneratorAPI(id, this, {}, rootOptions)
 4    const pluginGenerator = loadModule(`${id}/generator`, this.context)
 5
 6    if (pluginGenerator && pluginGenerator.hooks) {
 7      await pluginGenerator.hooks(api, {}, rootOptions, pluginIds)
 8    }
 9  }
10}
11

在這裏會給每一個 package.json  裏的插件初始化一個 GeneratorAPI  實例,將實例傳入對應插件的 generator  方法並執行,比如 @vue/cli-plugin-babel/generator.js

15.2 GeneratorAPI 類

Vue CLI 使用了一套基於插件的架構。如果你查閱一個新創建項目的 package.json,就會發現依賴都是以 @vue/cli-plugin- 開頭的。插件可以修改 webpack 的內部配置,也可以向 vue-cli-service 注入命令。在項目創建的過程中,絕大部分列出的特性都是通過插件來實現的。

剛剛提到,會往每一個插件的 generator  中傳入 GeneratorAPI  的實例,看看這個類提供了什麼。

15.2.1 例子:@vue/cli-plugin-babel

爲了不那麼抽象,我們先拿 @vue/cli-plugin-babel 來看,這個插件比較簡單:

 1module.exports = api => {
 2  delete api.generator.files['babel.config.js'];
 3
 4  api.extendPackage({
 5    babel: {
 6      presets: ['@vue/cli-plugin-babel/preset'],
 7    },
 8    dependencies: {
 9      'core-js': '^3.6.5',
10    },
11  });
12};
13

這裏 api  就是一個 GeneratorAPI 實例,這裏用到了一個 extendPackage  方法:

 1// GeneratorAPI.js
 2// 刪減部分代碼,只針對 @vue/cli-plugin-babel 分析
 3extendPackage (fields, options = {}) {
 4  const pkg = this.generator.pkg
 5  const toMerge = isFunction(fields) ? fields(pkg) : fields
 6  // 遍歷傳入的參數,這裏是 babel 和 dependencies 兩個對象
 7  for (const key in toMerge) {
 8    const value = toMerge[key]
 9    const existing = pkg[key]
10    // 如果 key 的名稱是 dependencies 和 devDependencies
11    // 就通過 mergeDeps 方法往 package.json 合併依賴
12    if (isObject(value) && (key === 'dependencies' || key === 'devDependencies')) {
13      pkg[key] = mergeDeps(
14        this.id,
15        existing || {},
16        value,
17        this.generator.depSources,
18        extendOptions
19      )
20    } else if (!extendOptions.merge || !(key in pkg)) {
21      pkg[key] = value
22    }
23  }
24}
25
26

這時候,默認的 package.json  就變成:

 1{
 2  "babel": {
 3    "presets": ["@vue/cli-plugin-babel/preset"]
 4  },
 5  "dependencies": {
 6    "core-js": "^3.6.5"
 7  },
 8  "devDependencies": {},
 9  "name": "test",
10  "private": true,
11  "version": "0.1.0"
12}
13
14

看完這個例子,對於 GeneratorAPI  的實例做什麼可能有些瞭解了,我們就來具體看看這個類的實例吧。

15.2.2 重要的幾個實例方法

先介紹幾個 GeneratorAPI  重要的實例方法,這裏就只介紹功能,具體代碼就不看了,等等會用到。

16. @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  實例的不同方法:

16.1 渲染 template

1api.render('./template', {
2  doesCompile: api.hasPlugin('babel') || api.hasPlugin('typescript'),
3});
4
5

將 template  目錄下的文件通過 render  渲染到內存中,這裏用的是 ejs  作爲模板渲染引擎。

16.2 寫 package.json

通過 extendPackage 往 pacakge.json 中寫入 Vue   的相關依賴:

 1if (options.vueVersion === '3') {
 2  api.extendPackage({
 3    dependencies: {
 4      vue: '^3.0.0',
 5    },
 6    devDependencies: {
 7      '@vue/compiler-sfc': '^3.0.0',
 8    },
 9  });
10} else {
11  api.extendPackage({
12    dependencies: {
13      vue: '^2.6.11',
14    },
15    devDependencies: {
16      'vue-template-compiler': '^2.6.11',
17    },
18  });
19}
20
21

通過 extendPackage 往 pacakge.json 中寫入 scripts

1api.extendPackage({
2  scripts: {
3    serve: 'vue-cli-service serve',
4    build: 'vue-cli-service build',
5  },
6  browserslist: ['> 1%', 'last 2 versions', 'not dead'],
7});
8
9

通過 extendPackage 往 pacakge.json 中寫入 CSS 預處理參數:

 1if (options.cssPreprocessor) {
 2  const deps = {
 3    sass: {
 4      sass: '^1.26.5',
 5      'sass-loader': '^8.0.2',
 6    },
 7    'node-sass': {
 8      'node-sass': '^4.12.0',
 9      'sass-loader': '^8.0.2',
10    },
11    'dart-sass': {
12      sass: '^1.26.5',
13      'sass-loader': '^8.0.2',
14    },
15    less: {
16      less: '^3.0.4',
17      'less-loader': '^5.0.0',
18    },
19    stylus: {
20      stylus: '^0.54.7',
21      'stylus-loader': '^3.0.2',
22    },
23  };
24
25  api.extendPackage({
26    devDependencies: deps[options.cssPreprocessor],
27  });
28}
29

16.3 調用 router 插件和 vuex 插件

 1// for v3 compatibility
 2if (options.router && !api.hasPlugin('router')) {
 3  require('./router')(api, options, options);
 4}
 5
 6// for v3 compatibility
 7if (options.vuex && !api.hasPlugin('vuex')) {
 8  require('./vuex')(api, options, options);
 9}
10

是不是很簡單,通過 GeneratorAPI  提供的實例方法,可以在插件中非常方便地對項目進行修改和自定義。

  1. 抽取單獨配置文件

上文提到,通過 extendPackage  回往 package.json  中寫入一些配置。但是,上文也提到有一個交互是 Where do you prefer placing config for Babel, ESLint, etc.? 也就是會將配置抽取成單獨的文件。generate  裏的 extractConfigFiles  方法就是執行了這個邏輯。

 1extractConfigFiles(extractAll, checkExisting) {
 2  const configTransforms = Object.assign(
 3    {},
 4    defaultConfigTransforms,
 5    this.configTransforms,
 6    reservedConfigTransforms
 7  );
 8  const extract = (key) => {
 9    if (
10      configTransforms[key] &&
11      this.pkg[key] &&
12      !this.originalPkg[key]
13    ) {
14      const value = this.pkg[key];
15      const configTransform = configTransforms[key];
16      const res = configTransform.transform(
17        value,
18        checkExisting,
19        this.files,
20        this.context
21      );
22      const { content, filename } = res;
23      this.files[filename] = ensureEOL(content);
24      delete this.pkg[key];
25    }
26  };
27  if (extractAll) {
28    for (const key in this.pkg) {
29      extract(key);
30    }
31  } else {
32    extract("babel");
33  }
34}
35
36

這裏的 configTransforms  就是一些會需要抽取的配置:

如果 extractAll  是 true,也就是在上面的交互中選了 Yes,就會將 package.json  裏的所有 key configTransforms 比較,如果都存在,就將配置抽取到獨立的文件中。

  1. 將內存中的文件輸出到硬盤

上文有提到,api.render  會通過 EJS 將模板文件渲染成字符串放在內存中。執行了 generate  的所有邏輯之後,內存中已經有了需要輸出的各種文件,放在 this.files  裏。 generate  的最後一步就是調用 writeFileTree  將內存中的所有文件寫入到硬盤。

到這裏 generate  的邏輯就基本都講完了,Vue CLI 生成代碼的部分也就講完了。

  1. 總結

整體看下來,Vue CLI 的代碼還是比較複雜的,整體架構條理還是比較清楚的,其中有兩點印象最深:

第一,整體的交互流程的掛載。將各個模塊的交互邏輯通過一個類的實例維護起來,執行時機和成功回調等也是設計的比較好。

第二,插件機制很重要。插件機制將功能和腳手架進行解耦。

看來,無論是 create-react-app 還是 Vue CLI,在設計的時候都會盡量考慮插件機制,將能力開放出去再將功能集成進來,無論是對於 Vue CLI 本身的核心功能,還是對於社區開發者來說,都具備了足夠的開放性和擴展性。

整體代碼看下來,最重要的就是兩個概念:

圍繞這兩個概念,代碼中的這幾個類:CreatorPromptModuleAPIGeneratorGeneratorAPI 就是核心。

簡單總結一下流程:

  1. 執行 vue create

  2. 初始化 Creator 實例 creator,掛載所有交互配置

  3. 調用 creator 的實例方法 create

  4. 詢問用戶自定義配置

  5. 初始化 Generator 實例 generator

  6. 初始化各種插件

  7. 執行插件的 generator 邏輯,寫 package.json、渲染模板等

  8. 將文件寫入到硬盤

這樣一個 CLI 的生命週期就走完了,項目已經初始化好了。

附:Vue CLI 中可以直接拿來用的工具方法

看完 Vue CLI 的源碼,除了感嘆這複雜的設計之外,也發現很多工具方法,在我們實現自己的 CLI 時,都是可以拿來即用的,在這裏總結一下。

獲取 CLI 參數

解析 CLI 通過 -- 傳入的參數。

 1const program = require('commander');
 2
 3function camelize(str) {
 4  return str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : ''));
 5}
 6
 7function cleanArgs(cmd) {
 8  const args = {};
 9  cmd.options.forEach(o => {
10    const key = camelize(o.long.replace(/^--/, ''));
11    // if an option is not present and Command has a method with the same name
12    // it should not be copied
13    if (typeof cmd[key] !== 'function' && typeof cmd[key] !== 'undefined') {
14      args[key] = cmd[key];
15    }
16  });
17  return args;
18}
19

檢查 Node 版本

通過 semver.satisfies 比較兩個 Node 版本:

 1const requiredVersion = require('../package.json').engines.node;
 2
 3function checkNodeVersion(wanted, id) {
 4  if (!semver.satisfies(process.version, wanted, { includePrerelease: true })) {
 5    console.log(
 6      chalk.red(
 7        'You are using Node ' +
 8          process.version +
 9          ', but this version of ' +
10          id +
11          ' requires Node ' +
12          wanted +
13          '.\nPlease upgrade your Node version.'
14      )
15    );
16    process.exit(1);
17  }
18}
19
20checkNodeVersion(requiredVersion, '@vue/cli');
21

讀取 package.json

 1const fs = require('fs');
 2const path = require('path');
 3
 4function getPackageJson(cwd) {
 5  const packagePath = path.join(cwd, 'package.json');
 6
 7  let packageJson;
 8  try {
 9    packageJson = fs.readFileSync(packagePath, 'utf-8');
10  } catch (err) {
11    throw new Error(`The package.json file at '${packagePath}' does not exist`);
12  }
13
14  try {
15    packageJson = JSON.parse(packageJson);
16  } catch (err) {
17    throw new Error('The package.json is malformed');
18  }
19
20  return packageJson;
21}
22

對象排序

這裏主要是在輸出 package.json 的時候可以對輸出的對象先進行排序,更美觀一些。。

 1module.exports = function sortObject(obj, keyOrder, dontSortByUnicode) {
 2  if (!obj) return;
 3  const res = {};
 4
 5  if (keyOrder) {
 6    keyOrder.forEach(key => {
 7      if (obj.hasOwnProperty(key)) {
 8        res[key] = obj[key];
 9        delete obj[key];
10      }
11    });
12  }
13
14  const keys = Object.keys(obj);
15
16  !dontSortByUnicode && keys.sort();
17  keys.forEach(key => {
18    res[key] = obj[key];
19  });
20
21  return res;
22};
23

輸出文件到硬盤

這個其實沒啥,就是三步:

 1const fs = require('fs-extra');
 2const path = require('path');
 3
 4// 刪除已經存在的文件
 5function deleteRemovedFiles(directory, newFiles, previousFiles) {
 6  // get all files that are not in the new filesystem and are still existing
 7  const filesToDelete = Object.keys(previousFiles).filter(
 8    filename => !newFiles[filename]
 9  );
10
11  // delete each of these files
12  return Promise.all(
13    filesToDelete.map(filename => {
14      return fs.unlink(path.join(directory, filename));
15    })
16  );
17}
18
19// 輸出文件到硬盤
20module.exports = async function writeFileTree(dir, files, previousFiles) {
21  if (previousFiles) {
22    await deleteRemovedFiles(dir, files, previousFiles);
23  }
24  // 主要就是這裏
25  Object.keys(files).forEach(name => {
26    const filePath = path.join(dir, name);
27    fs.ensureDirSync(path.dirname(filePath));
28    fs.writeFileSync(filePath, files[name]);
29  });
30};
31

判斷項目是否初始化 git

其實就是在目錄下執行 git status 看是否報錯。

 1const hasProjectGit = cwd => {
 2  let result;
 3  try {
 4    execSync('git status', { stdio: 'ignore', cwd });
 5    result = true;
 6  } catch (e) {
 7    result = false;
 8  }
 9  return result;
10};
11
12

對象的 get 方法

可以用 lodash,現在可以直接用 a?.b?.c 就好了

 1function get(target, path) {
 2  const fields = path.split('.');
 3  let obj = target;
 4  const l = fields.length;
 5  for (let i = 0; i < l - 1; i++) {
 6    const key = fields[i];
 7    if (!obj[key]) {
 8      return undefined;
 9    }
10    obj = obj[key];
11  }
12  return obj[fields[l - 1]];
13}
14
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/nUNBNBcSs1AoINI3GLlQcg