前端CLI腳手架思路解析-從0到1搭建 | 掘金技術徵文-雙節特別篇

爲何要本身搞腳手架

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

何時須要腳手架

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

入門需知

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

  • chalk (控制檯字符樣式)
  • commander (實現NodeJS命令行)
  • download (實現文件遠程下載)
  • fs-extra (加強的基礎文件操做庫)
  • handlebars (實現模板字符替換)
  • inquirer (實現命令行之間的交互)
  • log-symbols (爲各類日誌級別提供着色符號)
  • ora (優雅終端Spinner等待動畫)
  • update-notifier (npm在線檢查更新)

功能策劃

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

開始動手

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

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 是額外附屬文件(非必須),但這裏推薦建立好它們,相關內容根據本身習慣設定就行。node

在項目裏打開終端,先把須要的依賴裝上,後續能夠直接調用。webpack

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

註冊指令

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

"main": "./bin/index.js",
  "bin": {
    "js-plugin-cli": "./bin/index.js"
  },
複製代碼

main 中指向入口文件 bin/index.js,而 bin 下的 js-plugin-cli 就是咱們註冊的指令,你能夠設置你本身想要的名稱(儘可能簡潔)。github

萬物皆-v

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

#!/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-clipackage.jsonname 改成 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
複製代碼

須要注意的是 asyncawait,這裏用的是 Async/Await 的寫法,其餘相關寫法可參照 fs-extraasync 通常默認放函數前面,而 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.jsonname 名稱(只能小寫,因此須要轉換)。因爲模板是用於開發 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.theme

handlebars模板字符設置說明
咱們事先須要把模板文件內要修改的字符串改爲 {{ 定義名稱 }} 形式,而後才能用 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 ,腳手架已經發布到npm,歡迎小夥伴試用哦!時空門:js-plugin-cli

🏆 掘金技術徵文|雙節特別篇

相關文章
相關標籤/搜索