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

答案是:是的!
那么,你開(kāi)始學(xué) Vite 了嗎?用過(guò) Vite 的同學(xué)應(yīng)該都熟悉,創(chuàng)建一個(gè) Vite 的項(xiàng)目模版是通過(guò) npm init @vitejs/app 的方式。而 npm init 命令是在 [email protected] 開(kāi)始支持的,實(shí)際上它是先幫你安裝 Vite 的 @vitejs/create-app 包(package),然后再執(zhí)行 create-app 命令。
至于 @vitejs/create-app 則是在 Vite 項(xiàng)目的 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)稱(chēng)為 create-app CLI)具備的能力不多,目前只支持基礎(chǔ)模版的創(chuàng)建,所以全部代碼加起來(lái)只有 160 行,其整體的架構(gòu)圖:

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

minimist 是一個(gè)輕量級(jí)的用于解析命令行參數(shù)的工具。說(shuō)起解析命令行的工具,我想大家很容易想到 commander。相比較 commander 而言,minimist 則以輕取勝!因?yàn)樗挥?32.4 kB,commander 則有 142 kB,即也只有后者的約 1/5。
那么,下面我們就來(lái)看一下 minimist 的基礎(chǔ)使用。
例如,此時(shí)我們?cè)诿钚兄休斎耄?/p>
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 是一個(gè)對(duì)象,對(duì)象中 _ 屬性的值則是解析 node index.js 后的參數(shù)所形成的數(shù)組。
kolorist

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