从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 进行安装啦!

相关文章
相关标签/搜索