Vue CLI 是如何實現的 -- 終端命令行工具篇
Vue CLI 是一個基於 Vue.js 進行快速開發的完整系統,提供了終端命令行工具、零配置腳手架、插件體系、圖形化管理界面等。本文暫且只分析項目初始化部分,也就是終端命令行工具的實現。
- 用法
用法很簡單,每個 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
具體的用法大家感興趣的可以嘗試一下,這裏就不展開了,後續在源碼分析中會有相應的部分提到。
- 入口文件
本文中的
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 對於代碼模塊的管理非常細,每個模塊基本上都是單一功能模塊,可以任意地拼裝和使用。每個文件的代碼行數也都不會很多,閱讀起來非常舒服。
- 輸入命令有誤,猜測用戶意圖
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 這裏使用了這個包,來分別計算輸入的命令和當前已掛載的所有命令的編輯舉例,從而猜測用戶實際想輸入的命令是哪個。
小而美的一個功能,用戶體驗極大提升。
- 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 版本:
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 版本的一個情況:
解釋一下圖中幾個狀態:
-
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 <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,這個邏輯是可以拿來即用的。
- 檢查應用名
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
- 若目標文件夾已存在,是否覆蓋
這段代碼比較簡單,就是判斷 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
- 整體錯誤捕獲
在 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
- 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
: presetPrompt
,featurePrompt
, 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 中的方法觸發這些事件。
- Preset(預設)
Creator
類的實例方法 create
接受兩個參數:
-
cliOptions:終端命令行傳入的參數
-
preset:Vue CLI 的預設
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
方法就是獲取一些用於和用戶交互的模塊,比如:
-
babel:選擇是否使用 Babel
-
cssPreprocessors:選擇 CSS 的預處理器(Sass、Less、Stylus)
-
...
先說到這裏,後面在自定義配置加載的章節裏會展開介紹 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
可以看到,代碼中分別針對幾種情況作了處理:
-
cli 參數配了 --preset
-
cli 參數配了 --default
-
cli 參數配了 --inlinePreset
-
cli 沒配相關參數,默認獲取
Preset
的行爲
前三種情況就不展開說了,我們來看一下第四種情況,也就是默認通過交互 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
文件了。
- 自定義配置加載
這四種 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 等配置文件如何存儲?
-
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
-
...
- 初始化項目基礎文件
當用戶選完所有交互之後,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
文件過去呢?
-
第一,Vue CLI 支持不同的包管理,對應安裝、啓動和構建腳本都是不一樣的,這個是需要動態生成的;
-
第二,動態生成自由性更強,可以根據用戶的選項去生成對應的文檔,而不是大家都一樣。
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
去請求默認安裝源和淘寶安裝源: **
-
如果先返回的是淘寶安裝源,就會讓用戶確認一次,是否使用淘寶安裝源
-
如果先返回的是默認安裝源,就會直接使用默認安裝源
一般來說,肯定都是使用默認安裝源,但是考慮國內用戶。。咳咳。。爲這個設計點贊。
- 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
重要的實例方法,這裏就只介紹功能,具體代碼就不看了,等等會用到。
-
extendPackage:拓展 package.json 配置
-
render:通過 ejs 渲染模板文件
-
onCreateComplete: 註冊文件寫入硬盤之後的回調
-
genJSConfig: 將 json 文件輸出成 js 文件
-
injectImports: 向文件中加入 import
-
...
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
提供的實例方法,可以在插件中非常方便地對項目進行修改和自定義。
- 抽取單獨配置文件
上文提到,通過 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
比較,如果都存在,就將配置抽取到獨立的文件中。
- 將內存中的文件輸出到硬盤
上文有提到,api.render
會通過 EJS 將模板文件渲染成字符串放在內存中。執行了 generate
的所有邏輯之後,內存中已經有了需要輸出的各種文件,放在 this.files
裏。 generate
的最後一步就是調用 writeFileTree
將內存中的所有文件寫入到硬盤。
到這裏 generate
的邏輯就基本都講完了,Vue CLI 生成代碼的部分也就講完了。
- 總結
整體看下來,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 通過 --
傳入的參數。
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 版本:
-
process.version: 當前運行環境的 Node 版本
-
wanted: package.json 裏配置的 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
輸出文件到硬盤
這個其實沒啥,就是三步:
-
fs.unlink 刪除文件
-
fs.ensureDirSync 創建目錄
-
fs.writeFileSync 寫文件
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