如何用 Node.js 實現一個微型 CLI

林小帥發表於2020-04-06

什麼是 CLI

命令列介面(英語:command-line interface,縮寫:CLI)是在圖形使用者介面得到普及之前使用最為廣泛的使用者介面,它通常不支援滑鼠,使用者通過鍵盤輸入指令,計算機接收到指令後,予以執行。

實現一個微型 CLI Demo

Node.js 官方示例:微型 CLI


readline.createInterface

首先建立一個介面的例項,用於處理流資訊,例:輸入、輸出、提示字串、自動補全、歷史記錄等。

const rli = readline.createInterface({
  // 要監聽的可讀流。此選項是必需的。
  input: process.stdin,
  // 將逐行讀取資料寫入的可寫流。
  output: process.stdout
  // prompt // 要使用的提示字串。預設值: '> '。
  // historySize  //保留的最大歷史記錄行數。 要禁用歷史記錄,請將此值設定為 0。
  // completer // 用於 Tab 自動補全的可選函式。
});複製程式碼

建立完成後一個基本的 CLI 就已經有了。但是,僅僅是擁有了能夠處理輸入輸出等流資訊的能力而已。但是此時只能夠輸入,不能夠輸出,如果需要輸出能力則需要進一步進行完善。


on line

如果需要根據輸入流的資訊來反饋一些資訊顯示(輸出流),則需要使用返回的例項來監聽輸入流的內容,然後進行相應的處理,再返回流資訊用於輸出顯示。

// on 函式是為需要監聽的指令
// line 是能接受到當前命令列中的輸入流資訊,通過函式回撥的方式返回處理過的字串。
rli.on('line', line => {
  const line2str = line.trim()

  if (line2str === '嗨') { // 在命令列中輸入 “嗨” 並回車,CLI 則會輸出一個 “Hi!”
    console.log('Hi!')
  }
  if (line2str === '你好嗎') { // 沒錯是個英語老方了
    console.log('I\'m fine, thank you, and you?')
  }
})複製程式碼

通過監聽輸入的行資訊加以處理的邏輯,最後返回一個輸出資訊就實現了簡單的輸入輸出互動效果。

至此,一個大概的互動式的 CLI 核心部分就已經完成了。


啟動 CLI

如需使用 npm 命令的話則需要在 package.json 中 scripts 里加入你的命令名稱和指令碼位置。

"scripts": {
  // 其他命令……
  "cli": "node build/index.js" // 新增的 npm 命令,通過 npm 命令可以啟動 cli 指令碼。
},
// 這時候就可以通過 npm run cli 命令執行 CLI 了。複製程式碼

指令碼位置的話不能直接使用 ./filePath 或 /filePath 這樣的路徑會無法識別。需要使用 node filePath/xxx.js,這樣 node 就會將指令碼位置定位至當前專案開始尋找。


退出 CLI

當所有輸入完成後或者達到特定條件就可以退出 CLI 模式了。

if (line2str === '再見') {
  console.log('Bye!')
  process.exit(0); // 退出 CLI 模式
}複製程式碼

通過 process.exit 就可以實現退出當前的 CLI 模式返回到命令列中。

process 在接下來的內容中還會使用到,但是可以先看以下 NodeJs 對他的定義:

process 物件是一個全域性變數,它提供有關當前 Node.js 程式的資訊並對其進行控制。作為一個全域性變數,它始終可供 Node.js 應用程式使用,無需使用 require()。 它也可以使用 require() 顯式地訪問


實現一個簡單的問答式 CLI

什麼情況會需要用到 CLI 功能呢?我們可以假設一個這樣的場景:你在寫 Vue 的時候是不是會重複的新建 xxx.vue 檔案呢?這時候就可以使用 CLI 生成了。當然你會說:“我可以 copy & paste 啊!”。你當然可以,但是每次 copy & paste 完了你又要把裡面的程式碼手動刪除掉,不覺得很麻煩嗎?這時候一條命令加上簡單的輸入就可以生成乾淨的 xxx.vue 模板,甚至附帶的 xxx.js、xxx.css 也可以一併生成豈不是更有效率?

“我就喜歡 copy & paste!”。好了兄弟,你坐下,當我沒說。

下面我們繼續來分析一下實現這樣的一個 CLI 需要考慮哪些因素

問題

“一個問答式的 CLI 當然需要問題啦,這不是廢話嘛。”

話是沒錯,但是問題如何問當然也有一點點的講究。那就是問題一定會是封閉式的問題,封閉式問題因為提得比較具體且圈定的範圍固定,也就要求了回答者必須在這個範圍內給予明確的回答。

如果是開放式問題的話,那麼就會導致回答者(使用者)就有很大的自我發揮空間。因為問題過於開放籠統的話,那麼答案就沒有固定範圍了,這時候你的問題也就是無效提問了。


答案

這個不必過多解釋,既然是封閉式問題那就只有一些固定的選項,以及再照顧一下預設選項即可。

例如:程式碼檔案型別?【JS/ts/vue/css】

其中大寫/加粗一般為預設型別,即回車即選擇。


生成路徑

所有問題都回答、選擇完成後檔案的生成路徑,一般來說必須預設一個生成路徑以及提供自定義填寫符合資料夾規則的路徑。當然也可以將其做成半開閉形式,即有固定幾種選擇也可以自定義填寫符合資料夾規則的路徑。

例如:指定檔案路徑?【SRC/components/assets/yourpath】


生成的模板

通過答案得知需要生成的是哪種型別的檔案或者是某一類檔案或某一種檔案組合生成多個檔案。


反饋結果

當所有回答都完成時,需要及時反饋、顯示一些重要的步驟或資訊,讓使用者直觀的知道程式如何,以及最終結果。

上面將一些所考慮的因素都說完了,這裡就開始進入程式碼的實際編碼和設計部分了。這部分開始會以我自己的專案為例來說明。


問題 & 答案的設計

首先需要一個問題列表:

// 構建問題列表
const buildQuestion = () => {
  // 問題文字內容、提示、型別
  const questionText = [
    {question: '元件名稱?', tips: '', type: '[template]'},
    {question: '指定資料夾路徑?', tips: '(最大深度:4)', type: '[./views/]'},
    {question: '程式碼檔案型別?', tips: '', type: '[JS/ts]'},
    {question: '樣式表型別?', tips: '', type: '[CSS/less/sass/scss]'},
    // {question: '是否建立單獨的Api檔案?', tips: '', type: '[y/N]'} // 暫時未想好該如何處理 API 檔案的構建和寫入
  ];

  return questionText.map(item => {
    return { text: item.question, question: `\x1B[32m?\x1B[97m ${item.question}${item.tips}\x1B[32m${item.type}` }
  });
}複製程式碼

你可以設計你自己的問題列表來決定需要生成的是那些內容/程式碼。

以及一個無效回答的預設值和一個記錄回答物件:

// 無有效輸入時使用的預設內容
const defAnswer = {
  fileName: 'template',
  filePath: './views/',
  codeType: 'js',
  cssType: 'css',
  fileApi: false,
};

// 記錄問題的回答內容
const answer = {
  fileName: '',
  filePath: '',
  codeType: '',
  cssType: '',
  fileApi: false,
};複製程式碼

當使用者輸入了答案後我們就需要去檢查這個答案是否符合規則或者有效:

// 檢查是否符合規則,並處理答案預設選項
const checkAnswer = (step, content) => {
  // if (step > 1) { content = content.toLowerCase() }
  switch (step) {
    case 0:
      return answer.fileName = /^[a-zA-Z]{1,20}$/g.test(content)
        ? content : defAnswer.fileName;
    case 1:
      return answer.filePath = path.join(
        findChatIndex(
          path.join(defAnswer.filePath, content), '\\', 3),
        answer.fileName);
    case 2:
      content = content.toLowerCase()
      return answer.codeType = /^js|ts$/ig.test(content)
        ? content : 'js';
    case 3:
      content = content.toLowerCase()
      return answer.cssType = /^css|less|sass|scss$/ig.test(content)
        ? content : 'css';
    case 4:
      if (/^y|Y|n|N$/ig.test(content)) {
        const tempYN = content.toLowerCase()
        answer.fileApi = tempYN === 'y' ? true : false
        return content
      } else {
        answer.fileApi = false
        return 'N'
      }
  };
};複製程式碼


處理路徑

針對使用者自定輸入路徑時的處理,以及還要考慮不同作業系統路徑分割符不一致的情況。

// 拼接路徑
let findChatIndex = (str, chat, num) => {
  if (str.match(/\\/g).length <= num) return str;

  let chatIndex = str.indexOf(chat);
  for (let index = 0; index < num; index++) {
    let tempIndex = str.indexOf(chat, chatIndex + 1);
    if (tempIndex !== -1) {
      chatIndex = tempIndex
    }
  }
  return str.substr(0, chatIndex);
};複製程式碼


處理模板

這裡我就不貼程式碼了,因為我是使用了字串模板來作為模板的輸出內容,因為方便且字串模板可以儲存格式(縮排和換行)

參考這裡:template.js

到這就完了?不,到這只是完成了考慮因素的程式碼實現部分,還有一些是需要我們繼續完善的,例如輸入輸出的處理,顯示、反饋處理等


輸入輸出的設計

一般來說在進入一個獨立的 CLI 模式之前會對控制檯之前的內容進行一個簡單的清理:

readline.cursorTo(process.stdout, 0, 0); // 游標位置 0,0 即第一行第一位
readline.clearScreenDown(process.stdout); // 清理螢幕內容複製程式碼

這一步,簡單來說就有很好,清理之後沒有其他無關資訊。當然沒有的話也無傷大雅,屬於錦上添花的部分,看需要來把。

然後就是開始初始化第一個問題:

// 初始化第一個問題。
console.log(questionList[stepQuestion].question); // 問題一
// 設定輸入內容樣式
console.log('\x1B[36m'); // 控制檯字元樣式
複製程式碼

當然你也可以寫多一點東西,比如輸出一段簡介或者輸出一些其他自己喜歡的內容。

接下來就是比較重要的問題和答案處理部分了,監聽 line 輸入自然是不用多說。

// on 函式是為需要監聽的指令
// line 是能接受到當前命令列中的輸入流資訊,通過函式回撥的方式返回處理過的字串。
  const line2str = line.trim()

  // 將檢查處理後的答案資訊儲存用於後續命令列內容輸出
  let tempAnswer = checkAnswer(stepQuestion, line2str);

  // 將游標移入上一次步驟的位置,可以造成使用者已經選擇完成的效果。
  readline.cursorTo(process.stdout, 0, stepQuestion);
  // 清理之前的輸入內容。
  readline.clearScreenDown(process.stdout);
  // 選擇完成後輸出選擇後的結果資訊。
  console.log(`\x1B[32m?\x1B[97m ${questionList[stepQuestion].text}\x1B[36m%s`, tempAnswer);

  // 重置控制檯樣式。
  console.log('\x1B[0m');

// 如果當問題的步驟小於問題的長度時,則問題步長 + 1。
  if (stepQuestion < lenQuestion) {
    stepQuestion++;
    // 輸出下一個問題內容
    console.log(questionList[stepQuestion].question);
  } else { …… }複製程式碼

這裡可以看出我使用的是記錄步長的方式來處理什麼時候開始下一個問題的提問與答案的記錄。

之前也考慮過使用遞迴,但是最終實現起來處理提問與答案的記錄稍微麻煩,當然你也可以嘗試。

else 部分呢就是處理所有答案都回答完成的情況了:

  else {
    tpl.bulidTpl(answer)
    .then(() => {
      // 否則可以認為已經選擇完成
      // console.log('再見! %o', answer);
      console.log('再見!');
      console.log('\x1B[0m');
      process.exit(0);
    })
    .catch(err => {
      console.log(`\x1B[31m${err.message}`);
      console.log(`\x1B[31m${err.error}`);
      console.log('\x1B[0m');
      process.exit(0);
    });
  }複製程式碼

最後最後還是需要額外考慮一下意外觸發 CLI 任務中斷的情況:

rli.on('line', line => {})
.on('close', () => {
  console.log('\x1B[0m');
  console.log('【資訊】您已中斷模板建立任務,感謝您的使用再見!');
  process.exit(0);
});
複製程式碼


能看到到這裡呢也就說明了,這個 微型的問答式的 CLI 也就完成了。


如何用 Node.js 實現一個微型 CLI
最終效果


最後

當然這個只是一個簡單的 CLI 實現而已,關於這個 CLI 我自己也還有一些想法,因為這裡面還是有一些可以改進和優化的地方,例如現在是隻能生成 Vue 這一套單一的檔案模板,哪能不能生成其他框架的檔案模板呢?或者是可以通過配置檔案的方式生成的是一整套專案結構呢?又或者是程式碼模板能不能使用程式碼的方式而不是字串模板生成程式碼模板呢?

這些也都是我自己需要考慮和更深入學習瞭解的地方。

各位小夥伴可能也會有自己的想法可以創造很多有趣、好玩的 CLI。當然也祝各位小夥伴能夠學到有用的知識,然後把這些知識轉變成程式碼然後創造生產力工具為自己和公司、企業、社群增磚添瓦。


GitHub:Template Build




版權宣告:

本文版權屬於作者 林小帥,未經授權不得轉載及二次修改。

轉載或合作請在下方留言及聯絡方式。


相關文章