解放cv键 实现一个cli工具

前言

1b8a09b6e0285b225aa6d737d589232c.gif

在我平时的工作中会频繁的ctrl+ Cctrl + V相同的项目框架或者配置,比如:

  • 要搭建一个新项目的基础框架,从自己写好的项目模板clone下来,cv到新的git仓库,然后push
  • 写demo或者小项目时,cv类似的demo,然后删除一些代码

这种重复工作会占用我一部分时间,特别是在着急需要做演示写demo时候。而「脚手架」的作用就是快速的搭建项目基础结构,使用脚手架可以省去重复的cv流程。我们可以实现一个cli工具,通过简单的用户问询生成对应的项目模板,省去开发中重复创建的流程。

准备开发cli

  • node: 我的开发环境node版本是12.x,所以脚手架里使用的依赖包都是较低的版本遵循commonjs规范引入的。如果你更喜欢使用esm规范,可以在较高(>13.2.0)的版本中,通过 指定package.json文件中的"type":"module"使node支持esm语法。

  • 使用到的依赖包:

    包名 作用
    path node内置的处理文件路径的模块
    fs node内置的处理文件的模块
    commander 命令行自定义指令
    chalk 控制台输出样式美化
    ora 控制台显示loading动画
    inquirer 用户问询
    prettier 格式化文件样式
    rimraf 删除文件和文件夹
    download-git-repo git远程下载
    ejs 模板引擎
    execa 执行系统命令
  • npm账号:用来发布到社区,供其他人或者自己在其他办公设备上使用

搭建一个简单的cli项目

  1. 在空白文件夹下执行npm init生成package.json。我这里以yuwuwu-cli来命名项目,然后手动创建文件,目录结构如下:
yuwuwu-cli
├─ bin                
│  └─ index.js          
├─ package.json  
└─ README.md
复制代码
  1. 在package.json添加bin:{"yuwuwu": "./bin/index.js"},指明命令要执行的文件入口,这样yuwuwu就可以当做命令使用了。
//package.json
{
  "name": "yuwuwu-cli",
  "version": "1.0.0",
  "description": "脚手架",
  "main": "index.js",
  "directories": {
    "test": "test"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "bin": {
    "yuwuwu": "./bin/index.js"
  },
  "author": "yuwuwu",
  "license": "ISC",
  "dependencies": {
  }
}

复制代码
  1. index.js文件第一行添加#! /usr/bin/env node,这样的话系统就会调用node去执行文件。
#!/usr/bin/env node
console.log("Hello yuwuwu-cli")
复制代码
  1. 本地调试,执行npm link,成功后就可以全局使用这个库了,此时执行yuwuwu控制台就会输出Hello yuwuwu-cli

image.png

  1. 发布到社区,先执行npm login登录上你的npm账号,没有可以去官网注册。登录后npm publish发布。发布时注意package.json文件中的name不能与已有的npm包重名,我们可以完善一下readme.md文件,使得其他用户快速上手你的工具。
//先登录
npm login
//再发布
npm publish
复制代码
  1. 发布成功后,在任意一台设备上npm install yuwuwu-cli -g全局安装后就可以使用yuwuwu这个命令了。

搭建自己的cli

首先根据脚手架的功能,梳理出整个代码的流程,整理出todolist:

  1. 创建项目文件
  2. 用户问询选择模板
  3. 下载项目模板
  4. 安装项目依赖

1. 创建项目文件

1.1 接收命令行参数

参考vue-cli,vue的脚手架有create <project-name>命令创建项目,常用还有-V、config等命令,这种命令我们就需要借助commander去实现。

const version = require("../package.json").version;
const program = require("commander");


program
  .command("init <project-name>")
  .description("初始化项目文件")
  .action((projectName) => {
    console.log("项目名称:" + projectName);
    cliStart()
  });

program
  .on("--help", function () {
    console.log();
    console.log("你可以这样使用:yuwuwu init <project-name>");
    console.log();
  });

program
  .version(version, '-v,--version')
  .parse(process.argv);
if (program.args.length < 2) program.help();

async function cliStart() {
    //todo
    // 1. 创建项目文件
    // 2. 用户问询选择模板
    // 3. 下载项目模板
    // 4. 安装项目依赖
}
复制代码

这样我们就实现了init <project-name>、--help、-V/-v/--version三个命令。在控制台输入一下命令,可以看到成功得到我们想要的信息了。

image.png

这样的内容看起来不够美观,体现不出哪些信息是重要信息,我们可以使用chalk这个依赖包,它可以改变控制台输出内容的颜色。给我们认为重要的信息加上颜色。

  chalk.green("项目名称:" + projectName)
复制代码

image.png

1.2 创建文件夹

在创建文件夹时候会碰到文件夹已存在的问题,这时候需要问询使用者,是否需要覆盖重名文件夹。用户问询需要用inquirer去实现,而操作文件夹用fspath模块可以实现。

async function cliStart() {
    //todo
    // 1. 创建项目文件
    await mkdirByProjectName()
    // 2. 用户问询选择模板
    // 3. 下载项目模板
    // 4. 安装项目依赖
}
async function mkdirByProjectName() {
    if (fs.existsSync(program.args[0])) {
        console.log(chalk.red(program.args[0] + "文件夹已存在"));
        let answers = await isRemoveDirQuestion()
        if (answers.ok) {
            rimraf.sync(getProjectName());
            fs.mkdirSync(getProjectName());
        }
    } else {
        fs.mkdirSync(getProjectName());
    }
}
function isRemoveDirQuestion() {
    return inquirer.prompt([
        {
            type: "confirm",
            message: "是否覆盖原文件夹?",
            name: "ok",
        }
    ])
}
function getProjectName() {
    return path.resolve(process.cwd(), program.args[0])
}
复制代码

我们来看一下这一步的逻辑,先通过fs.existsSync()判断文件夹是否存在,不存在的话直接fs.mkdirSync()创建,存在的话通过inquirer.prompt()向用户发起一个问询,如果用户同意覆盖就rimraf.sync()删除原文件夹,再创建。

2. 用户问询选择模板

这一步比较简单,我们只需要向用户问询,我这里罗列了一下自己平时开发中经常会被cv到的几个项目。

这几个模板之间的差异主要体现在下一步中,有的模板直接从github上拉取远程仓库的代码,而有的是根据需求自定义生成项目。

async function cliStart() {
    //todo
    // 1. 创建项目文件
    await mkdirByProjectName()
    // 2. 用户问询选择模板
    const {choice} = await choiceTemplateQuestion()
    // 3. 下载项目模板
    // 4. 安装项目依赖
}
//...

function choiceTemplateQuestion() {
    return inquirer.prompt([
        {
            type: "list",
            name: "choice",
            message: "选择要使用的模板:",
            choices: [
                {
                    value: "github:yuwuwu/vue-mobile-template",
                    name: "vue2+vant移动端模板",
                },
                {
                    value: "github:yuwuwu/vue-pc-template",
                    name: "vue2+element后台管理模板",
                },
                {
                    value: "node",
                    name: "自定义node模板",
                },
            ],
        },
    ])
}
复制代码

执行的效果如下: QQ20220322-211257-HD.gif

3. 下载项目模板

3.1 下载远程模板

下载远程模板需要使用到download-git-repo这个依赖,需要注意的是它不支持promise,所以我们要对它进行简单的封装,使其支持promise,使得cliStart方法美观。 因为从git上下载模板需要时间的,但是此时控制台是没有任何交互动作的,这里我们使用ora这个依赖包,它可以在控制台输出loading,给与用户等待的感知。

async function cliStart() {
    //todo
    // 1. 创建项目文件
    await mkdirByProjectName()
    // 2. 用户问询选择模板
    const { choice } = await choiceTemplateQuestion()
    // 3. 下载项目模板
    switch (choice) {
        case 'node':
            await createNodeTemplate()
            break;
        default:
            await downloadByGit(choice);
    }
    // 4. 安装项目依赖
}
//...
function downloadByGit(url) {
    const loading = ora("downloading").start()
    return new Promise((res, rej) => {
        download(url, getProjectName(), { clone: false }, function (err) {
            loading.stop()
            if (err) {
                console.log(chalk.red(err));
                rej()
                process.exit(1)
            }
            console.log(chalk.green('download success!'));
            res()
        });
    })
}
复制代码

到这里已经完成一个拉取不同的项目模板的脚手架了,可以运行看一下效果: QQ20220322-212416-HD.gif 此时打开test文件夹,发现文件夹下已经下载了一堆项目文件了。

WX20220324-002554@2x.png

3.2 自定义node模板

有时候项目模板还需要定制化,比如我在平时的工作中,经常会创建一些node项目做演示,项目a需要corsbody-parser模块,项目b需要fs模块,直接下载git上的模板会多或者少某些依赖模块,这时候就要通过cli生成定制化的模板。 node的程序一般都包含主入口文件index.js和配置文件package.json,我们只需要根据用户问询的内容动态生成这两个文件的内容就可以实现自定义模板。 生成文件使用fs模块就可以,而文件里面的内容可以使用使用了ejs代码模板渲染出来。而写入的代码格式都是乱的,可以使用prettier格式化内容。

// index.ejs
// 省略部分重复逻辑的代码,完整代码可以在结尾查看
const express = require('express');
const app = express()

<% if (modules.indexOf("cors")> -1) { -%>
const cors = require('cors');
<% } -%>
//...
const server = app.listen(<%= port %> , '0.0.0.0', () => {
  const host = server.address().address;
  const port = server.address().port;
  console.log('app start listening at http://%s:%s', host, port);
});
复制代码
// package.ejs
// 省略部分重复逻辑的代码,完整代码可以在结尾查看

{
"name": "<%= name %>",
"version": "1.0.0",
"description": "<%= description %>",
"main": "index.js",
"directories": {
"test": "test"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node index.js"
},
"author": "<%= author %>",
"license": "ISC",
"dependencies": {
<% if (modules.indexOf("cors") > -1) { -%>
"cors": "^2.8.5",
<% } -%>
"express": "^4.17.2"
}
}
复制代码
async function cliStart() {
    //todo
    // 1. 创建项目文件
    await mkdirByProjectName()
    // 2. 用户问询选择模板
    const { choice } = await choiceTemplateQuestion()
    // 3. 下载项目模板
    switch (choice) {
        case 'node':
            await createNodeTemplate()
            break;
        default:
            await downloadByGit(choice);
    }
    // 4. 安装项目依赖
}
//...
async function createNodeTemplate() {
  let answers = await nodeProjectQuestion()
  fs.writeFileSync(getProjectName() + "/index.js", getIndexTemplate(answers))
  fs.writeFileSync(getProjectName() + "/package.json", getPackageTemplate(answers))
}
function getIndexTemplate(config){
  const ejsTemplateData = fs.readFileSync(path.resolve(__dirname,"./nodeTemplate/index.ejs"))
  const indexTemplateData = ejs.render(ejsTemplateData.toString(),{
    port: config.port,
    modules: config.modules
  })
  return prettier.format(indexTemplateData,{ parser: "babel" })
}
function getPackageTemplate(config){
  const ejsTemplateData = fs.readFileSync(path.resolve(__dirname,"./nodeTemplate/package.ejs"))
  const packageTemplateData = ejs.render(ejsTemplateData.toString(),{
    name: config.name,
    description: config.description,
    author: config.author,
    modules: config.modules
  })
  return prettier.format(packageTemplateData,{ parser: "json" })
}
复制代码

4. 安装项目依赖

到这一步脚手架生成项目模板的功能已经完成了,剩下的是一些优化性的操作,比如:执行npm install、npm start。代码里执行系统命令我们可以使用execa

async function cliStart() {
    //todo
    // 1. 创建项目文件
    await mkdirByProjectName()
    // 2. 用户问询选择模板
    const { choice } = await choiceTemplateQuestion()
    // 3. 下载项目模板
    switch (choice) {
        case 'node':
            await createNodeTemplate()
            break;
        default:
            await downloadByGit(choice);
    }
    // 4. 安装项目依赖
    await installModules()
}
//...
async function installModules() {
  const loading = ora("install...").start()
  await execa("yarn", { cwd: getProjectName() }, ["install"])
    .then(() => {
      loading.succeed("install")
      console.log(chalk.green("install success!!!"))
    })
    .catch((err) => {
      console.log(chalk.red(err))
      loading.stop()
    })
}
复制代码

execa("yarn", { cwd: getProjectName() }, ["install"])这段代码的含义为:先进入创建的项目根目录,再执行npm install。

完成这一步后,我们脚手架的功能就都实现了,可以更新一下版本重新发布到npm上,这样在任何一台设备都可以使用了。

结尾

脚手架的功能不止这些,我只是完成了脚手架较为基础的一些功能。这里提供了一个脚手架的思路,希望可以对大家工作带来帮助。平时开发中还有很多场景都是在进行重复操作,重复cv,我们在日常的coding中也要有这种自动化、工程化的思维,减少重复的工作提高效率,早点下班。

完整代码查看github:yuwuwu-cli

猜你喜欢

转载自juejin.im/post/7078337031167803406