从零开发cli脚手架
這是一篇篇幅很長的文章,分為四個部分,每個部分都可以單獨食用。
1.使用npm發布插件包
npm 為我們打開了連接整個 JavaScript 天才世界的一扇大門。它是世界上最大的軟件注冊表,每星期大約有 30 億次的下載量,包含超過 600000 個 包(package) (即,代碼模塊)。來自各大洲的開源軟件開發者使用 npm 互相分享和借鑒。包的結構使您能夠輕松跟蹤依賴項和版本。
npm 由三個獨立的部分組成:?
我們可以從npm上獲取大量優秀第三方包,當然我們也可以上傳自己的包。
下面簡單介紹如何使用npm創建和發布自己的包。
?
創建npm賬號
要上傳我們自己的包,首先要有npm賬號,如果你沒有npm賬號,可以去[注冊頁](https://www.npmjs.com/signup)注冊并登錄自己的賬號。輸入用戶名、郵件、密碼即可創建。<br />在命令行輸入`npm login`,根據提示輸入賬號、密碼,即可在命令行登錄我們的賬號。后續可以通過此賬號上傳我們開發的npm包。<br />在命令行輸入`npm whoami`可以測試自己是否登錄成功,登錄成功則會打印出你的用戶名。<br />包的分類
npm包分為public和private兩種類型。
scopes
在此之前,我們先了解這么一個概念——scopes(中文意思是作用域)。我們在注冊npm賬號和創建組織時,你將被授予一個與你的用戶或組織名稱匹配的范圍,即你獲得了一個適用范圍(scope),這個范圍是你的用戶名或者創建的組織名。你可以將此范圍用作相關包的命名空間。如你有一個package名叫mypackage,你的用戶名為myusername,則你可以把這個package放到你的域里`@myusername/mypackage ?`<br />作用:<br />避免與別人的包重名,發生沖突。<br />限制該包的訪問權限。<br />pubilc package
作為npm用戶或組織成員,你可以創建和發布公共包,任何人都可以下載并在他們自己的項目中使用。?
**unscoped:**的公共包存在于全球公共注冊表(http://registry.npmjs.org)的命名空間中,并且可以在package.json文件中僅以包的名稱來引用:package-name。
**scoped:**的公共包屬于某個用戶或組織,在package.json文件中作為依賴關系包含時,必須在前面加上用戶或組織名稱。
@username/package-name
@org-name/package-name
private package
要使用私有軟件包,你必須使用npm 2.7.0或更高版本,并且有一個付費用戶或組織賬戶。
通過npm私有包,你可以使用npm注冊表來托管只對你和選定的合作者可見的代碼,允許你在項目中管理和使用私有代碼與公共代碼。
?
私有包總是有一個范圍,而范圍內的包默認為私有。
用戶范圍的私有包只能由你和被你授予讀或讀/寫權限的合作者訪問。
組織范圍的私有包只能由被授予讀或讀/寫權限的團隊訪問。
?
創建package.json文件
發布到注冊表的軟件包必須包含一個package.json文件。package.json文件會表明你的包名、版本、作用、列出你的項目所依賴的軟件包、關鍵詞等等。這份文件是你需要知道的關于package.json文件中需要的所有內容。它必須是實際的JSON,而不僅僅是一個JavaScript對象字面。所以package.json對npm包來說至關重要。我們使用動手創建demo來學習。
打開命令行,在指定目錄創建一個demo項目
mkdir zzmath
cd zzmath
創建package.json文件
npm init,根據提示輸入你的包的信息。
使用編輯器打開package.json文件。
這就是創建package.json文件后會有的一些信息。
name:包名
version: 版本號
description:包的描述
scripts: 腳本
main:指定包的入口文件
keywords: 包的關鍵詞
author: 作者
license: ISC
更多字段請參考官方介紹
README.md
為了幫助其他人在npm上找到你的軟件包,并在他們的項目中擁有使用你的代碼的良好體驗,建議在你的包的根目錄中包括一個README文件。你的 README 文件可以包括安裝、配置和使用你的軟件包中的代碼的說明,以及任何其他用戶可能會發現的有用信息。README文件將顯示在包頁面上。
語義版本管理
為了保持JavaScript生態系統的健康、可靠和安全,每當你對自己擁有的npm包進行重大更新時,建議在package.json文件中發布該包的新版本,并在其上標注更新的版本號,以遵循語義版本規范。遵循語義版本規范有助于依賴你的代碼的其他開發人員了解特定版本的變化程度,并在必要時調整自己的代碼。
軟件包版本從1.0.0開始,遵循以下規則。
語義版本控制的概念很簡單:所有的版本都有 3 個數字:x.y.z。?
當發布新的版本時:
當進行不兼容的 API 更改時,則升級主版本。
當以向后兼容的方式添加功能時,則升級次版本。
當進行向后兼容的缺陷修復時,則升級補丁版本。
npm 設置了一些規則,可用于在 package.json 文件中選擇要將軟件包更新到的版本(當運行 npm update 時)。規則使用了這些符號:
?
^: 只會執行不更改最左邊非零數字的更新。 如果寫入的是 ^0.13.0,則當運行 npm update 時,可以更新到 0.13.1、0.13.2 等,但不能更新到 0.14.0 或更高版本。 如果寫入的是 ^1.13.0,則當運行 npm update 時,可以更新到 1.13.1、1.14.0 等,但不能更新到 2.0.0 或更高版本。
~: 如果寫入的是 ?0.13.0,則當運行 npm update 時,會更新到補丁版本:即 0.13.1 可以,但 0.14.0 不可以。
>: 接受高于指定版本的任何版本。
>=: 接受等于或高于指定版本的任何版本。
<=: 接受等于或低于指定版本的任何版本。
<: 接受低于指定版本的任何版本。
=: 接受確切的版本。
-: 接受一定范圍的版本。例如:2.1.0 - 2.6.2。
||: 組合集合。例如 < 2.1 || > 2.6。
可以合并其中的一些符號,例如 1.0.0 || >=1.1.0 <1.2.0,即使用 1.0.0 或從 1.1.0 開始但低于 1.2.0 的版本。
還有其他的規則:
無符號: 僅接受指定的特定版本(例如 1.2.1)。
latest: 使用可用的最新版本。
?
發布和更新包
繼續使用前面的demo,我們來開發一個簡單的工具包,并發布到npm上。
新建index.js文件,封裝兩個簡單的數學計算方法。
發布包
在命令行執行npm publish,即可發布我們的第一個npm包

到npm官網搜索即可找到我們剛發布的npm包
使用發布的包
新建一個test-demo小項目,并執行安裝命令,安裝我們的包


新建test.js,導入我們的包
在控制臺 執行命令 node test.js,查看輸出。
zhuxiaodong@zhuxiaodongdeMacBook-Pro test-demo % node test.js 22 4更新包
前面說過,我們的包需要嚴格遵守語言版本管理,我們更新包的時候需要更新包的版本號。我們可以通過修改package.json文件里version來更改包的版本。或者通過執行命令
npm version patch:更新補丁版本
npm version major:更新主版本
npm version minor:更新次版本
?
我們修改一下我們的包,然后更改版本號,執行npm publish即可更新包。

為軟件包添加dist-tags
(dist-tags)是人類可讀的標簽,你可以用它來組織和標記你發布的軟件包的不同版本。dist-tags是對語義版本的補充。除了比語義版本號更容易讓人讀懂之外,標簽還允許發布者更有效地發布他們的軟件包。
比如我們可以為包打上beta的標簽,告訴人們此包還處于beta測試中,請謹慎使用。
:::warning
:由于dist-tags與語義版本共享一個命名空間,因此要避免dist-tags與現有版本號發生沖突。建議避免使用以數字或字母 "v "開頭的dist-tag。
:::
使用命令npm publish --tag <tag> 為包打標簽。
?
也可以使用以下命令為指定的版本打上標簽
npm dist-tag add <package-name>@<version> [<tag>]
?
我們為我們的包打上beta標簽,在打標簽之前需要先更新版本號。


使用npm發布插件包就講到這里,更多知識請查看官方文檔。
2.commander.js的簡單使用
commander.js是完整的 node.js 命令行解決方案。可以為你的腳手架工具定義執行命令、選項,處理傳入的參數以及自動生成幫助信息等。基本上是現在腳手架工具必裝的插件。
安裝
npm install commander
?
聲明program變量
#!/usr/bin/env node const { program } = require('commander');//orconst { Command } = require('commander'); const program = new Command();首先體驗一個小例子:

根據前兩部操作完成之后大概是這樣。
測試我們的命令:
?
在控制臺輸入npm i -g ../easy-cli 安裝我們的cli,mac用戶可能需要使用sudo權限sudo npm i -g ../easy-cli安裝。這樣就在全局和我們的cli項目文件建立了一個連接。
使用npm list -g可以查看我們在全局安裝的所有包。
可以看到我們的easy-cli已經安裝到了全局。
只需建立一次連接,在后續修改cli后,不需要再次執行。
?
在不需要調試的時候可以使用npm unlink -g easy-cli刪除連接。
?
在命令行執行我們的命令以及選項 easy -c?
輸出:
hello world!
在命令行輸入 easy double 2執行這個命令
輸出:
4
?
在命令行輸入 easy double 2 -c
輸出:
4
hello world!
大家可以嘗試執行以下命令,看看會打印什么
?
帶著好奇和疑問,開始學習commander.js
?
選項(option)
option是我們能夠通過命令執行的選項。例如:在安裝npm包時,將包安裝在dev依賴里。
npm i <package-name> -save-dev,--save-dev就是定義的選項。
?
Commander 使用.option() 方法來定義選項,同時可以附加選項的簡介。每個選項可以定義一個短選項名稱(-后面接單個字符)和一個長選項名稱(–后面接一個或多個單詞),使用逗號、空格或|分隔。
program.option('-s, --small', 'small pizza size') // 短名稱 長名稱 選項簡介選項參數
有兩種最常用的選項,一類是 boolean 型選項,選項無需配置參數(上面體驗的那種),另一類選項則可以設置參數(使用尖括號聲明在該選項后,如–expect )。如果在命令行中不指定具體的選項及參數,則會被定義為undefined。帶參數的又分為必填參數和可選參數
選項可以通過在Command對象上調用.opts()方法來獲取
通過program.parse(arguments)方法處理參數,沒有被使用的選項會存放在program.args數組中。該方法的參數是可選的,默認值為process.argv。
必填參數
使用尖括號定義的參數是必填的。
#!/usr/bin/env node const { Command } = require("commander"); const program = new Command();program.option("-c, --cons <input>", "console input"); //參數program.parse()const options = program.opts();if(options.cons) console.log(options.cons);執行命令 easy -c "hey,how are you?"
輸出:
hey,how are you?
?
執行命令 easy -c
輸出:
error: option '-c, --cons ’ argument missing
?
可選參數
使用方括號定義的參數是可選的。選項在不帶參數時可用作boolean選項,在帶有參數時則從參數中得到值。
#!/usr/bin/env node const { Command } = require("commander"); const program = new Command();program.option("-c, --cons [input]", "console input or console hello world!"); //參數program.parse();const options = program.opts();if (options.cons===true){console.log("hello world!"); }else{console.log(options.cons); }執行命令 easy -c "hey,how are you?"
輸出:
hey,how are you?
?
執行命令 easy -c
輸出:
hello world!
?
選項默認值
可以通過在選項解釋后面添加第四個值來為選項添加默認值。
:::warning
必填選項的默認值只在輸入命令時 不輸入必填選項 時使用,如果輸入必填選項,但是沒有輸入必填選項的參數則會報錯。
:::
執行命令 easy -c "hey,how are you?"
輸出:
hey,how are you?
cheese: blue
?
執行命令 easy -c
輸出:
Hola
cheese: blue
?
執行命令 easy -c -ch
輸出:
error: option '-ch, --cheese ’ argument missing
?
變長參數選項
定義選項時,可以通過使用…來設置參數為可變長參數。在命令行中,用戶可以輸入多個參數,解析后會以數組形式存儲在對應屬性字段中。在輸入下一個選項前(-或–開頭),用戶輸入的指令均會被視作變長參數。與普通參數一樣的是,可以通過–標記當前命令的結束。
執行命令 easy -n 1 2 3 --letter a b c
輸出:
執行命令 easy -n 1 2 3 --letter a b c -- ext
輸出:
版本選項
version方法可以設置版本,其默認選項為-V和--version,設置了版本后,命令行會輸出當前的版本號。
#!/usr/bin/env node const { Command } = require("commander"); const program = new Command();program.version("0.0.1");//通常使用package.json文件里的version //program.version(require("../package.json").version)//可以在version方法里再傳遞一些參數(長選項名稱,描述信息),用法與option方法類似。 //program.version('0.0.1', '-v, --vers', 'output the current version');program.parse()執行命令:easy -V
輸出:0.0.1
幫助選項
commander.js會自動生成幫助信息。默認幫助選項幫助信息是-h,--help。
命令行執行easy -h
輸出:
我們也可以自定義幫助信息
#!/usr/bin/env node const { Command } = require("commander"); const program = new Command();program.option('-f, --foo', 'enable some foo');program.addHelpText('after', ` Example call:$ custom-help --help`);program.parse(process.argv);命令行執行easy -h
輸出:
更多幫助信息的設置請參考官方文檔。
?
命令(command)
命令就是我們使用腳本執行的方法,如我們使用npm安裝包的時候使用 npm i <package-name> -s或者npm install <package-name> -s命令執行安裝方法,i 和 install就是方法名,i是方法的簡稱,install是方法的全稱,-s就是命令的選項。將包安裝在dependencies。
?
通過.command()或.addCommand()可以配置命令,有兩種實現方式:
.command()的第一個參數可以配置命令名稱及命令參數,參數支持必選(尖括號表示)、可選(方括號表示)及變長參數(點號表示,如果使用,只能是最后一個參數)。跟選項類似。尖括號(例如)意味著必選,而方括號(例如[optional])則代表可選。可以向.description()方法傳遞第二個參數,從而在幫助中展示命令參數的信息。該參數是一個包含了 “命令參數名稱:命令參數描述” 鍵值對的對象。
?
命令行執行easy clone a b
輸出:
也可以將命令寫成單獨的可執行文件
?
當.command()帶有描述參數時,就意味著使用獨立的可執行文件作為子命令。 Commander 將會嘗試在入口腳本(例如 ./examples/pm)的目錄中搜索program-command形式的可執行文件,例如easy-start, pm-install。通過配置選項executableFile可以自定義名字。
#!/usr/bin/env node //easy.js const { Command } = require("commander"); const program = new Command();program .command("start", "每隔1秒輸出當前時間秒數")// 需要有 easy-start.js可執行文件 .command("start2","每隔2秒輸出當前時間秒數", { executableFile: 'milliseconds' })//需要有milliseconds.js 可執行文件program.parse(process.argv);
新建easy-start.js文件
新建milliseconds.js文件
#!/usr/bin/env nodesetInterval(() => {console.log(new Date().getSeconds()); }, 2000);
分別執行命令:easy start、easy start2,查看輸出。
變長參數
在參數名后加上...來聲明可變參數,且只有最后一個參數支持這種用法。
program.command('rmdir <dirs...>').action(function (dirs) {dirs.forEach((dir) => {console.log('rmdir %s', dir);});});命令行輸入easy rmdir 1 2 34 45
輸出:
rmdir 1
rmdir 2
rmdir 34
rmdir 45
?
單獨的可執行文件接收參數
#!/usr/bin/env node //easy.js const { Command } = require("commander"); const program = new Command();program .command("start <length> <number...>", "遍歷number")// 需要有 easy-start.js可執行文件program.parse(process.argv); #!/usr/bin/env node //easy-start.js const { Command } = require("commander"); const program = new Command();program.parse();console.log(program.args)命令行輸入easy start 5 12345 4 23
輸出:[ ‘5’, ‘12345’, ‘4’, ‘23’ ]
?
需要通過制定參數位置來取參數。 "5"是length,剩下的都是number。
?
監聽
監聽命令和選項可以執行自定義函數。
#!/usr/bin/env node const { Command } = require("commander"); const program = new Command();program.option("-c, --cons", "console hello world!");program.command("clone <source> [destination]")//定義命令名稱clone 必填參數source 可選參數destination.description("clone a repository into a newly created directory") //命令的描述.action((source, destination) => {//命令處理函數console.log(`repository has been cloned from ${source} to ${destination}`);});program.on("option:cons", function () {console.log("輸入選項 --cons") });program.on("command:*", function (operands) {console.error(`error: unknown command '${operands[0]}'`);const availableCommands = program.commands.map((cmd) => cmd.name());console.log("availableCommands:", availableCommands); });program.parse(); const options = program.opts();if (options.cons) console.log("hello world!");命令行輸入easy clone a b -c
輸出:
命令行輸入easy pull a b -c
輸出:
示例
const { Command } = require('commander'); const program = new Command();program.version('0.0.1').option('-c, --config <path>', 'set config path', './deploy.conf');program.command('setup [env]').description('run setup commands for all envs').option('-s, --setup_mode <mode>', 'Which setup mode to use', 'normal')//命令的選項.action((env, options) => {env = env || 'all';console.log('read config from %s', program.opts().config);console.log('setup for %s env(s) with %s mode', env, options.setup_mode);});program.command('exec <script>').alias('ex')//定義別名.description('execute the given remote cmd').option('-e, --exec_mode <mode>', 'Which exec mode to use', 'fast').action((script, options) => {console.log('read config from %s', program.opts().config);console.log('exec "%s" using %s mode and config %s', script, options.exec_mode, program.opts().config);}).addHelpText('after', ` Examples:$ deploy exec sequential$ deploy exec async`);program.parse(process.argv);
更多知識請前往官方文檔。
3.inquirer.js的使用
inquirer.js是一個命令行交互式工具,通過設置問題、選項,在執行命令時在控制臺展示這些問題,并在用戶做出回答后接收答案。
安裝
npm install inquirer
Demo小體驗
#!/usr/bin/env node const inquirer = require("inquirer");const requireLetterAndNumber = (value) => {if (/\w/.test(value) && /\d/.test(value)) {return true;}return "Password need to have at least a letter and a number"; };const questions = [{type: "expand",message: "Conflict on `file.js`",name: "q_1",choices: [{key: "y",name: "Overwrite",value: "overwrite",},{key: "a",name: "Overwrite this one and all next",value: "overwrite_all",},{key: "d",name: "Show diff",value: "diff",},new inquirer.Separator("."),{key: "x",name: "Abort",value: "abort",},],},{type: "input",name: "q_2",message: "Question with filtering and validating Text",validate: async () => {await new Promise((r) => setTimeout(r, 3000));return true;},filter: async (answer) => {await new Promise((r) => setTimeout(r, 3000));return `filtered${answer}`;},filteringText: "Filtering your answer...",validatingText: "validating what you wrote",},{type: "input",name: "q_3",message: "Question without filtering and validating Text",validate: async () => {await new Promise((r) => setTimeout(r, 3000));return true;},filter: async (answer) => {await new Promise((r) => setTimeout(r, 3000));return `filtered${answer}`;},},{type: "input",name: "q_4",message: "what‘s your last name",default() {return "Doe";},},{type: "list",name: "q_5",message: "What do you want to do?",choices: ["Order a pizza","Make a reservation",new inquirer.Separator(),"Ask for opening hours",{name: "Contact support",disabled: "Unavailable at this time",},"Talk to the receptionist",],},{type: "list",name: "q_6",message: "What size do you need?",choices: ["Jumbo", "Large", "Standard", "Medium"],filter(val) {return val.toLowerCase();},},{type: "password",name: "q_9",message: "Enter a password",validate: requireLetterAndNumber,},{type: "password",name: "q_10",message: "Enter a masked password",mask: "*",validate: requireLetterAndNumber,},{type: "confirm",name: "q_11",message: "password is ok?",default: true,},{type: "input",name: "q_12",message: "How many do you need?",validate(value) {const valid = !isNaN(parseFloat(value));return valid || "Please enter a number";},filter: Number,},{type: "rawlist",name: "q_13",message: "What do you want to do?",choices: ["Order a pizza","Make a reservation",new inquirer.Separator(),"Ask opening hours","Talk to the receptionist",],}, ];inquirer.prompt(questions).then((answers) => {console.log(JSON.stringify(answers, null, " ")); });在控制臺執行 node demo.js 查看效果。
?
通過分析上面的代碼和控制臺的交互效果,我們看到我們定義了一個對象questions數組,每一個對象有type``name``message,有些有validate``dafault``filter``choices等,將這個questions數組作為參數傳給inquirer.prompt()方法,返回一個promise,可以得到用戶在控制臺輸入的答案。
Methods
inquirer.prompt(questions, answers) -> promise
?
通過inquirer.prompt()方法啟動我們的提示。
questions:(數組)包含問題對象(使用反應式接口,你也可以傳遞一個Rx.Observable實例
answers (對象)包含已經回答的問題的值。默認為{}。
返回一個Promise
?
問題(question)
question有以下屬性:
回答(answers)
answers:包含用戶在每個提示中的答案的key/value。
?
key:question對象的name屬性
?
value可能的值:
confirm:Boolean
input :String 用戶輸入(如果定義了過濾器,則進行過濾)
number:Number 用戶輸入(如果定義了過濾器,則進行過濾)
rawlist, list:Number選定的選擇值(如果沒有指定值則為名稱)
分隔符(Separator)
分隔符可以被添加到任何choices數組中。
eg:demo中的 q_5 ,默認是“-”,可以傳入指定字符作為分隔符。
?
完整demo
#!/usr/bin/env node const inquirer = require("inquirer");const choices = Array.apply(0, new Array(26)).map((x, y) =>String.fromCharCode(y + 65) ); choices.push("Multiline option 1\n super cool feature \n more lines"); choices.push("Multiline option 2\n super cool feature \n more lines"); choices.push("Multiline option 3\n super cool feature \n more lines"); choices.push("Multiline option 4\n super cool feature \n more lines"); choices.push("Multiline option 5\n super cool feature \n more lines"); choices.push(new inquirer.Separator()); choices.push("Multiline option \n super cool feature"); choices.push({name: "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium.",value: "foo",short: "The long option", });const requireLetterAndNumber = (value) => {if (/\w/.test(value) && /\d/.test(value)) {return true;}return "Password need to have at least a letter and a number"; };const questions = [{type: "expand",message: "Conflict on `file.js`",name: "q_1",choices: [{key: "y",name: "Overwrite",value: "overwrite",},{key: "a",name: "Overwrite this one and all next",value: "overwrite_all",},{key: "d",name: "Show diff",value: "diff",},new inquirer.Separator("."),{key: "x",name: "Abort",value: "abort",},],},{type: "input",name: "q_2",message: "Question with filtering and validating Text",validate: async () => {await new Promise((r) => setTimeout(r, 3000));return true;},filter: async (answer) => {await new Promise((r) => setTimeout(r, 3000));return `filtered${answer}`;},filteringText: "Filtering your answer...",validatingText: "validating what you wrote",},{type: "input",name: "q_3",message: "Question without filtering and validating Text",validate: async () => {await new Promise((r) => setTimeout(r, 3000));return true;},filter: async (answer) => {await new Promise((r) => setTimeout(r, 3000));return `filtered${answer}`;},},{type: "input",name: "q_4",message: "what‘s your last name",default() {return "Doe";},},{type: "list",name: "q_5",message: "What do you want to do?",choices: ["Order a pizza","Make a reservation",new inquirer.Separator(),"Ask for opening hours",{name: "Contact support",disabled: "Unavailable at this time",},"Talk to the receptionist",],},{type: "list",name: "q_6",message: "What size do you need?",choices: ["Jumbo", "Large", "Standard", "Medium"],filter(val) {return val.toLowerCase();},},{type: "list",name: "q_7",message: "What's your favorite letter?",loop: false,choices,},{type: "checkbox",name: "q_8",message: "Select the letter contained in your name:",choices},{type: "password",name: "q_9",message: "Enter a password",validate: requireLetterAndNumber,},{type: "password",name: "q_10",message: "Enter a masked password",mask: "*",validate: requireLetterAndNumber,},{type: "confirm",name: "q_11",message: "password is ok?",default: true,},{type: "input",name: "q_12",message: "How many do you need?",validate(value) {const valid = !isNaN(parseFloat(value));return valid || "Please enter a number";},filter: Number,},{type: "rawlist",name: "q_13",message: "What do you want to do?",choices: ["Order a pizza","Make a reservation",new inquirer.Separator(),"Ask opening hours","Talk to the receptionist",],},{type: "rawlist",name: "q_14",message: "What size do you need",choices: ["Jumbo", "Large", "Standard", "Medium", "Small", "Micro"],filter(val) {return val.toLowerCase();},},{type: "confirm",name: "q_15",message: "Do you like bacon?",},{type: "input",name: "favorite",message: "Bacon lover, what is your favorite type of bacon?",when(answers) {return answers.q_15;},}, ];inquirer.prompt(questions).then((answers) => {console.log(JSON.stringify(answers, null, " "));inquirer.prompt({ //嵌套type: "list",name: "beverage",message: "And your favorite beverage?",choices: ["Pepsi", "Coke", "7up", "Mountain Dew", "Red Bull"],}); });可以注釋問題對象一個個執行對應的問題,分析,不難學會對應的type的用法。
更多知識請查詢官方文檔,對于大多數場景,上述demo的知識點就夠用了。
?
4.開發cli腳手架
我們創建vue項目的時候通常使用官方腳手架工具vue-cli 或者使用是自己創建模版項目,里面封裝好了各種配置,通過克隆模版項目進行創建項目。首先對于vue-cli 創建的項目來說,我們每次都需要重新配置各種依賴。比如vue.config.js、axios、vuex、vue-router、ui組件庫等等,重復工作很多。對于使用之前的模版創建的項目,不夠靈活,簡單項目不需要vuex或vue-router,不需要ui組件庫,對于移動端項目,需要vconsole插件、postcss-pxtorem插件。不同的項目需要的插件不同,使用模版項目不夠靈活,只能單獨配置或者抽成多個模版項目。所以我們來搭建一個比較靈活的腳手架easy-vue-cli。在vue-cli的基礎上,再封裝一層,通過命令+詢問的方式安裝插件、配置項目。
easy-vue-cli選項有是否創建vue.config.js文件、是否使用Gzip壓縮,是否安裝axios,是否安裝Vconsole,是否使用UI組件庫。項目里使用了很多node.js的api,這里不詳細講述,請自行查詢文檔。其實在學會上面的插件的使用后,主要還是要求我們的node.js的功力,發揮自己的想象,實現一些厲害功能。
主要思路是使用vue create命令創建初始化項目,在初始化項目創建好后,詢問用戶是否安裝插件、生成配置。
?
1.初始化項目
創建easy-vue-cli項目文件夾。使用命令npm init根據提示創建package.json文件。創建bin文件夾和lib文件夾,分別存放我們的命令文件和主要文件。項目還使用了oraloading 插件。可以先安裝。
?
2.創建主要項目文件
在bin文件夾新建easy.js文件。文件內容如下:
#!/usr/bin/env node const { Command } = require("commander"); const program = new Command();program.version(require("../package.json").version);program.command("create <app-name>") //定義腳手架的命令是 create.description("create a vue app and config some dependencies").action((name) => { //獲取項目名,調用創建項目的函數const options = program.opts(); require("../lib/create")(name, options); });program.parse(process.argv); 在`lib`文件夾新建`create.js`文件。文件內容如下: #!/usr/bin/env nodeconst spawn = require("child_process").spawn //spawn 使用給定的 command 和 args 中的命令行參數衍生新進程 const fs = require("fs") const inquirer = require("inquirer"); const generate = require("./generate")/*** @description: delete project*/ function removeDir(filePath) {const stat = fs.statSync(filePath);if(stat.isFile()){fs.unlinkSync(filePath);}else{const files = fs.readdirSync(filePath);console.log(files)if (files.length === 0) {fs.rmdirSync(filePath);} else {let tempFiles = 0;files.forEach((file) => {tempFiles++;const nextFilePath = `${filePath}/${file}`;removeDir(nextFilePath);});//刪除母文件夾下的所有子空文件夾后,將母文件夾也刪除if (tempFiles === files.length) {fs.rmdirSync(filePath);}}} }const choices = [{name: "vue.config.js",checked:true},{name: "Axios",},{name: "Gzip",},{name: "Vconsole",} ];const questions = [{type: "checkbox",name: "configs",message: "Select the config in your app",choices,},{type: "confirm",name: "UI_Components",message: "Do you want to install the ui component library?",},{type: "list",name: "ui",message: "Select the config in your app",choices: ["Element UI", "Ant Design", "Vant"],when(answers) {return answers.UI_Components;},}, ];async function create(projectName,options) {const cmd = spawn("vue", ["create", projectName],{ stdio:["inherit", "inherit", "pipe"]}) //使用vue create 命令創建初始化項目cmd.on("close", function(code, signal){if(code===0){//vue初始化項目創建成功,調用inquirer方法,詢問用戶console.log("vue初始化項目創建成功");inquirer.prompt(questions).then(async (answers) => {if(answers.configs.includes("vue.config.js")){await generate.generateVueConfigJS(projectName);//調用生成vue.config.js的方法}if (answers.configs.includes("Gzip")) {//調用 安裝并配置compression-webpack-plugin 的方法await generate.installCompressionWebpackPlugin(projectName);}});}})process.on("SIGINT", function () {//監聽進程主動關閉,刪掉未創建完的項目console.log("Got SIGINT. Press Control-D/Control-C to exit.");removeDir(projectName);});}module.exports = (...args) => {return create(...args).catch((err) => {error(err);process.exit(1);}); };在lib文件夾下新建generate.js文件。文件內容如下:
const fs = require("fs"); const ora = require("ora"); const spawn = require("child_process").spawn;/*** @description: 生成vue.config.js文件*/ function generateVueConfigJS(projectName) {return new Promise((resolve, reject) => {const spinner = ora({text: "Generating vue.config.js",color: "yellow",});let file = fs.createReadStream(`${__dirname}/vueConfig.js`, {encoding: "utf8",});let out = fs.createWriteStream(`${process.cwd()}/${projectName}/vue.config.js`,{encoding: "utf8",});file.on("data", function (dataChunk) {out.write(dataChunk, function () {spinner.start();});});out.on("open", function (fd) {});file.on("end", function () {out.end("", function () {setTimeout(() => {spinner.succeed("Successfully generated vue.config.js");}, 500);resolve(true);});});}); }//安裝并配置compression-webpack-plugin function installCompressionWebpackPlugin(projectName) {return new Promise((resolve, reject) => {const spinner = ora({text: "install compression-webpack-plugin",color: "yellow",}).start();const cmd = spawn("npm",["install", "compression-webpack-plugin@4.0.0"],{stdio: "pipe",cwd: `${process.cwd()}/${projectName}`,});cmd.on("close", function (code, signal) {if (code === 0) {const content = `const path = require("path")const CompressionWebpackPlugin = require("compression-webpack-plugin")const isProd = process.env.NODE_ENV === "production"function resolve(dir) {return path.join(__dirname, dir)}`;const content2 = `configureWebpack: (config) => {if (isProd) {// 生產環境config.plugins.push(new CompressionWebpackPlugin({// 正在匹配需要壓縮的文件后綴test: /\.(js|css|svg|woff|ttf|json|html)$/,// 大于10kb的會壓縮threshold: 10240,deleteOriginalAssets: false// 其余配置查看compression-webpack-plugin}))}},`;//往固定的行寫入數據const data = fs.readFileSync(`${process.cwd()}/${projectName}/vue.config.js`, "utf8").split("\n");data.splice(0, 0, content);fs.writeFileSync(`${process.cwd()}/${projectName}/vue.config.js`,data.join("\n"),"utf8");const data2 = fs.readFileSync(`${process.cwd()}/${projectName}/vue.config.js`, "utf8").split("\n");data2.splice(data2.length - 45, 0, content2);fs.writeFileSync(`${process.cwd()}/${projectName}/vue.config.js`,data2.join("\n"),"utf8");spinner.succeed("install compression-webpack-plugin success");resolve();} else {spinner.warn("install compression-webpack-plugin error");reject(`install compression-webpack-plugin error`);}});});}module.exports = {generateVueConfigJS,installCompressionWebpackPlugin, }; 分別是生成`vue.config.js`文件和安裝并配置`compression-webpack-plugin`插件的方法。<br />?新建vueConfig.js文件,存放我們的vue.config.js文件的模版文件,大家可以靈活調整。
module.exports = {publicPath: "./", //配置根路徑outputDir: "dist", //構建輸出目錄assetsDir: "assets", //靜態資源目錄(js\css\img)lintOnSave: true, //是否開啟eslintproductionSourceMap: false, // 生產環境是否生成 sourceMap 文件devServer: {},css: {// 是否使用css分離插件 ExtractTextPlugin//如果需要css熱更新就設置為false,打包時候要改為trueextract: false,// 開啟 CSS source maps?sourceMap: process.env.NODE_ENV !== "production",// css預設器配置項// loaderOptions: {// sass: {// prependData: `@import "@/styles/variables.scss";`,// },// },},chainWebpack: (config) => {config.resolve.symlinks(true);(config.entry.app = ["babel-polyfill", "./src/main.js"]),// 別名配置config.resolve.alias.set("@", resolve("src")).set("@utils", resolve("src/utils")).set("@api", resolve("src/api")).set("@components", resolve("src/components")).set("@pic", resolve("src/assets/imgs"));config.resolve.extensions.clear().merge([".js", ".vue", ".json"]);config.optimization.splitChunks({chunks: "all", // 控制webpack選擇哪些代碼塊用于分割(其他類型代碼塊按默認方式打包)。有3個可選的值:initial、async和all。minSize: 30000, // 形成一個新代碼塊最小的體積maxSize: 0,minChunks: 2, // 在分割之前,這個代碼塊最小應該被引用的次數(默認配置的策略是不需要多次引用也可以被分割)maxAsyncRequests: 5, // 按需加載的代碼塊,最大數量應該小于或者等于5maxInitialRequests: 3, // 初始加載的代碼塊,最大數量應該小于或等于3automaticNameDelimiter: "~",name: true,cacheGroups: {libs: {name: "chunk-libs",test: /[\\/]node_modules[\\/]/,priority: 10,chunks: "initial", // only package third parties that are initially dependent},commons: {name: "chunk-commons",test: resolve("src/components"), // can customize your rulesminChunks: 3, // minimum common numberpriority: 5,reuseExistingChunk: true,},},});config.plugins.delete("prefetch-index");config.plugins.delete("preload-index");}, };3.配置命令
在package.json文件中添加bin屬性
"bin": {"easy": "bin/easy.js"},4.測試
,使用npm安裝我們的腳手架。npm i ../腳手架路徑/?
使用easy create test命令創建項目,會先調用vue-cli創建初始化項目

項目創建成功,查看test項目,會發現項目生成了vue.config.js文件,安裝并配置好了compression-webpack-plugin
?
5.結束
這里easy-vue-cli只實現了生成vue.config.js文件和安裝并配置compression-webpack-plugin插件,安裝axios和安裝ui組件并沒有實現,可以參考上述方法自行實現。篇幅太長了。大家可以優化代碼、使用遠程的模版文件等等。這里只是給出一個思路。有改進的地方、想法歡迎提出討論、修改。謝謝。
最后
篇幅太長了,謝謝大家能夠耐心看完,我還是小菜雞一枚,歡迎提出改進、錯誤的地方,如果文章內容對你有一點作用,希望可以給一個👍, 沖沖沖!!!。
總結
以上是生活随笔為你收集整理的从零开发cli脚手架的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 项目一:中国计算机设计大赛赛事统计
- 下一篇: 特别手持弹幕 技术支持 app's su