手擼一個自己的前端腳手架

手擼一個自己的前端腳手架

圖片描述
很多小夥伴一直很糾結什麼是腳手架?其實核心功能就是創建項目初始文件,那問題又來了,市面上的腳手架不夠用嗎,爲什麼還要自己寫?

只要提到腳手架你就會想到,vue-clicreate-react-appdva-cli ... 他們的特點不用多說那就是專一! 但是在公司開發中你會發現有以下一系列的問題!

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

在自己開發cli前,那肯定先要看些優秀的cli是如何實現的!雖然不是第一個吃螃蟹的人,那也要想想怎麼吃更好^_^#

1.必備模塊

我們先從大家衆所周知的vue-cli入手,先來看看他都是用了哪些npm包來實現的

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

先幻想一下要實現的功能:

根據模板初始化項目 quick-cli create project-name
初始化配置文件 quick-cli config set repo repo-name

2.工程創建

廢話不多說我們開始創建項目,編寫自己的腳手架~~~

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文件夾下的代碼

"scripts": {
    "lint":"eslint src"
}

2.3 配置husky

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

{
  "hooks": {
    "pre-commit": "npm run lint"
  }
}

2.4 鏈接全局包

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

"bin": {
    "quick-cli": "./bin/www"
}

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

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

鏈接包到全局下使用

npm link

我們已經可以成功的在命令行中使用quick-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就是用戶在命令行中傳入的參數

執行quick-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: [
      'quick-cli create <template-name>',
    ],
  },
  config: { // 配置配置文件
    description: 'config info',
    alias: 'c',
    examples: [
      'quick-cli config get <k>',
      'quick-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);
};

執行quick-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/quick-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/quick-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 = `quick-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']}/.quickrc`; // 配置文件的存儲位置
const defaultConfig = {
  repo: 'quick-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=quick-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方法中所有的quick-cli全部用獲取到的值替換掉啦!

到此基本核心的方法已經ok!剩下的大家可以自行擴展啦!

6.項目發佈

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

nrm use npm
npm publish # 已經發布成功~~

可以通過npm install quick-cli -g 進行安裝!


希望您能通過文章有所收穫!
如果您覺得文章不錯,歡迎關注微信公衆號,持續獲取更多內容!
圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章