原文連接 http://axuebin.com/articles/fe-solution/cli/vuecli.html 轉載請聯繫。
Vue CLI 是一個基於 Vue.js 進行快速開發的完整系統,提供了終端命令行工具、零配置腳手架、插件體系、圖形化管理界面等。本文暫且只分析項目初始化部分,也就是終端命令行工具的實現。javascript
我的原創技術文章會發在公衆號玩相機的程序員上,用鍵盤和相機記錄生活的公衆號。css
用法很簡單,每一個 CLI 都大同小異:html
npm install -g @vue/cli vue create vue-cli-test
目前 Vue CLI 同時支持 Vue 2 和 Vue 3 項目的建立(默認配置)。vue
上面是 Vue CLI 提供的默認配置,能夠快速地建立一個項目。除此以外,也能夠根據本身的項目需求(是否使用 Babel、是否使用 TS 等)來自定義項目工程配置,這樣會更加的靈活。java
選擇完成以後,敲下回車,就開始執行安裝依賴、拷貝模板等命令...node
看到 Successfully 就是項目初始化成功了。react
vue create
命令支持一些參數配置,能夠經過 vue create --help
獲取詳細的文檔:webpack
用法: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 輸出使用幫助信息
具體的用法你們感興趣的能夠嘗試一下,這裏就不展開了,後續在源碼分析中會有相應的部分提到。git
本文中的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 對於代碼模塊的管理很是細,每一個模塊基本上都是單一功能模塊,能夠任意地拼裝和使用。每一個文件的代碼行數也都不會不少,閱讀起來很是舒服。
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 這裏使用了這個包,來分別計算輸入的命令和當前已掛載的全部命令的編輯舉例,從而猜想用戶實際想輸入的命令是哪一個。
小而美的一個功能,用戶體驗極大提高。
和 create-react-app
相似,Vue CLI 也是先檢查了一下當前 Node 版本是否符合要求:
process.version
require("../package.json").engines.node
好比我目前在用的是 Node v10.20.1 而 @vue/cli 4.5.9 要求的 Node 版本是 >=8.9
,因此是符合要求的。
在 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 版本的一個狀況:
解釋一下圖中幾個狀態:
經過上面那張圖,咱們能夠看到,Node 8.x 在 2020 年已經 EOL,Node 12.x 在 2021 年的時候也會進入 MAINTENANCE 狀態,而 Node 10.x 在 2021 年 四、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
中就有相關代碼是在處理這個邏輯的。
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,這個邏輯是能夠拿來即用的。
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
這段代碼比較簡單,就是判斷 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); } } } }
在 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); } }); };
在 lib/create.js
方法的最後,執行了這樣兩行代碼:
const creator = new Creator(name, targetDir, getPromptModules()); await creator.create(options);
看來最重要的代碼仍是在 Creator
這個類中。
打開 Creator.js
文件,好傢伙,500+ 行代碼,而且引入了 12 個模塊。固然,這篇文章不會把這 500 行代碼和 12 個模塊都理一遍,不必,感興趣的本身去看看好了。
本文仍是梳理主流程和一些有意思的功能。
先看一下 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
,具體有什麼區別呢?下文有有詳細展開。
首先, 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 中的方法觸發這些事件。
Creator
類的實例方法 create
接受兩個參數:
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。
用過 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
方法就是獲取一些用於和用戶交互的模塊,好比:
先說到這裏,後面在自定義配置加載的章節裏會展開介紹 Vue CLI 用到的全部 prompt
。
咱們具體來看一下獲取預設相關的邏輯。這部分代碼在 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(); } } } };
能夠看到,代碼中分別針對幾種狀況做了處理:
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
,在下一節展開介紹。
**
在 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
文件了。
這四種 prompt
分別對應的是預設選項、自定義 feature 選擇、具體 feature 選項和其它選項,它們之間存在互相關聯、層層遞進的關係。結合這四種 prompt
,就是 Vue CLI 展示開用戶面前的全部交互了,其中也包含自定義配置的加載。
也就是最初截圖裏看到的哪三個選項,選擇 Vue2 仍是 Vue3 仍是自定義 feature
:
若是選擇了 Vue2
或者 Vue3
,則後續關於 preset
全部的 prompt
都會終止。
**
若是在 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
的時候纔會彈出這個交互。
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
的交互。
**
這裏存儲的就是一些除了上述三類選項以外的選項目前包含三個:
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 的名稱。
這裏總結一下 Vue CLI 的總體交互,也就是 prompt
的實現。
也就是文章最開始的時候提到,Vue CLI 支持默認配置以外,也支持自定義配置(Babel、TS 等),這樣一個交互流程是如何實現的。
Vue CLI 將全部交互分爲四大類:
從預設選項到具體 feature 選項,它們是一個層層遞進的關係,不一樣的時機和選擇會觸發不一樣的交互。
Vue CLI 這裏在代碼架構上的設計值得學習,將各個交互維護在不一樣的模塊中,經過統一的一個 prmoptAPI
實例在 Creator
實例初始化的時候,插入到不一樣的 prompt
中,而且註冊各自的回調函數。這樣設計對於 prompt
而言是徹底解耦的,刪除某一項 prompt
對於上下文的影響能夠忽略不計。
好了,關於預設(Preset)和交互(Prompt)到這裏基本分析完了,剩下的一些細節問題就再也不展開了。
這裏涉及到的相關源碼文件有,你們能夠自行看一下:
當用戶選完全部交互以後,CLI 的下一步職責就是根據用戶的選項去生成對應的代碼了,這也是 CLI 的核心功能之一。
根據用戶的選項會掛載相關的 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" } }
根據傳入的參數和一系列的判斷,會在目標目錄下初始化 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); }
項目的 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
文件過去呢?
調用 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
的不一樣依賴。
這裏的邏輯深刻進去感受仍是挺複雜的,我也沒仔細深刻看,就不展開說了。。。
這裏有一個有意思的點,關於安裝依賴時使用的 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
去請求默認安裝源和淘寶安裝源:
**
通常來講,確定都是使用默認安裝源,可是考慮國內用戶。。咳咳。。爲這個設計點贊。
除了 Creator
外,整個 Vue CLI 的第二大重要的類是 Generator
,負責項目代碼的生成,來具體看看幹了啥。
在 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
。
Vue CLI 使用了一套基於插件的架構。若是你查閱一個新建立項目的 package.json,就會發現依賴都是以 @vue/cli-plugin- 開頭的。插件能夠修改 webpack 的內部配置,也能夠向 vue-cli-service 注入命令。在項目建立的過程當中,絕大部分列出的特性都是經過插件來實現的。
剛剛提到,會往每個插件的 generator
中傳入 GeneratorAPI
的實例,看看這個類提供了什麼。
爲了避免那麼抽象,咱們先拿 @vue/cli-plugin-babel
來看,這個插件比較簡單:
module.exports = api => { delete api.generator.files['babel.config.js']; api.extendPackage({ babel: { presets: ['@vue/cli-plugin-babel/preset'], }, dependencies: { 'core-js': '^3.6.5', }, }); };
這裏 api
就是一個 GeneratorAPI
實例,這裏用到了一個 extendPackage
方法:
// GeneratorAPI.js // 刪減部分代碼,只針對 @vue/cli-plugin-babel 分析 extendPackage (fields, options = {}) { const pkg = this.generator.pkg const toMerge = isFunction(fields) ? fields(pkg) : fields // 遍歷傳入的參數,這裏是 babel 和 dependencies 兩個對象 for (const key in toMerge) { const value = toMerge[key] const existing = pkg[key] // 若是 key 的名稱是 dependencies 和 devDependencies // 就經過 mergeDeps 方法往 package.json 合併依賴 if (isObject(value) && (key === 'dependencies' || key === 'devDependencies')) { pkg[key] = mergeDeps( this.id, existing || {}, value, this.generator.depSources, extendOptions ) } else if (!extendOptions.merge || !(key in pkg)) { pkg[key] = value } } }
這時候,默認的 package.json
就變成:
{ "babel": { "presets": ["@vue/cli-plugin-babel/preset"] }, "dependencies": { "core-js": "^3.6.5" }, "devDependencies": {}, "name": "test", "private": true, "version": "0.1.0" }
看完這個例子,對於 GeneratorAPI
的實例作什麼可能有些瞭解了,咱們就來具體看看這個類的實例吧。
先介紹幾個 GeneratorAPI
重要的實例方法,這裏就只介紹功能,具體代碼就不看了,等等會用到。
上文已經看過一個 @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
實例的不一樣方法:
api.render('./template', { doesCompile: api.hasPlugin('babel') || api.hasPlugin('typescript'), });
將 template
目錄下的文件經過 render
渲染到內存中,這裏用的是 ejs
做爲模板渲染引擎。
經過 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], }); }
// 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
提供的實例方法,能夠在插件中很是方便地對項目進行修改和自定義。
上文提到,經過 extendPackage
回往 package.json
中寫入一些配置。可是,上文也提到有一個交互是 Where do you prefer placing config for Babel, ESLint, etc.? 也就是會將配置抽取成單獨的文件。generate
裏的 extractConfigFiles
方法就是執行了這個邏輯。
extractConfigFiles(extractAll, checkExisting) { const configTransforms = Object.assign( {}, defaultConfigTransforms, this.configTransforms, reservedConfigTransforms ); const extract = (key) => { if ( configTransforms[key] && this.pkg[key] && !this.originalPkg[key] ) { const value = this.pkg[key]; const configTransform = configTransforms[key]; const res = configTransform.transform( value, checkExisting, this.files, this.context ); const { content, filename } = res; this.files[filename] = ensureEOL(content); delete this.pkg[key]; } }; if (extractAll) { for (const key in this.pkg) { extract(key); } } else { extract("babel"); } }
這裏的 configTransforms
就是一些會須要抽取的配置:
若是 extractAll
是 true
,也就是在上面的交互中選了 Yes,就會將 package.json
裏的全部 key
configTransforms
比較,若是都存在,就將配置抽取到獨立的文件中。
上文有提到,api.render
會經過 EJS 將模板文件渲染成字符串放在內存中。執行了 generate
的全部邏輯以後,內存中已經有了須要輸出的各類文件,放在 this.files
裏。 generate
的最後一步就是調用 writeFileTree
將內存中的全部文件寫入到硬盤。
到這裏 generate
的邏輯就基本都講完了,Vue CLI 生成代碼的部分也就講完了。
總體看下來,Vue CLI 的代碼仍是比較複雜的,總體架構條理仍是比較清楚的,其中有兩點印象最深:
第一,總體的交互流程的掛載。將各個模塊的交互邏輯經過一個類的實例維護起來,執行時機和成功回調等也是設計的比較好。
第二,插件機制很重要。插件機制將功能和腳手架進行解耦。
看來,不管是 create-react-app 仍是 Vue CLI,在設計的時候都會盡可能考慮插件機制,將能力開放出去再將功能集成進來,不管是對於 Vue CLI 自己的核心功能,仍是對於社區開發者來講,都具有了足夠的開放性和擴展性。
總體代碼看下來,最重要的就是兩個概念:
圍繞這兩個概念,代碼中的這幾個類:Creator、PromptModuleAPI、Generator、GeneratorAPI 就是核心。
簡單總結一下流程:
vue create
Creator
實例 creator
,掛載全部交互配置creator
的實例方法 create
Generator
實例 generator
generator
邏輯,寫 package.json
、渲染模板等這樣一個 CLI 的生命週期就走完了,項目已經初始化好了。
看完 Vue 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; }
經過 semver.satisfies
比較兩個 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');
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; };
這個其實沒啥,就是三步:
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 status
看是否報錯。
const hasProjectGit = cwd => { let result; try { execSync('git status', { stdio: 'ignore', cwd }); result = true; } catch (e) { result = false; } return result; };
能夠用 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]]; }
我的原創技術文章會發在公衆號玩相機的程序員上,用鍵盤和相機記錄生活的公衆號。