開發前端 CLI 腳手架思路解析

爲什麼要自己搞腳手架

在實際的開發過程中,我們經常用別人開發的腳手架,以節約搭建項目的時間。但是,當 npm 沒有自己中意的腳手架時,我們不得不自己動手,此時學會開發前端 CLI 腳手架的技能就顯得非常重要。搭建一個符合大衆化的腳手架能使自己在項目經驗上加個分哦!

什麼時候需要腳手架

其實很多時候從 0 開始搭建的項目都可以做成模板,而腳手架的主要核心功能就是利用模板來快速搭建一個完整的項目結構,後續我們只需在這上面進行開發就可以了。

入門需知

下面我們以創建 js 插件項目的腳手架來加深我們對前端腳手架的認知。
在此之前,我們先把需要用到的依賴庫熟悉一下(點擊對應庫名跳轉到對應文檔):

功能策劃

我們先用思維導圖來策劃一下我們的腳手架需要有哪些主要命令:init(初始化模板)、template(下載模板)、mirror(切換鏡像)、upgrade(檢查更新),相關導圖如下:

開始動手

新建一個名爲 js-plugin-cli 的文件夾後打開,執行 npm init -y 快速初始化一個 package.json,然後根據下面創建對應的文件結構:

js-plugin-cli
├─ .gitignore
├─ .npmignore
├─ .prettierrc
├─ LICENSE
├─ README.md
├─ bin
│  └─ index.js
├─ lib
│  ├─ init.js
│  ├─ config.js
│  ├─ download.js
│  ├─ mirror.js
│  └─ update.js
└─ package.json
複製代碼

其中 .gitignore、.npmignore、.prettierrc、LICENSE、README.md 是額外附屬文件(非必須),但這裏推薦創建好它們,相關內容根據自己習慣設定就行。在項目裏打開終端,先把需要的依賴裝上,後續可以直接調用。

yarn add -D chalk commander download fs-extra handlebars inquirer log-symbols ora update-notifier
複製代碼

註冊指令

當我們要運行調試腳手架時,通常執行 node ./bin/index.js 命令,但我還是習慣使用註冊對應的指令,像 vue init webpack demo 的 vue 就是腳手架指令,其他命令行也要由它開頭。打開 package.json 文件,先註冊下指令:

"main""./bin/index.js",
"bin"{
  "js-plugin-cli""./bin/index.js"
}

main 中指向入口文件 bin/index.js,而 bin 下的 js-plugin-cli 就是我們註冊的指令,你可以設置你自己想要的名稱(儘量簡潔)。

萬物皆 - v

我們先編寫基礎代碼,讓 js-plugin-cli -v 這個命令能夠在終端打印出來。
打開 bin/index.js 文件,編寫以下代碼 :

#!/usr/bin/env node

// 請求 commander 庫
const program = require('commander')

// 從 package.json 文件中請求 version 字段的值,-v和--version是參數
program.version(require('../package.json').version, '-v, --version')

// 解析命令行參數
program.parse(process.argv)

其中 #!/usr/bin/env node (固定第一行)必加,主要是讓系統看到這一行的時候,會沿着對應路徑查找 node 並執行。調試階段時,爲了保證 js-plugin-cli 指令可用,我們需要在項目下執行 npm link(不需要指令時用 npm unlink 斷開),然後打開終端,輸入以下命令並回車:

js-plugin-cli -v

此時,應該返回版本號 1.0.0,如圖:接下來我們將開始寫邏輯代碼,爲了維護方便,我們將在 lib 文件夾下分模塊編寫,然後在 bin/index.js 引用。

upgrade 檢查更新

打開 lib/update.js 文件,編寫以下代碼 :

// 引用 update-notifier 庫,用於檢查更新
const updateNotifier = require('update-notifier')
// 引用 chalk 庫,用於控制檯字符樣式
const chalk = require('chalk')
// 引入 package.json 文件,用於 update-notifier 庫讀取相關信息
const pkg = require('../package.json')

// updateNotifier 是 update-notifier 的方法,其他方法可到 npmjs 查看
const notifier = updateNotifier({
  // 從 package.json 獲取 name 和 version 進行查詢
  pkg,
  // 設定檢查更新週期,默認爲 1000 * 60 * 60 * 24(1 天)
  // 這裏設定爲 1000 毫秒(1秒)
  updateCheckInterval: 1000,
})

function updateChk() {
  // 當檢測到版本時,notifier.update 會返回 Object
  // 此時可以用 notifier.update.latest 獲取最新版本號
  if (notifier.update) {
    console.log(`New version available: ${chalk.cyan(notifier.update.latest)}, it's recommended that you update before using.`)
    notifier.notify()
  } else {
    console.log('No new version is available.')
  }
}

// 將上面的 updateChk() 方法導出
module.exports = updateChk

這裏需要說明兩點:updateCheckInterval 默認是 1 天,也就意味着今天檢測更新了一次,下一次能進行檢測更新的時間點應該爲明天同這個時間點之後,否則週期內檢測更新都會轉到 No new version is available.
舉個栗子:我今天 10 點的時候檢查更新了一次,提示有新版本可用,然後我下午 4 點再檢查一次,此時將不會再提示有新版本可用,只能等到明天 10 點過後再檢測更新纔會重新提示新版本可用。因此,將 updateCheckInterval 設置爲 1000 毫秒,就能使每次檢測更新保持最新狀態。
另外,update-notifier 檢測更新機制是通過 package.json 文件的 name 字段值和 version 字段值來進行校驗:它通過 name 字段值從 npmjs 獲取庫的最新版本號,然後再跟本地庫的 version 字段值進行比對,如果本地庫的版本號低於 npmjs 上最新版本號,則會有相關的更新提示。
當然,此時我們還需要把 upgrade 命令聲明一下,打開 bin/index.js 文件,在合適的位置添加以下代碼:

// 請求 lib/update.js
const updateChk = require('../lib/update')

// upgrade 檢測更新
program
  // 聲明的命令
  .command('upgrade')
  // 描述信息,在幫助信息時顯示
  .description("Check the js-plugin-cli version.")
  .action(() ={
    // 執行 lib/update.js 裏面的操作
    updateChk()
  })

添加後的代碼應該如圖所示:記得把 program.parse(process.argv) 放到最後就行。添加好代碼後,打開控制檯,輸入命令 js-plugin-cli upgrade 查看效果:爲了測試效果,我將本地庫 js-plugin-cli 下 package.json 的 name 改爲 vuepress-creatorversion 默認爲 1.0.0,而 npmjs 上 vuepress-creator 腳手架最新版本爲 2.x,因此會有更新的提示。

mirror 切換鏡像鏈接

我們通常會把模板放 Github 上,但是在國內從 Github 下載模板不是一般的慢,所以我考慮將模板放 Vercel 上,但是爲了避免一些地區的用戶因網絡問題不能正常下載模板的問題,我們需要將模板鏈接變成可定義的,然後用戶就可以自定義模板鏈接,更改爲他們自己覺得穩定的鏡像託管平臺上,甚至還可以把模板下載下來,放到他們自己服務器上維護。
爲了能夠記錄切換後的鏡像鏈接,我們需要在本地創建 config.json 文件來保存相關信息,當然不是由我們手動創建,而是讓腳手架來創建,整個邏輯過程如下:所以我們還需要在 lib 文件夾下創建 config.js 文件,用於生成默認配置文件。
打開 lib/config.js 文件,添加以下代碼:

// 請求 fs-extra 庫
const fse = require('fs-extra')

const path = require('path')

// 聲明配置文件內容
const jsonConfig = {
  "name""js-plugin-cli",
  "mirror""https://zpfz.vercel.app/download/files/frontend/tpl/js-plugin-cli/"
}

// 拼接 config.json 完整路徑
const configPath = path.resolve(__dirname, '../config.json')

async function defConfig() {
  try {
    // 利用 fs-extra 封裝的方法,將 jsonConfig 內容保存成 json 文件
    await fse.outputJson(configPath, jsonConfig)
  } catch (err) {
    console.error(err)
    process.exit()
  }
}

// 將上面的 defConfig() 方法導出
module.exports = defConfig

這裏需要注意的是,我們不要再直接去用內置的 fs 庫,推薦使用增強庫 fs-extrafs-extra 除了封裝原有基礎文件操作方法外,還有方便的 json 文件讀寫方法。
打開 lib/mirror.js 文件,添加以下代碼:

// 請求 log-symbols 庫
const symbols = require('log-symbols')
// 請求 fs-extra 庫
const fse = require('fs-extra')

const path = require('path')

// 請求 config.js 文件
const defConfig = require('./config')
// 拼接 config.json 完整路徑
const cfgPath = path.resolve(__dirname, '../config.json')

async function setMirror(link) {
  // 判斷 config.json 文件是否存在
  const exists = await fse.pathExists(cfgPath)
  if (exists) {
    // 存在時直接寫入配置
    mirrorAction(link)
  } else {
    // 不存在時先初始化配置,然後再寫入配置
    await defConfig()
    mirrorAction(link)
  }
}

async function mirrorAction(link) {
  try {
    // 讀取 config.json 文件
    const jsonConfig = await fse.readJson(cfgPath)
    // 將傳進來的參數 link 寫入 config.json 文件
    jsonConfig.mirror = link
    // 再寫入 config.json 文件
    await fse.writeJson(cfgPath, jsonConfig)
    // 等待寫入後再提示配置成功
    console.log(symbols.success, 'Set the mirror successful.')
  } catch (err) {
    // 如果出錯,提示報錯信息
    console.log(symbols.error, chalk.red(`Set the mirror failed. ${err}`))
    process.exit()
  }
}

// 將上面的 setMirror(link) 方法導出
module.exports = setMirror

需要注意的是 async 和 await,這裏用的是 Async/Await 的寫法,其他相關寫法可參照 fs-extra[10] 。async 一般默認放函數前面,而 await 看情況添加,舉個例子:

...
const jsonConfig = await fse.readJson(cfgPath)
jsonConfig.mirror = link
await fse.writeJson(cfgPath, jsonConfig)
console.log(symbols.success, 'Set the mirror successful.')
...

我們需要等待 fs-extra 讀取完,纔可以進行下一步,如果不等待,就會繼續執行 jsonConfig.mirror = link 語句,就會導致傳入的 json 結構發生變化。再比如 await fse.writeJson(cfgPath, jsonConfig) 這句,如果去掉 await,將意味着還在寫入 json 數據(假設寫入數據需要花 1 分鐘)時,就已經繼續執行下一個語句,也就是提示 Set the mirror successful.,但實際上寫入文件不會那麼久,就算去掉 await,也不能明顯看出先後執行關係。老規矩,我們還需要把 mirror 命令聲明一下,打開 bin/index.js 文件,在合適的位置添加以下代碼:

// 請求 lib/mirror.js
const setMirror = require('../lib/mirror')

// mirror 切換鏡像鏈接
program
  .command('mirror <template_mirror>')
  .description("Set the template mirror.")
  .action((tplMirror) ={
    setMirror(tplMirror)
  })

打開控制檯,輸入命令 js-plugin-cli mirror 你的鏡像鏈接 查看效果:此時,在項目下應該已經生成 config.json 文件,裏面相關內容應該爲:

{
  "name""js-plugin-cli",
  "mirror""https://zpfz.vercel.app/download/files/frontend/tpl/js-plugin-cli/"
}

download 下載 / 更新模板

網絡上很多教程在談及腳手架下載模板時都會選擇 download-git-repo 庫,但是這裏我選擇 download 庫,因爲利用它可以實現更自由的下載方式,畢竟 download-git-repo 庫主要還是針對 Github 等平臺的下載,而 download 庫可以下載任何鏈接的資源,甚至還有強大的解壓功能(無需再安裝其他解壓庫)。
在此之前,我們得先明白 lib/download.js 需要執行哪些邏輯:下載 / 更新模板應屬於強制機制,也就是說,不管用戶本地是否有模板存在,lib/download.js 都會下載並覆蓋原有文件,以保持模板的最新狀態,相關邏輯圖示如下:打開 lib/download.js 文件,添加以下代碼:

// 請求 download 庫,用於下載模板
const download = require('download')
// 請求 ora 庫,用於實現等待動畫
const ora = require('ora')
// 請求 chalk 庫,用於實現控制檯字符樣式
const chalk = require('chalk')
// 請求 fs-extra 庫,用於文件操作
const fse = require('fs-extra')
const path = require('path')

// 請求 config.js 文件
const defConfig = require('./config')

// 拼接 config.json 完整路徑
const cfgPath = path.resolve(__dirname, '../config.json')
// 拼接 template 模板文件夾完整路徑
const tplPath = path.resolve(__dirname, '../template')

async function dlTemplate() {
  // 參考上方 mirror.js 主代碼註釋
  const exists = await fse.pathExists(cfgPath)
  if (exists) {
    // 這裏記得加 await,在 init.js 調用時使用 async/await 生效
    await dlAction()
  } else {
    await defConfig()
    // 同上
    await dlAction()
  }
}

async function dlAction() {
  // 清空模板文件夾的相關內容,用法見 fs-extra 的 README.md
  try {
    await fse.remove(tplPath)
  } catch (err) {
    console.error(err)
    process.exit()
  }

  // 讀取配置,用於獲取鏡像鏈接
  const jsonConfig = await fse.readJson(cfgPath)
  // Spinner 初始設置
  const dlSpinner = ora(chalk.cyan('Downloading template...'))

  // 開始執行等待動畫
  dlSpinner.start()
  try {
    // 下載模板後解壓
    await download(jsonConfig.mirror + 'template.zip', path.resolve(__dirname, '../template/'){
      extract: true
    });
  } catch (err) {
    // 下載失敗時提示
    dlSpinner.text = chalk.red(`Download template failed. ${err}`)
    // 終止等待動畫並顯示 X 標誌
    dlSpinner.fail()
    process.exit()
  }
  // 下載成功時提示
  dlSpinner.text = 'Download template successful.'
  // 終止等待動畫並顯示 ✔ 標誌
  dlSpinner.succeed()
}

// 將上面的 dlTemplate() 方法導出
module.exports = dlTemplate

我們先用 fse.remove() 清空模板文件夾的內容(不考慮模板文件夾存在與否,因爲文件夾不存在不會報錯),然後執行等待動畫並請求下載,模板文件名固定爲 template.zipdownload 語句裏的 extract:true 表示開啓解壓。
上述代碼有兩處加了 process.exit(),意味着將強制進程儘快退出(有點類似 return 的作用,只不過 process.exit() 結束的是整個進程),哪怕還有未完全完成的異步操作。
就比如說第二個 process.exit() 吧,當你鏡像鏈接處於 404 或者其他狀態,它會返回你相應的報錯信息並退出進程,就不會繼續執行下面 dlSpinner.text 語句了。
我們還需要把 template 命令聲明一下,打開 bin/index.js 文件,在合適的位置添加以下代碼:

// 請求 lib/download.js
const dlTemplate = require('../lib/download')

// template 下載/更新模板
program
  .command('template')
  .description("Download template from mirror.")
  .action(() ={
    dlTemplate()
  })

打開控制檯,輸入命令 js-plugin-cli template 查看效果:上圖直接報錯返回,提示 404 Not Found,那是因爲我還沒把模板文件上傳到服務器上。等把模板上傳後就能正確顯示了。

init 初始化項目

接下來是咱們最主要的 init 命令,init 初始化項目涉及的邏輯比其他模板相對較多,所以放在最後解析。
初始化項目的命令是 js-plugin-cli init 項目名,所以我們需要把 項目名 作爲文件夾的名稱,也是項目內 package.json 的 name 名稱(只能小寫,所以需要轉換)。由於模板是用於開發 js 插件,也就需要拋出全局函數名稱(比如 import Antd from 'ant-design-vue' 的 Antd),所以我們還需要把模板的全局函數名稱拋給用戶來定義,通過控制檯之間的交互來實現。完成交互後,腳手架會把用戶輸入的內容替換到模板內容內,整個完整的邏輯導圖如下:打開 lib/init.js 文件,添加以下代碼:

// 請求 fs-extra 庫,用於文件操作
const fse = require('fs-extra')
// 請求 ora 庫,用於初始化項目時等待動畫
const ora = require('ora')
// 請求 chalk 庫
const chalk = require('chalk')
// 請求 log-symbols 庫
const symbols = require('log-symbols')
// 請求 inquirer 庫,用於控制檯交互
const inquirer = require('inquirer')
// 請求 handlebars 庫,用於替換模板字符
const handlebars = require('handlebars')

const path = require('path')

// 請求 download.js 文件,模板不在本地時執行該操作
const dlTemplate = require('./download')

async function initProject(projectName) {
  try {
    const exists = await fse.pathExists(projectName)
    if (exists) {
      // 項目重名時提醒用戶
      console.log(symbols.error, chalk.red('The project already exists.'))
    } else {
      // 執行控制檯交互
      inquirer
        .prompt([{
          type: 'input', // 類型,其他類型看官方文檔
          name: 'name', // 名稱,用來索引當前 name 的值
          message: 'Set a global name for javascript plugin?',
          default: 'Default', // 默認值,用戶不輸入時用此值
        }])
        .then(async (answers) ={
          // Spinner 初始設置
          const initSpinner = ora(chalk.cyan('Initializing project...'))
          // 開始執行等待動畫
          initSpinner.start()

          // 拼接 template 文件夾路徑
          const templatePath = path.resolve(__dirname, '../template/')
          // 返回 Node.js 進程的當前工作目錄
          const processPath = process.cwd()
          // 把項目名轉小寫
          const LCProjectName = projectName.toLowerCase()
          // 拼接項目完整路徑
          const targetPath = `${processPath}/${LCProjectName}`

          // 先判斷模板路徑是否存在
          const exists = await fse.pathExists(templatePath)
          if (!exists) {
            // 不存在時,就先等待下載模板,下載完再執行下面的語句
            await dlTemplate()
          }

          // 等待複製好模板文件到對應路徑去
          try {
            await fse.copy(templatePath, targetPath)
          } catch (err) {
            console.log(symbols.error, chalk.red(`Copy template failed. ${err}`))
            process.exit()
          }

          // 把要替換的模板字符準備好
          const multiMeta = {
            project_name: LCProjectName,
            global_name: answers.name
          }
          // 把要替換的文件準備好
          const multiFiles = [
            `${targetPath}/package.json`,
            `${targetPath}/gulpfile.js`,
            `${targetPath}/test/index.html`,
            `${targetPath}/src/index.js`
          ]

          // 用條件循環把模板字符替換到文件去
          for (var i = 0; i < multiFiles.length; i++) {
            // 這裏記得 try {} catch {} 哦,以便出錯時可以終止掉 Spinner
            try {
              // 等待讀取文件
              const multiFilesContent = await fse.readFile(multiFiles[i]'utf8')
              // 等待替換文件,handlebars.compile(原文件內容)(模板字符)
              const multiFilesResult = await handlebars.compile(multiFilesContent)(multiMeta)
              // 等待輸出文件
              await fse.outputFile(multiFiles[i], multiFilesResult)
            } catch (err) {
              // 如果出錯,Spinner 就改變文字信息
              initSpinner.text = chalk.red(`Initialize project failed. ${err}`)
              // 終止等待動畫並顯示 X 標誌
              initSpinner.fail()
              // 退出進程
              process.exit()
            }
          }

          // 如果成功,Spinner 就改變文字信息
          initSpinner.text = 'Initialize project successful.'
          // 終止等待動畫並顯示 ✔ 標誌
          initSpinner.succeed()
          console.log(`
            To get started:

              cd ${chalk.yellow(LCProjectName)}
              ${chalk.yellow('npm install')} or ${chalk.yellow('yarn install')}
              ${chalk.yellow('npm run dev')} or ${chalk.yellow('yarn run dev')}
          `)
        })
        .catch((error) ={
          if (error.isTtyError) {
            console.log(symbols.error, chalk.red("Prompt couldn't be rendered in the current environment."))
          } else {
            console.log(symbols.error, chalk.red(error))
          }
        })
    }
  } catch (err) {
    console.error(err)
    process.exit()
  }
}

// 將上面的 initProject(projectName) 方法導出
module.exports = initProject

lib/init.js 的代碼相對較長,建議先熟悉上述的邏輯示意圖,瞭解這麼寫的意圖後就能明白上述的代碼啦!抽主要的片段解析:
inquirer 取值說明
inquirer.prompt 中的字段 name 類似 key,當你需要獲取該值時,應以 answers.key對應值 形式獲取(answers 命名取決於 .then(answers => {})),例:

inquirer.prompt([{
  type: 'input', // 類型,其他類型看官方文檔
  name: 'theme', // 名稱,用來索引當前 name 的值
  message: 'Pick a theme?',
  default: 'Default', // 默認值,用戶不輸入時用此值
}]).then(answers ={})

上述要獲取對應值應該爲 answers.themehandlebars 模板字符設置說明
我們事先需要把模板文件內要修改的字符串改成 {{ 定義名稱 }} 形式,然後才能用 handlebars.compile 進行替換,爲了保證代碼可讀性,我們把模板字符整成 { key:value } 形式,然後 key 對應定義名稱,value 對應要替換的模板字符,例:

const multiMeta = {
  project_name: LCProjectName,
  global_name: answers.name
}

上述代碼意味着模板文件內要修改的字符串改成 {{ project_name }} 或者 {{ global_name }} 形式,當被替換時,將改成後面對應的模板字符。下圖是模板文件:接下來我們把 init 命令聲明一下,打開 bin/index.js 文件,在合適的位置添加以下代碼:

// 請求 lib/init.js
const initProject = require('../lib/init')

// init 初始化項目
program
  .name('js-plugin-cli')
  .usage('<commands> [options]')
  .command('init <project_name>')
  .description('Create a javascript plugin project.')
  .action(project ={
    initProject(project)
  })

打開控制檯,輸入命令 js-plugin-cli init 你的項目名稱 查看效果:這樣就完成整個腳手架的搭建了~ 然後可以發佈到 npm,以全局安裝方式進行安裝(記得 npm unlink 解除連接哦)。

寫在最最最後

這篇文章花了幾天時間(含寫腳手架 demo 的時間)編輯的,時間比較匆趕,若在語句上表達不夠明白或者錯誤,歡迎掘友指出哦~
最後附上項目源碼:js-plugin-cli[11] ,腳手架已經發布到 npm,歡迎小夥伴試用哦!

參考資料

[1]

chalk: https://www.npmjs.com/package/chalk

[2]

commander: https://www.npmjs.com/package/commander

[3]

download: https://www.npmjs.com/package/download

[4]

fs-extra: https://www.npmjs.com/package/fs-extra

[5]

handlebars: https://www.npmjs.com/package/handlebars

[6]

inquirer: https://www.npmjs.com/package/inquirer

[7]

log-symbols: https://www.npmjs.com/package/log-symbols

[8]

ora: https://www.npmjs.com/package/ora

[9]

update-notifier: https://www.npmjs.com/package/update-notifier

[10]

fs-extra: https://www.npmjs.com/package/fs-extra

[11]

js-plugin-cli: https://github.com/zpfz/js-plugin-cli/

作者:左撇峯子,

原文鏈接:https://juejin.im/post/6879265583205089287

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/sDX6R4ZeNOXYcR68JZYvbA