從0開始手撕cli

爲何要手擼一個本身的前端腳手架?前端

在公司工做中你會發現有如下一系列的問題!vue

  • 業務類型多
  • 屢次造輪子,項目升級等問題
  • 公司代碼規範,沒法統一

在本身開發cli前,能夠先看看優秀的cli(vue-cli 、 create-react-app 、 dva-cli 等)是如何實現的。node

1.必備模塊

咱們先從你們衆所周知的vue-cli入手,先來看看他用了哪些npm包來實現的react

  • commander        :參數解析 --help其實就藉助了他~
  • inquirer        :交互式命令行工具,有他就能夠實現命令行的選擇功能
  • download-git-repo       :在git中下載模板
  • chalk       :粉筆幫咱們在控制檯中畫出各類各樣的顏色
  • metalsmith       :讀取全部文件,實現模板渲染
  • consolidate       :統一模板引擎

先想下要實現的功能:ios

根據模板初始化項目 zhu-cli create project-namegit

初始化配置文件 zhu-cli config set repo repo-namegithub

2.工程建立

廢話很少說咱們開始建立項目,編寫本身的腳手架vue-cli

npm init -y # 初始化package.json
npm install eslint husky --save-dev # eslint是負責代碼校驗工做,husky提供了git鉤子功能 
npx eslint --init # 初始化eslint配置文件
複製代碼

2.1 建立文件夾

├── bin
│    └── www // 全局命令執行的根文件 
├── package.json
├── src
│    ├── main.js // 入口文件
│    └── utils // 存放工具方法 
│── .huskyrc // git hook
│── .eslintrc.json // 代碼規範校驗
複製代碼

2.2 eslint配置

配置package.json 校驗src文件夾下的代碼npm

"scripts": {
   "lint":"eslint src"
}
複製代碼

2.3 配置husky

當使用git提交前校驗代碼是否符合規範json

{
"hooks": {
    "pre-commit": "npm run lint"
  }
}
複製代碼

2.4 連接全局包

設置在命令下執行zhu-cli時調用bin目錄下的www文件

"bin": {
    "zhu-cli": "./bin/www"
}
複製代碼

www文件中使用main做爲入口文件,而且以node環境執行此文件

#! /usr/bin/env node
require('../src/main.js');
複製代碼

連接包到全局下使用

npm link
複製代碼

咱們已經能夠成功的在命令行中使用 zhu-cli 命令,而且能夠執行main.js文件!

3.解析命令行參數

commander:The complete solution for node.js command-line interfaces

先吹一波commander,commander能夠自動生成help,解析選項參數!

像這樣 vue-cli --help!

像這樣 vue-cli create <project-namne>

3.1 使用commander

npm install commander
複製代碼

main.js就是咱們的入口文件

const program = require('commander');
program.version('0.0.1')
.parse(process.argv); // process.argv就是用戶在命令行中傳入的參數
複製代碼

執行zhu-cli --help 是否是已經有一提示了!

這個版本號應該使用的是當前cli項目的版本號,咱們須要動態獲取,而且爲了方便咱們將常量所有放到 util下的 constants 文件夾中

const { name, version } = require('../../package.json');
module.exports = { 
    name,
    version
};
複製代碼

這樣咱們就能夠動態獲取版本號

const program = require('commander');
const { version } = require('./utils/constants');
program.version(version)
  .parse(process.argv);
  
複製代碼

3.2 配置指令命令

根據咱們想要實現的功能配置執行動做,遍歷產生對應的命令

const actionsMap = { 
     create: { // 建立模板
        description: 'create project',
        alias: 'cr',
        examples: [
          'zhu-cli create <template-name>',
        ],
    },
    config: { // 配置配置文件
        description: 'config info',
        alias: 'c',
        examples: [
          'zhu-cli config get <k>',
          'zhu-cli config set <k> <v>',
        ],
    },
    '*': {
        description: 'command not found',
    },
};

// 循環建立命令 
Object.keys(actionsMap).forEach((action) => {
    program
    .command(action) // 命令的名稱 
    .alias(actionsMap[action].alias) // 命令的別名
    .description(actionsMap[action].description) // 命令的描述 
    .action(() => { // 動做
      console.log(action);
    });
});

program.version(version)
  .parse(process.argv);
複製代碼

3.3 編寫help命令

監聽help命令打印幫助信息

program.on('--help', () => {
    console.log('Examples');
    Object.keys(actionsMap).forEach((action) => {
        (actionsMap[action].examples || []).forEach((example) => {
            console.log(`  ${example}`);
        }); 
    });
});
複製代碼

到如今咱們已經把命令行配置的很棒啦,接下來就開始實現對應的功能!

4.create命令

create命令的主要做用就是去git倉庫中拉取模板並下載對應的版本到本地,若是有模板則根據用戶填寫 的信息渲染好模板,生成到當前運行命令的目錄下~

action(() => { // 動做
    if (action === '*') { // 若是動做沒匹配到說明輸入有誤
        console.log(acitonMap[action].description); 
    } else { // 引用對應的動做文件 將參數傳入
        require(path.resolve(__dirname, action))(...process.argv.slice(3));
    }
}
複製代碼

根據不一樣的動做,動態引入對應模塊的文件

建立create.js

// 建立項目
module.exports = async (projectName) => {
  console.log(projectName);
};
複製代碼

執行 zhu-cli create project ,能夠打印出 project

4.1 拉取項目

咱們須要獲取倉庫中的全部模板信息,個人模板所有放在了git上,這裏就以git爲例,我經過axios去獲 取相關的信息~~~

npm i axios
複製代碼

這裏藉助下github的 api

const axios = require('axios');
// 1).獲取倉庫列表
const fetchRepoList = async () => {
    // 獲取當前組織中的全部倉庫信息,這個倉庫中存放的都是項目模板
    const { data } = await axios.get('https://api.github.com/orgs/zhu-cli/repos'); 
    return data;
};
module.exports = async (projectName) => {
  let repos = await fetchRepoList();
  repos = repos.map((item) => item.name);
  console.log(repos)
};
複製代碼

發如今安裝的時候體驗很很差沒有任何提示,並且最終的結果我但願是能夠供用戶選擇的!

4.2 inquirer & ora

咱們來解決上面提到的問題

npm i inquirer ora
複製代碼
module.exports = async (projectName) => { 
    const spinner = ora('fetching repo list'); 
    spinner.start(); // 開始loading
    let repos = await fetchRepoList(); 
    spinner.succeed(); // 結束loading
    
    // 選擇模板
    repos = repos.map((item) => item.name); 
    const { repo } = await Inquirer.prompt({
        name: 'repo',
        type: 'list',
        message: 'please choice repo template to create project', 
        choices: repos, // 選擇模式
    });
    console.log(repo);
};
複製代碼

咱們看到的命令行中選擇的功能基本都是基於inquirer實現的,能夠實現不一樣的詢問方式

4.3 獲取版本信息

和獲取模板同樣,咱們能夠故技重施

const fetchTagList = async (repo) => {
  const { data } = await axios.get(`https://api.github.com/repos/zhu-cli/${repo}/tags`);
  return data;
};
// 獲取版本信息
spinner = ora('fetching repo tags'); 
spinner.start();
let tags = await fetchTagList(repo); 
spinner.succeed(); // 結束loading
// 選擇版本
tags = tags.map((item) => item.name); 
const { tag } = await Inquirer.prompt({
  name: 'tag',
  type: 'list',
  message: 'please choice repo template to create project',
  choices: tags,
});
複製代碼

咱們發現每次都須要去開啓loading、關閉loading,重複的代碼固然不能放過啦!咱們來簡單的封裝下

const wrapFetchAddLoding = (fn, message) => async (...args) => { const spinner = ora(message);
    spinner.start(); // 開始loading
    const r = await fn(...args);
    spinner.succeed(); // 結束loading
    return r; 
};
// 這回用起來舒心多了~~~
let repos = await wrapFetchAddLoding(fetchRepoList, 'fetching repo list')(); 
let tags = await wrapFetchAddLoding(fetchTagList, 'fetching tag list')(repo);
複製代碼

4.4 下載項目

咱們已經成功獲取到了項目模板名稱和對應的版本,那咱們就能夠直接下載啦!

npm i download-git-repo
複製代碼

很遺憾的是這個方法不是promise方法,不要緊咱們本身包裝一下

const { promisify } = require('util');
const downLoadGit = require('download-git-repo');
downLoadGit = promisify(downLoadGit);
複製代碼

node中已經幫你提供了一個現成的方法,將異步的api能夠快速轉化成promise的形式~ 下載前先找個臨時目錄來存放下載的文件,來~繼續配置常量

const downloadDirectory = `${process.env[process.platform === 'darwin' ? 'HOME'
: 'USERPROFILE']}/.template`;
複製代碼

這裏咱們將文件下載到當前用戶下的 .template 文件中,因爲系統的不一樣目錄獲取方式不一 樣, process.platform 在windows下獲取的是 win32 我這裏是mac 全部獲取的值是 darwin ,在根據 對應的環境變量獲取到用戶目錄

const download = async (repo, tag) => { let api = `zhu-cli/${repo}`; // 下載項目 
    if (tag) {
        api += `#${tag}`;
      }
    const dest = `${downloadDirectory}/${repo}`; // 將模板下載到對應的目錄中 
    await downLoadGit(api, dest);
    return dest; // 返回下載目錄
};
// 下載項目
const target = await wrapFetchAddLoding(download, 'download template')(repo, tag);
複製代碼

若是對於簡單的項目能夠直接把下載好的項目拷貝到當前執行命令的目錄下便可。

安裝 ncp 能夠實現文件的拷貝功能

npm i ncp
複製代碼

像這樣:

let ncp = require('ncp');
ncp = promisify(ncp);
// 將下載的文件拷貝到當前執行命令的目錄下
await ncp(target, path.join(path.resolve(), projectName));
複製代碼

固然這裏能夠作的更嚴謹一些,判斷一下當前目錄下是否有重名文件等..., 還有不少細節也須要考慮像多 次建立項目是否要利用已經下載好的模板,你們能夠自由的發揮~

4.5 模板編譯

剛纔說的是簡單文件,那固然直接拷貝就行了,可是有的時候用戶能夠定製下載模板中的內容,拿 package.json 文件爲例,用戶能夠根據提示給項目命名、設置描述等 這裏我在項目模板中增長了ask.js

module.exports = [
    {
        type: 'confirm',
        name: 'private',
        message: 'ths resgistery is private?',
    },
    ...
]
複製代碼

根據對應的詢問生成最終的 package.json 下載的模板中使用了 ejs 模板

{
  "name": "vue-template",
  "version": "0.1.2",
  "private": "<%=private%>",
    "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build"
  },
  "dependencies": {
    "vue": "^2.6.10"
  },
  "autor":"<%=author%>",
  "description": "<%=description%>",
  "devDependencies": {
    "@vue/cli-service": "^3.11.0",
    "vue-template-compiler": "^2.6.10"
  },
  "license": "<%=license%>"
}
複製代碼

寫到這裏,你們應該想到了!核心原理就是將下載的模板文件,依次遍歷根據用戶填寫的信息渲 染模板,將渲染好的結果拷貝到執行命令的目錄下

安裝須要用到的模塊

npm i metalsmith ejs consolidate
複製代碼
const MetalSmith = require('metalsmith'); // 遍歷文件夾 
let { render } = require('consolidate').ejs;
render = promisify(render); // 包裝渲染方法

// 沒有ask文件說明不須要編譯
if (!fs.existsSync(path.join(target, 'ask.js'))) {
  await ncp(target, path.join(path.resolve(), projectName));
} else {
  await new Promise((resovle, reject) => {
    MetalSmith(__dirname)
        .source(target) // 遍歷下載的目錄
        .destination(path.join(path.resolve(), projectName)) // 輸出渲染後的結果 
        .use(async (files, metal, done) => {
            // 彈框詢問用戶
            const result = await Inquirer.prompt(require(path.join(target,'ask.js')));
            const data = metal.metadata();
            Object.assign(data, result); // 將詢問的結果放到metadata中保證在下一個中間件中 能夠獲取到
            delete files['ask.js'];
            done(); 
    
        })
        .use((files, metal, done) => {
            Reflect.ownKeys(files).forEach(async (file) => {
            let content = files[file].contents.toString(); // 獲取文件中的內容
            if (file.includes('.js') || file.includes('.json')) { // 若是是js或者 json纔有多是模板
                if (content.includes('<%')) { // 文件中用<% 我才須要編譯
                content = await render(content, metal.metadata()); // 用數據渲染模板 
                files[file].contents = Buffer.from(content); // 渲染好的結果替換便可
            } 
        }
        });
        done(); 
            
    })
    .build((err) => { // 執行中間件 
        if (!err) {
            resovle();
        } else {
            reject(); 
        }
    });
});
}
複製代碼

這裏的邏輯就是上面描述的那樣,實現了模板替換!到此安裝項目的功能就完成了,咱們發現這 裏面全部用到的地址的路徑都寫死了,咱們但願這是一個更通用的腳手架,可讓用戶本身配置 拉取的地址~

5.config命令

新建config.js 主要的做用其實就是配置文件的讀寫操做,固然若是配置文件不存在須要提供默認的值, 先來編寫常量

constants.js 的配置

const configFile = `${process.env[process.platform === 'darwin' ? 'HOME' : 
'USERPROFILE']}/.zhurc`; // 配置文件的存儲位置
const defaultConfig = {
    repo: 'zhu-cli', // 默認拉取的倉庫名 
};
複製代碼

編寫 config.js

const fs = require('fs');
const { defaultConfig, configFile } = require('./util/constants');
module.exports = (action, k, v) => {
    if (action === 'get') { 
        console.log('獲取');
    } else if (action === 'set') { 
        console.log('設置');
    }
// ...
};
複製代碼

通常 rc 類型的配置文件都是 ini 格式也就是:

repo=zhu-cli
register=github
複製代碼

下載 ini 模塊解析配置文件

npm i ini
複製代碼

這裏的代碼很簡單,無非就是文件操做了

const fs = require('fs');
const { encode, decode } = require('ini');
const { defaultConfig, configFile } = require('./util/constants');
const fs = require('fs');
const { encode, decode } = require('ini');
const { defaultConfig, configFile } = require('./util/constants');
module.exports = (action, k, v) => { 
    const flag = fs.existsSync(configFile); 
    const obj = {};
    if (flag) { // 配置文件存在
        const content = fs.readFileSync(configFile, 'utf8'); 
        const c = decode(content); // 將文件解析成對象 
        Object.assign(obj, c);
    }
    if (action === 'get') {
        console.log(obj[k] || defaultConfig[k]);
    } else if (action === 'set') {
        obj[k] = v;
        fs.writeFileSync(configFile, encode(obj)); // 將內容轉化ini格式寫入到字符串中
        console.log(`${k}=${v}`);
    } else if (action === 'getVal') {
        return obj[k];
    } 
};
複製代碼

getVal 這個方法是爲了在執行create命令時能夠獲取到配置變量

const config = require('./config');
const repoUrl = config('getVal', 'repo');
複製代碼

這樣咱們能夠將create方法中全部的 zhu-cli 所有用獲取到的值替換掉啦!

到此基本核心的方法已經ok!剩下的你們能夠自行擴展啦!

6.項目發佈

終於走到最後一步啦,咱們將項目推送 npm 上,流程再也不贅述啦!

nrm use npm
npm publish # 已經發布成功~~
複製代碼

能夠經過 npm install zhu-cli -g 進行安裝啦!

相關文章
相關標籤/搜索