
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 去請求默認安裝源和淘寶安裝源:
**
- 如果先回傳的是淘寶安裝源,就會讓用戶確認一次,是否使用淘寶安裝源
- 如果先回傳的是默認安裝源,就會直接使用默認安裝源
一般來說,肯定都是使用默認安裝源,但是考慮國內用戶,,咳咳,,為這個設計點贊,
15. Generator 生成代碼
除了 Creator 外,整個 Vue CLI 的第二大重要的類是 Generator,負責專案代碼的生成,來具體看看干了啥,
15.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,
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 來看,這個插件比較簡單:
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 = https://www.cnblogs.com/axuebin/p/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 的實體做什么可能有些了解了,我們就來具體看看這個類的實體吧,
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
api.render('./template', {
doesCompile: api.hasPlugin('babel') || api.hasPlugin('typescript'),
});
將 template 目錄下的檔案通過 render 渲染到記憶體中,這里用的是 ejs 作為模板渲染引擎,
16.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],
});
}
16.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 提供的實體方法,可以在插件中非常方便地對專案進行修改和自定義,
17. 抽取單獨組態檔
上文提到,通過 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 = https://www.cnblogs.com/axuebin/p/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 比較,如果都存在,就將配置抽取到獨立的檔案中,
18. 將記憶體中的檔案輸出到硬碟
上文有提到,api.render 會通過 EJS 將模板檔案渲染成字串放在記憶體中,執行了 generate 的所有邏輯之后,記憶體中已經有了需要輸出的各種檔案,放在 this.files 里, generate 的最后一步就是呼叫 writeFileTree 將記憶體中的所有檔案寫入到硬碟,
到這里 generate 的邏輯就基本都講完了,Vue CLI 生成代碼的部分也就講完了,
19. 總結
整體看下來,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]];
}
轉載請註明出處,本文鏈接:https://www.uj5u.com/qiye/278772.html
標籤:JavaScript
