通過 Vite 的 create-app 學(xué)習(xí)如何實現(xiàn)一個簡易版 CLI
前言
前段時間,尤雨溪回答了一個廣大網(wǎng)友都好奇的一個問題:Vite 會不會取代 Vue CLI?

答案是:是的!
那么,你開始學(xué) Vite 了嗎?用過 Vite 的同學(xué)應(yīng)該都熟悉,創(chuàng)建一個 Vite 的項目模版是通過 npm init @vitejs/app 的方式。而 npm init 命令是在 [email protected] 開始支持的,實際上它是先幫你安裝 Vite 的 @vitejs/create-app 包(package),然后再執(zhí)行 create-app 命令。
至于 @vitejs/create-app 則是在 Vite 項目的 packages/create-app 文件夾下。其整體的目錄結(jié)構(gòu):
// packages/create-app
|———— template-lit-element
|———— template-lit-element-ts
|———— template-preact
|———— template-preact-ts
|———— template-react
|———— template-react-ts
|———— template-vanilla
|———— template-vue
|———— template-vue-ts
index.js
package.json
Vite 的 create-app CLI(以下統(tǒng)稱為 create-app CLI)具備的能力不多,目前只支持基礎(chǔ)模版的創(chuàng)建,所以全部代碼加起來只有 160 行,其整體的架構(gòu)圖:

可以看出確實非常簡單,也因此 create-app CLI 是一個很值得入門學(xué)習(xí)如何實現(xiàn)簡易版 CLI 的例子。
那么,接下來本文將會圍繞以下兩個部分帶著大家一起通過 create-app CLI 來學(xué)習(xí)如何制作一個簡易版的 CLI:
create-app中使用到的庫(minimist、kolorist)逐步拆解、分析
create-appCLI 源碼
create-app CLI 中使用到的庫
create-app CLI 實現(xiàn)用到的庫(npm)確實很有意思,既有我們熟悉的 enquirer(用于命令行的提示),也有不熟悉的 minimist 和 kolorist。那么,后面這兩者又是拿來干嘛的?下面,我們就來了解一番~
minimist

minimist 是一個輕量級的用于解析命令行參數(shù)的工具。說起解析命令行的工具,我想大家很容易想到 commander。相比較 commander 而言,minimist 則以輕取勝!因為它只有 32.4 kB,commander 則有 142 kB,即也只有后者的約 1/5。
那么,下面我們就來看一下 minimist 的基礎(chǔ)使用。
例如,此時我們在命令行中輸入:
node index.js my-project
那么,在 index.js 文件中可以使用 minimist 獲取到輸入的 myproject 參數(shù):
var argv = require('minimist')(process.argv.slice(2));
console.log(argv._[0]);
// 輸出 my-project
這里的 argv 是一個對象,對象中 _ 屬性的值則是解析 node index.js 后的參數(shù)所形成的數(shù)組。
kolorist

kolorist 是一個輕量級的使命令行輸出帶有色彩的工具。并且,說起這類工具,我想大家很容易想到的就是 chalk。不過相比較 chalk 而言,兩者包的大小差距并不明顯,前者為 49.9 kB,后者為 33.6 kB。不過 kolorist 可能較為小眾,npm 的下載量大大不如后者 chalk,相應(yīng)地 chalk 的 API 也較為詳盡。
同樣的,下面我們也來看一下 kolorist 的基礎(chǔ)使用。
例如,當(dāng)此時應(yīng)用發(fā)生異常的時候,需要打印出紅色的異常信息告知用戶發(fā)生異常,我們可以使用 kolorist 提供的 red 函數(shù):
import { red } from 'kolorist'
console.log(red("Something is wrong"))
又或者,可以使用 kolorist 提供的 stripColors 來直接輸出帶顏色的字符串:
import { red, stripColors } from 'kolorist'
console.log(stripColors(red("Something is wrong"))
逐步拆解、分析 create-app CLI 源碼
了解過 CLI 相關(guān)知識的同學(xué)應(yīng)該知道,我們通常使用的命令是在 package.json 文件的 bin 中配置的。而 create-app CLI 對應(yīng)的文件根目錄下該文件的 bin 配置會是這樣:
// pacakges/create-app/package.json
"bin": {
"create-app": "index.js",
"cva": "index.js"
}
可以看到 create-app 命令則由這里注冊生效,它指向的是當(dāng)前目錄下的 index.js 文件。并且,值得一提的是這里注冊了 2 個命令,也就是說我們還可以使用 cva 命令來創(chuàng)建基于 Vite 的項目模版(想不到吧 ??)。
而 create-app CLI 實現(xiàn)的核心就是在 index.js 文件。那么,下面我們來看一下 index.js 中代碼的實現(xiàn)~
基礎(chǔ)依賴引入
上面我們也提及了 create-app CLI 引入了 minimist、enquire、kolorist 等依賴,所以首先是引入它們:
const fs = require('fs')
const path = require('path')
const argv = require('minimist')(process.argv.slice(2))
const { prompt } = require('enquirer')
const {
yellow,
green,
cyan,
magenta,
lightRed,
stripColors
} = require('kolorist')
其中,fs 和 path 是 Node 內(nèi)置的模塊,前者用于文件相關(guān)操作、后者用于文件路徑相關(guān)操作。接著就是引入 minimist、enquirer 和 kolorist,它們相關(guān)的介紹上面已經(jīng)提及,這里就不重復(fù)論述~
定義項目模版(顏色)和文件
從 /packages/create-app 目錄中,我們可以看出 create-app CLI 為我們提供了 9 種項目基礎(chǔ)模版。并且,在命令行交互的時候,每個模版之間的顏色各有不同,即 CLI 會使用 kolorist 提供的顏色函數(shù)來為模版定義好對應(yīng)的顏色:
const TEMPLATES = [
yellow('vanilla'),
green('vue'),
green('vue-ts'),
cyan('react'),
cyan('react-ts'),
magenta('preact'),
magenta('preact-ts'),
lightRed('lit-element'),
lightRed('lit-element-ts')
]
其次,由于 .gitignore 文件的特殊性,每個項目模版下都是先創(chuàng)建的 _gitignore 文件,在后續(xù)創(chuàng)建項目的時候再替換掉該文件的命名(替換為 .gitignore)。所以,CLI 會預(yù)先定義一個對象來存放需要重命名的文件:
const renameFiles = {
_gitignore: '.gitignore'
}
定義文件操作相關(guān)的工具函數(shù)
由于創(chuàng)建項目的過程中會涉及和文件相關(guān)的操作,所以 CLI 內(nèi)部定義了 3 個工具函數(shù):
copyDir 函數(shù)
copyDir 函數(shù)用于將某個文件夾 srcDir 中的文件復(fù)制到指定文件夾 destDir中。它會先調(diào)用 fs.mkdirSync 函數(shù)來創(chuàng)建制定的文件夾,然后枚舉從 srcDir 文件夾下獲取的文件名構(gòu)成的數(shù)組,即 fs.readdirSync(srcDir)。
其對應(yīng)的代碼如下:
function copyDir(srcDir, destDir) {
fs.mkdirSync(destDir, { recursive: true })
for (const file of fs.readdirSync(srcDir)) {
const srcFile = path.resolve(srcDir, file)
const destFile = path.resolve(destDir, file)
copy(srcFile, destFile)
}
}
copy 函數(shù)
copy 函數(shù)則用于復(fù)制文件或文件夾 src 到指定文件夾 dest。它會先獲取 src 的狀態(tài) stat,如果 src 是文件夾的話,即 stat.isDirectory() 為 true 時,則會調(diào)用上面介紹的 copyDir 函數(shù)來復(fù)制 src 文件夾下的文件到 dest 文件夾下。反之,src 是文件的話,則直接調(diào)用 fs.copyFileSync 函數(shù)復(fù)制 src 文件到 dest 文件夾下。
其對應(yīng)的代碼如下:
function copy(src, dest) {
const stat = fs.statSync(src)
if (stat.isDirectory()) {
copyDir(src, dest)
} else {
fs.copyFileSync(src, dest)
}
}
emptyDir 函數(shù)
emptyDir 函數(shù)用于清空 dir 文件夾下的代碼。它會先判斷 dir 文件夾是否存在,存在則枚舉該問文件夾下的文件,構(gòu)造該文件的路徑 abs,調(diào)用 fs.unlinkSync 函數(shù)來刪除該文件,并且當(dāng) abs 為文件夾時,則會遞歸調(diào)用 emptyDir 函數(shù)刪除該文件夾下的文件,然后再調(diào)用 fs.rmdirSync 刪除該文件夾。
其對應(yīng)的代碼如下:
function emptyDir(dir) {
if (!fs.existsSync(dir)) {
return
}
for (const file of fs.readdirSync(dir)) {
const abs = path.resolve(dir, file)
if (fs.lstatSync(abs).isDirectory()) {
emptyDir(abs)
fs.rmdirSync(abs)
} else {
fs.unlinkSync(abs)
}
}
}
CLI 實現(xiàn)核心函數(shù)
CLI 實現(xiàn)核心函數(shù)是 init,它負(fù)責(zé)使用前面我們所說的那些函數(shù)、工具包來實現(xiàn)對應(yīng)的功能。下面,我們就來逐點分析 init 函數(shù)實現(xiàn)的過程:
1. 創(chuàng)建項目文件夾
通常,我們可以使用 create-app my-project 命令來指定要創(chuàng)建的項目文件夾,即在哪個文件夾下:
let targetDir = argv._[0]
// cwd = process.cwd()
const root = path.join(cwd, targetDir)
console.log(`Scaffolding project in ${root}...`)
其中,argv._[0] 代表 create-app 后的第一個參數(shù),root 是通過 path.join 函數(shù)構(gòu)建的完整文件路徑。然后,在命令行中會輸出提示,告述你腳手架(Scaffolding)項目創(chuàng)建的文件路徑:
Scaffolding project in /Users/wjc/Documents/project/vite-project...
當(dāng)然,有時候我們并不想輸入在 create-app 后輸入項目文件夾,而只是輸入 create-app 命令。那么,此時 tagertDir 是不存在的。CLI 則會使用 enquirer 包的 prompt 來在命令行中輸出詢問:
? project name: > vite-project
你可以在這里輸入項目文件夾名,又或者直接回車使用 CLI 給的默認(rèn)項目文件夾名。這個過程對應(yīng)的代碼:
if (!targetDir) {
const { name } = await prompt({
type: "input",
name: "name",
message: "Project name:",
initial: "vite-project"
})
targetDir = name
}
接著,CLI 會判斷該文件夾是否存在當(dāng)前的工作目錄(cwd)下,如果不存在則會使用 fs.mkdirSync 創(chuàng)建一個文件夾:
if (!fs.existsSync(root)) {
fs.mkdirSync(root, { recursive: true })
}
反之,如果存在該文件夾,則會判斷此時文件夾下是否存在文件,即使用 fs.readdirSync(root) 獲取該文件夾下的文件:
const existing = fs.readdirSync(root)
這里 existing 會是一個數(shù)組,如果此時數(shù)組長度不為 0,則表示該文件夾下存在文件。那么 CLI 則會詢問是否刪除該文件夾下的文件:
Target directory vite-project is not empty.
Remove existing files and continue?(y/n): Y
你可以選擇通過輸入 y 或 n 來告知 CLI 是否要清空該目錄。并且,如果此時你輸入的是 y,即不清空該文件夾,那么整個 CLI 的執(zhí)行就會退出。這個過程對應(yīng)的代碼:
if (existing.length) {
const { yes } = await prompt({
type: 'confirm',
name: 'yes',
initial: 'Y',
message:
`Target directory ${targetDir} is not empty.\n` +
`Remove existing files and continue?`
})
if (yes) {
emptyDir(root)
} else {
return
}
}
2. 確定項目模版
在創(chuàng)建好項目文件夾后,CLI 會獲取 --template 選項,即當(dāng)我們輸入這樣的命令時:
npm init @vitejs/app --template 文件夾名
如果 --template 選項不存在(即 undefined),則會詢問要選擇的項目模版:
let template = argv.t || argv.template
if (!template) {
const { t } = await prompt({
type: "select",
name: "t",
message: "Select a template:",
choices: TEMPLATES
})
template = stripColors(t)
}
由于,TEMPLATES 中只是定義了模版的類型,對比起 packages/create-app 目錄下的項目模版文件夾命名有點差別(缺少 template 前綴)。例如,此時 template 會等于 vue-ts,那么就需要給 template 拼接前綴和構(gòu)建完整目錄:
const templateDir = path.join(__dirname, `template-${template}`)
所以,現(xiàn)在 templateDir 就會等于當(dāng)前工作目錄 + template-vue-ts。
3. 寫入項目模版文件
確定完需要創(chuàng)建的項目的模版后,CLI 就會讀取用戶選擇的項目模版文件夾下的文件,然后將它們一一寫入此時創(chuàng)建的項目文件夾下:
可能有點繞,舉個例子,選擇的模版是
vue-ts,自己要創(chuàng)建的項目文件夾為vite-project,那么則是將create-app/template-vue-ts文件夾下的文件寫到vite-project文件夾下。
const files = fs.readdirSync(templateDir)
for (const file of files.filter((f) => f !== 'package.json')) {
write(file)
}
由于通過 fs.readdirSync 函數(shù)返回的是該文件夾下的文件名構(gòu)成的數(shù)組 ,所以這里會通過 for of 枚舉該數(shù)組,每次枚舉會調(diào)用 write 函數(shù)進(jìn)行文件的寫入。
注意此時會跳過
package.json文件,之后我會講解為什么需要跳過package.json文件。
而 write 函數(shù)則接受兩個參數(shù) file 和 content,其具備兩個能力:
對指定的文件
file寫入指定的內(nèi)容content,調(diào)用fs.writeFileSync函數(shù)來實現(xiàn)將內(nèi)容寫入文件復(fù)制模版文件夾下的文件到指定文件夾下,調(diào)用前面介紹的
copy函數(shù)來實現(xiàn)文件的復(fù)制
write 函數(shù)的定義:
const write = (file, content) => {
const targetPath = renameFiles[file]
? path.join(root, renameFiles[file])
: path.join(root, file)
if (content) {
fs.writeFileSync(targetPath, content)
} else {
copy(path.join(templateDir, file), targetPath)
}
}
并且,值得一提的是 targetPath 的獲取過程,會針對 file 構(gòu)建完整的文件路徑,并且兼容處理 _gitignore 文件的情況。
在寫入模版內(nèi)的這些文件后,CLI 就會處理 package.json 文件。之所以單獨處理 package.json 文件的原因是每個項目模版內(nèi)的 package.json 的 name 都是寫死的,而當(dāng)用戶創(chuàng)建項目后,name 都應(yīng)該為該項目的文件夾命名。這個過程對應(yīng)的代碼會是這樣:
const pkg = require(path.join(templateDir, `package.json`))
pkg.name = path.basename(root)
write('package.json', JSON.stringify(pkg, null, 2))
其中,
path.basename函數(shù)則用于獲取一個完整路徑的最后的文件夾名
最后,CLI 會輸出一些提示告訴你項目已經(jīng)創(chuàng)建結(jié)束,以及告訴你接下來啟動項目需要運行的命令:
console.log(`\nDone. Now run:\n`)
if (root !== cwd) {
console.log(` cd ${path.relative(cwd, root)}`)
}
console.log(` npm install (or \`yarn\`)`)
console.log(` npm run dev (or \`yarn dev\`)`)
console.log()
結(jié)語
雖然 Vite 的 create-app CLI 的實現(xiàn)僅僅只有 160 行的代碼,但是它也較為全面地考慮了創(chuàng)建項目的各種場景,并做對應(yīng)的兼容處理。簡而言之,十分小而美。所以,我相信大家經(jīng)過學(xué)習(xí) Vite 的 create-app CLI 的實現(xiàn),都應(yīng)該可以隨手甩出(實現(xiàn))一個 CLI 的代碼 ?? ~
點贊 ??、在看 ??
通過閱讀本篇文章,如果有收獲的話,可以點個贊和在看,這將會成為我持續(xù)分享的動力,感謝~
