經過 Vite 的 create-app 學習如何實現一個簡易版 CLI

前言

前段時間,尤雨溪回答了一個廣大網友都好奇的一個問題:Vite 會不會取代 Vue CLI?javascript

答案是:是的!前端

那麼,你開始學 Vite 了嗎?用過 Vite 的同窗應該都熟悉,建立一個 Vite 的項目模版是經過 npm init @vitejs/app 的方式。而 npm init 命令是在 npm@6.1.0 開始支持的,實際上它是先幫你安裝 Vite 的 @vitejs/create-app 包(package),而後再執行 create-app 命令。vue

至於 @vitejs/create-app 則是在 Vite 項目的 packages/create-app 文件夾下。其總體的目錄結構:java

// 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(如下統稱爲 create-app CLI)具有的能力很少,目前只支持基礎模版的建立,因此所有代碼加起來只有 160 行,其總體的架構圖:node

能夠看出確實很是簡單,也所以 create-app CLI 是一個很值得入門學習如何實現簡易版 CLI 的例子react

那麼,接下來本文將會圍繞如下兩個部分帶着你們一塊兒經過 create-app CLI 來學習如何製做一個簡易版的 CLIgit

  • create-app 中使用到的庫(minimistkolorist
  • 逐步拆解、分析 create-app CLI 源碼

create-app CLI 中使用到的庫

create-app CLI 實現用到的庫(npm)確實頗有意思,既有咱們熟悉的 enquirer(用於命令行的提示),也有不熟悉的 minimistkolorist。 那麼,後面這二者又是拿來幹嗎的?下面,咱們就來了解一番~npm

minimist

minimist 是一個輕量級的用於解析命令行參數的工具。提及解析命令行的工具,我想你們很容易想到 commander。相比較 commander 而言,minimist 則以輕取勝!由於它只有 32.4 kB,commander 則有 142 kB,即也只有後者的約 1/5。json

那麼,下面咱們就來看一下 minimist 的基礎使用。前端工程化

例如,此時咱們在命令行中輸入:

node index.js my-project

那麼,在 index.js 文件中可使用 minimist 獲取到輸入的 myproject 參數:

var argv = require('minimist')(process.argv.slice(2));
console.log(argv._[0]); 
// 輸出 my-project

這裏的 argv 是一個對象,對象中 _ 屬性的值則是解析 node index.js 後的參數所造成的數組。

kolorist

kolorist 是一個輕量級的使命令行輸出帶有色彩的工具。而且,提及這類工具,我想你們很容易想到的就是 chalk。不過相比較 chalk 而言,二者包的大小差距並不明顯,前者爲 49.9 kB,後者爲 33.6 kB。不過 kolorist 可能較爲小衆,npm 的下載量大大不如後者 chalk,相應地 chalk 的 API 也較爲詳盡。

一樣的,下面咱們也來看一下 kolorist 的基礎使用。

例如,當此時應用發生異常的時候,須要打印出紅色的異常信息告知用戶發生異常,咱們可使用 kolorist 提供的 red 函數:

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 相關知識的同窗應該知道,咱們一般使用的命令是在 package.json 文件的 bin 中配置的。而 create-app CLI 對應的文件根目錄下該文件的 bin 配置會是這樣:

// pacakges/create-app/package.json
"bin": {
  "create-app": "index.js",
  "cva": "index.js"
}

能夠看到 create-app 命令則由這裏註冊生效,它指向的是當前目錄下的 index.js 文件。而且,值得一提的是這裏註冊了 2 個命令,也就是說咱們還可使用 cva 命令來建立基於 Vite 的項目模版(想不到吧 😲)。

create-app CLI 實現的核心就是在 index.js 文件。那麼,下面咱們來看一下 index.js 中代碼的實現~

基礎依賴引入

上面咱們也說起了 create-app CLI 引入了 minimistenquirekolorist 等依賴,因此首先是引入它們:

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')

其中,fspath 是 Node 內置的模塊,前者用於文件相關操做、後者用於文件路徑相關操做。接着就是引入 minimistenquirerkolorist,它們相關的介紹上面已經說起,這裏就不重複論述~

定義基礎模版(顏色)和文件

/packages/create-app 目錄中,咱們能夠看出 create-app CLI 爲咱們提供了 9 種項目基礎模版。而且,在命令行交互的時候,每一個模版之間的顏色各有不一樣,即 CLI 會使用 kolorist 提供的顏色函數來爲模版定義好對應的顏色

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 文件的特殊性,每一個項目模版下都是先建立的 _gitignore 文件,在後續建立項目的時候再替換掉該文件的命名(替換爲 .gitignore)。因此,CLI 會預先定義一個對象來存放須要重命名的文件

const renameFiles = {
  _gitignore: '.gitignore'
}

定義文件操做相關的工具函數

因爲建立項目的過程當中會涉及和文件相關的操做,因此 CLI 內部定義了 3 個工具函數:

copyDir 函數

copyDir 函數用於將某個文件夾 srcDir 中的文件複製到指定文件夾 destDir中。它會先調用 fs.mkdirSync 函數來建立制定的文件夾,而後枚舉從 srcDir 文件夾下獲取的文件名構成的數組,即 fs.readdirSync(srcDir)

其對應的代碼以下:

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 函數

copy 函數則用於複製文件或文件夾 src 到指定文件夾 dest。它會先獲取 src 的狀態 stat,若是 src 是文件夾的話,即 stat.isDirectory()true 時,則會調用上面介紹的 copyDir 函數來複制 src 文件夾下的文件到 dest 文件夾下。反之,src 是文件的話,則直接調用 fs.copyFileSync 函數複製 src 文件到 dest 文件夾下。

其對應的代碼以下:

function copy(src, dest) {
  const stat = fs.statSync(src)
  if (stat.isDirectory()) {
    copyDir(src, dest)
  } else {
    fs.copyFileSync(src, dest)
  }
}

emptyDir 函數

emptyDir 函數用於清空 dir 文件夾下的代碼。它會先判斷 dir 文件夾是否存在,存在則枚舉該問文件夾下的文件,構造該文件的路徑 abs,調用 fs.unlinkSync 函數來刪除該文件,而且當 abs 爲文件夾時,則會遞歸調用 emptyDir 函數刪除該文件夾下的文件,而後再調用 fs.rmdirSync 刪除該文件夾。

其對應的代碼以下:

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 實現核心函數

CLI 實現核心函數是 init,它負責使用前面咱們所說的那些函數、工具包來實現對應的功能。下面,咱們就來逐點分析 init 函數實現的過程:

1. 建立項目文件夾

一般,咱們可使用 create-app my-project 命令來指定要建立的項目文件夾,即在哪一個文件夾下:

let targetDir = argv._[0]
// cwd = process.cwd()
const root = path.join(cwd, targetDir)
console.log(`Scaffolding project in ${root}...`)

其中,argv._[0] 表明 create-app 後的第一個參數,root 是經過 path.join 函數構建的完整文件路徑。而後,在命令行中會輸出提示,告述你腳手架(Scaffolding)項目建立的文件路徑:

Scaffolding project in /Users/wjc/Documents/project/vite-project...

固然,有時候咱們並不想輸入在 create-app 後輸入項目文件夾,而只是輸入 create-app 命令。那麼,此時 tagertDir 是不存在的。CLI 則會使用 enquirer 包的 prompt 來在命令行中輸出詢問:

? project name: > vite-project

你能夠在這裏輸入項目文件夾名,又或者直接回車使用 CLI 給的默認項目文件夾名。這個過程對應的代碼:

if (!targetDir) {
  const { name } = await prompt({
    type: "input",
    name: "name",
    message: "Project name:",
    initial: "vite-project"
  })
  targetDir = name
}

接着,CLI 會判斷該文件夾是否存在當前的工做目錄(cwd)下,若是不存在則會使用 fs.mkdirSync 建立一個文件夾:

if (!fs.existsSync(root)) {
  fs.mkdirSync(root, { recursive: true })
}

反之,若是存在該文件夾,則會判斷此時文件夾下是否存在文件,即便用 fs.readdirSync(root) 獲取該文件夾下的文件:

const existing = fs.readdirSync(root)

這裏 existing 會是一個數組,若是此時數組長度不爲 0,則表示該文件夾下存在文件。那麼 CLI 則會詢問是否刪除該文件夾下的文件:

Target directory vite-project is not empty. 
Remove existing files and continue?(y/n): Y

你能夠選擇經過輸入 yn 來告知 CLI 是否要清空該目錄。而且,若是此時你輸入的是 y,即不清空該文件夾,那麼整個 CLI 的執行就會退出。這個過程對應的代碼:

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. 肯定項目模版

在建立好項目文件夾後,CLI 會獲取 --template 選項,即當咱們輸入這樣的命令時:

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 拼接前綴和構建完整目錄:

const templateDir = path.join(__dirname, `template-${template}`)

因此,如今 templateDir 就會等於當前工做目錄 + template-vue-ts

3. 寫入項目模版文件

肯定完須要建立的項目的模版後,CLI 就會讀取用戶選擇的項目模版文件夾下的文件,而後將它們一一寫入此時建立的項目文件夾下:

可能有點繞,舉個例子,選擇的模版是 vue-ts,本身要建立的項目文件夾爲 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 函數返回的是該文件夾下的文件名構成的數組 ,因此這裏會經過 for of 枚舉該數組,每次枚舉會調用 write 函數進行文件的寫入。

注意此時會跳過 package.json 文件,以後我會講解爲何須要跳過 package.json 文件。

write 函數則接受兩個參數 filecontent,其具有兩個能力:

  • 對指定的文件 file 寫入指定的內容 content,調用 fs.writeFileSync 函數來實現將內容寫入文件
  • 複製模版文件夾下的文件到指定文件夾下,調用前面介紹的 copy 函數來實現文件的複製

write 函數的定義:

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 構建完整的文件路徑,而且兼容處理 _gitignore 文件的狀況。

在寫入模版內的這些文件後,CLI 就會處理 package.json 文件。之因此單獨處理 package.json 文件的緣由是每一個項目模版內的 package.jsonname 都是寫死的,而當用戶建立項目後,name 都應該爲該項目的文件夾命名。這個過程對應的代碼會是這樣:

const pkg = require(path.join(templateDir, `package.json`))
pkg.name = path.basename(root)
write('package.json', JSON.stringify(pkg, null, 2))
其中, path.basename 函數則用於獲取一個完整路徑的最後的文件夾名

最後,CLI 會輸出一些提示告訴你項目已經建立結束,以及告訴你接下來啓動項目須要運行的命令:

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()

結語

雖然 Vite 的 create-app CLI 的實現僅僅只有 160 行的代碼,可是它也較爲全面地考慮了建立項目的各類場景,並作對應的兼容處理。簡而言之,十分小而美。因此,我相信你們通過學習 Vite 的 create-app CLI 的實現,都應該能夠隨手甩出(實現)一個 CLI 的代碼 😎 ~

點贊 👍

經過閱讀本篇文章,若是有收穫的話,能夠點個贊,這將會成爲我持續分享的動力,感謝~

我是五柳,喜歡創新、搗鼓源碼,專一於源碼(Vue 三、Vite)、前端工程化、跨端等技術學習和分享,歡迎關注個人 微信公衆號:Code center

相關文章
相關標籤/搜索