【自研脚手架详细教程】开发实录

310 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第11天,点击查看活动详情

关于 模板 的部分,这里就不展示了,读者根据自己的需求配置好后,确保项目能正常运行,然后将其 push 到远程仓库,以便脚手架拉取 code 。下面我们来讲讲如何实现 CLI 工具。

明确功能

  • 自选模板
  • 拉取 GitHub 代码
  • 自动安装项目依赖

创建项目

初始化

创建一个空文件夹来存放脚手架项目:

mkdir foursheep-cli
cd foursheep-cli

初始化 package.json

npm init -y

目录结构

│  package-lock.json
│  package.json
│  README.md
│  yarn.lock
│
├─.idea
│      .gitignore
│      foursheep-cli.iml
│      modules.xml
│      workspace.xml
│
├─bin
│      cli.js  // 全局命令执行的根文件
│
└─lib
        action.js  // 自定义命令时执行文件
        clone.js   // 克隆仓库代码的函数
        init.js    // CLI 初始化时的函数

package.json 具体配置:

{
  "name": "foursheep-cli",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo "Error: no test specified" && exit 1"
  },
  "keywords": [],
  "author": "foursheep",
  "license": "ISC",
  "dependencies": {
    "axios": "^0.27.2",
    "chalk": "^4.1.2",
    "clear": "^0.1.0",
    "commander": "^9.4.0",
    "cross-env": "^7.0.3",
    "cross-spawn": "^7.0.3",
    "download-git-repo": "^3.0.2",
    "execa": "^6.1.0",
    "figlet": "^1.5.2",
    "fs-extra": "^10.1.0",
    "handlebars": "^4.7.7",
    "inquirer": "^8.2.0",
    "open": "^8.4.0",
    "ora": "^5.1.0",
    "shelljs": "^0.8.5",
    "util": "^0.12.4"
  }
}

实现功能

介绍脚手架信息

我们输入脚手架命令时,需要解析命令行及其参数,这时就要引入 commander 模块。在 /bin/cli.js 中写入:

const program = require("commander")
const package = require('../package.json'); // 获取 package.json 中的字段

// 介绍脚手架信息
program
  .version(`foursheep-cli@${package.version}`) // 脚手架的版本号
  .usage('<command> [option] ✅例如: foursheep init demo') 
  .command("init <name>")
  .option("-y", "忽略 package.json 的信息输入")
  .description("♥Welcome to use foursheep-cli by foursheep") // 当前命令的描述
  
program.parse(process.argv);

修改 package.json ,指定每个命令所对应的可执行文件的位置:

{
  "bin": {
    "foursheep": "bin/cli.js"
  }
}

使用 npm link 将 CLI 挂载到全局(类似于 npm 的全局安装?)

npm link

在终端执行命令 foursheepfoursheep -V ,可以看到这样的效果:

image.png

扩展命令参数

program.option() 中自定义参数,第一个参数为 -xxx ,第二个参数为该指令的描述。示例如下:

// bin/cli.js
const program = require("commander")
const package = require('../package.json'); // 获取 package.json 中的字段

// 介绍脚手架信息
program
  .version(`foursheep-cli@${package.version}`) // 脚手架的版本号
  .usage('<command> [option] ✅例如: foursheep init demo') 
  .command("init <name>")
  .option("-y", "忽略 package.json 的信息输入")
  .description("♥Welcome to use foursheep-cli by foursheep") // 当前命令的描述
  
+ // 自定义--help中的选项
+ program
+   .option('-u, --user', "output the tool's author")
+   .action(() => {
+     console.log(`this cli tool was made by ${package.author}`);
+   })
  
program.parse(process.argv);

在终端输入 foursheep -u 可以看到:

image.png

绘制专属 LOGO

使用 figlet 绘制 Logo —— 打印欢迎界面。

可以用 figlet 的 API 绘制各种好看的图案,如字体的样式、颜色、图标等等。这里我就简单打印出一个 Logo 就好了。

// bin/cli.js
const program = require("commander");
const package = require('../package.json'); // 获取 package.json 中的字段
+ const figlet = require('figlet');
+ const chalk = require("chalk");
+ // const clear = require("clear");

// 介绍脚手架信息
program
  .version(`foursheep-cli@${package.version}`) // 脚手架的版本号
  .usage('<command> [option] ✅例如: foursheep init demo') 
  .command("init <name>")
  .option("-y", "忽略 package.json 的信息输入")
  .description("♥Welcome to use foursheep-cli by foursheep") // 当前命令的描述
  
// 自定义--help中的选项
program
  .option('-u, --user', "output the tool's author")
  .action(() => {
    console.log(`this cli tool was made by ${package.author}`);
  })
  
+ // 绘制专属 LOGO
+ program.on('--help', () => {
+   console.log("")
+ 
+   // 使用 figlet 绘制 Logo —— 打印欢迎界面
+   // clear();
+   // 封装一个输出绿色文字的API
+   const log = (content) => console.log(chalk.green(content));
+   const data = figlet.textSync("fs!Welcome");
+   log(data);
+ 
+   console.log("");
+ });

program.parse(process.argv);

在终端输入 foursheep -h ,可以看到如下界面:

image.png

细心的读者可以看到代码中注释掉了 clear() ,那这行代码有什么用呢,直接上图看效果就清楚了:

image.png

因此,clear() 的作用就是清屏,当然不是删除掉之前的打印信息,屏幕往上滑还是可以看到之前的打印信息的。

自定义命令

笔者自定义的命令是 foursheep create <name> -f ,要达到的效果是①在本地新建文件夹;②拉取 GitHub 上的代码;③自动安装依赖。

首先来看看怎么自定义命令:

// bin/cli.js
const program = require("commander");
const package = require('../package.json'); // 获取 package.json 中的字段
const figlet = require('figlet');
const chalk = require("chalk");
+ const {createAction} = require("../lib/action");
// const clear = require("clear");

// 介绍脚手架信息
program
  .version(`foursheep-cli@${package.version}`) // 脚手架的版本号
  .usage('<command> [option] ✅例如: foursheep init demo') 
  .command("init <name>")
  .option("-y", "忽略 package.json 的信息输入")
  .description("♥Welcome to use foursheep-cli by foursheep") // 当前命令的描述
  
// 自定义--help中的选项
program
  .option('-u, --user', "output the tool's author")
  .action(() => {
    console.log(`this cli tool was made by ${package.author}`);
  })
  
// 绘制专属 LOGO
program.on('--help', () => {
  console.log("")

  // 使用 figlet 绘制 Logo —— 打印欢迎界面
  // clear();
  // 封装一个输出绿色文字的API
  const log = (content) => console.log(chalk.green(content));
  const data = figlet.textSync("fs!Welcome");
  log(data);

  console.log("");
})

+ program
+   .command("create <name>")
+   .option("-f, --force", "当文件夹已经存在,是否强制创建")
+   .description("♥create a new project")
+   .action((value, options) => {
+     createAction(value, options);
+   });

program.parse(process.argv);

下面我们来实现 lib/action.jscreateAction 方法:

// lib/action.js
const createAction = async function (projectName, cmd) {
  // 判断文件是否存在,是否覆盖
  await isFileCover(projectName, cmd);
}

const isFileCover = async (projectName, cmd) => {
  ......
  
  // 创建目录
  const creator = new Creator(projectName, cwd, directoryPath);
  creator.fetchRepo();
};

class Creator {
  // 获取仓库地址
  async fetchRepo() {};
  // 拉取代码
  async download(frame) {};
  // 安装依赖
  async runNpm() {};
}

module.exports = {
  createAction
}
  1. 创建文件夹
  • 判断本地是否有重名的文件夹
    • 有:是否强制创建文件夹
      • 是:删除本地同名文件夹,然后创建新文件夹
      • 不是:让用户自行决定是否覆盖
    • 没有:直接创建新文件夹

优化用户在命令行窗口的交互界面,这里使用了 inquirer 模块,具体用法请参考官网。

// lib/action.js
const path = require('path');
const inquirer = require('inquirer');
const fs = require('fs-extra');
const chalk = require('chalk');

// 判断文件是否存在,是否覆盖
const isFileCover = async (projectName, cmd) => {
  // 获取当前执行的目录路径
  const cwd = process.cwd();
  // 获取到要创建的地址
  const directoryPath = path.join(cwd, projectName);

  // 判断该目录存不存在
  if (fs.existsSync(directoryPath)) {
    if (cmd.force) {
      // 如果是强制创建, 删除目录后面直接创建
      await fs.remove(directoryPath);
    } else {
      // 提示用户是否确认覆盖
      const {chose} = await inquirer.prompt([ // 配置询问方式
        {
          name: 'chose',
          type: 'list', // 类型
          message: `${directoryPath}文件夹已存在,是否覆盖?`,
          choices: [
            { name: '覆盖', value: 'overwrite' },
            { name: '取消', value: false }
          ]
        }
      ]);

      // 覆盖就是删除再创建
      if (chose === 'overwrite') {
        // 移除已存在的目录
        console.log(chalk.hex("#ff8800").bold(`\r\n正在移除目录...`));
        await fs.remove(directoryPath);
        console.log(chalk.hex("#ff8800").bold(`\r\n目录移除成功!`));
        // await loadingWrap(fs.remove, '文件覆盖中', targetDir);
      } else {
        return;
      }
    }
  }

  // 创建目录
  const creator = new Creator(projectName, cwd, directoryPath);
  creator.fetchRepo();
}
  1. 选择模板
// lib/action.js
class Creator {
  async fetchRepo() {
    // 失败重新拉取
    // let repos = await loadingWrap(fetchRepoList, '模版文件正在拉取中');
  
    // if (!repos) return false;
  
    // repos = repos.map(item => item.name)
  
    // 创建目标目录
    await fs.ensureDirSync(this.directoryPath);
  
    const { service }  = await inquirer.prompt([{
      name : 'service',
      type: 'list',
      message: chalk.yellow('请选择服务:'),
      choices: [ // 根据自己的需求定制
        { name: '前端', value: 'frontend' },
        { name: '后端', value: 'backend' },
        { name: '微前端', value: 'micro-frontend' },
        { name: 'monorepo', value: 'monorepo' },
      ]
    }])
  
    if (service === 'frontend') {
       ...
    } else if (service === 'backend') {
      inquirer
        .prompt([
          {
            name : 'frame',
            type: 'list',
            message: chalk.yellow('请选择框架:'),
            choices: [
              { name: 'node-koa-ts-mysql', value: 'node-koa-ts-mysql' },
            ]
          }
        ])
        .then(answer => {
          this.download(answer.frame);
        })
    } else if (service === 'micro-frontend') {
       ...
    } else if (service === 'monorepo') {
       ...
    }
  }
}
  1. 拉取代码
// lib/action.js
const ora = require("ora");
const axios = require('axios');
const clone = require("./clone");

class Creator {
  async download(frame) {
    console.log('frame', frame)
    const timeStart = new Date().getTime();
    const spinner = ora(`Downloading...${frame}`);
    let count = 0;
  
    // 拉取代码模板
    axios.get('https://api.github.com/users/HCYETY/repos')
      .then(repoArr => {
        repoArr.data.forEach(item => {
          // 注意:这里的 frame 是 fetchRepo 函数传来的模板变量名,与 GitHub 上的仓库名是保持一致的
          if (item.name === frame) {
            console.log('item.name', item.name)
            spinner.start();
            clone(item.clone_url, this.directoryPath)
              .then(() => {
                spinner.succeed(`Success! Created ${this.projectName} at ${this.cwd}. 耗时${new Date().getTime() - timeStart}ms`);
                this.runNpm();
              })
              .catch(err => {
                spinner.fail(`Project template download failed.`);
                console.log(chalk.red(err));
                if (count < 3) {
                  console.log(chalk.yellow('尝试重新拉取代码模板'));
                  this.download(frame);
                  count++;
                } else {
                  spinner.fail(`代码模板拉取失败,次数已超过 3 次,不再自动拉取,请重新输入.`);
                }
              })
          }
        })
      }) .catch((err) => {
      console.log('获取 github 接口数据失败', err);
    })
  }
}

axios 请求的地址是 GitHub 开放的仓库 API 接口,是一个 json 格式的数据,其中包含了我们需要的克隆地址 clone_urlimage.png

封装拉取远程仓库代码的函数:

// lib/clone.js :
const ora = require("ora"); // 进度条
const download = require("download-git-repo");

module.exports = async function (githubAddress, directoryPath) {
  const cloneSpinner = ora(`✈正在下载代码模板到${directoryPath}`).start();

  return new Promise((resolve, reject) => {
    download(
      `direct:${githubAddress}`,
      directoryPath,
      { clone: true },
      err => {
        if (err) {
          reject(err);
        } else {
          cloneSpinner.succeed('项目拉取成功');
          resolve();
        }
      }
    )
  })
};
  1. 安装依赖
// lib/action.js
const { spawn } = require("cross-spawn");

class Creator {
  async runNpm() {
    const installTime = new Date().getTime();
    const installing = ora("Installing...\n");
    installing.start();
    // windows系统兼容性处理
    const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
    const result = await spawn('cnpm', ['install', '-D'], {
      // stdio: ['inherit', 'pipe', 'inherit'],
      stdio: ['pipe', process.stdout, process.stderr],
      cwd: this.directoryPath
    });
    // 监听依赖安装状态
    result.on('close', async (code) => {
      if (code !== 0) {
        console.log(chalk.red('Error occurred while installing dependencies!'));
        process.exit(1);
      } else {
        console.log(chalk.hex("#67c23a").bold(`\r\nInstall finished  耗时${new Date() - installTime}ms`));
        installing.stop();
        console.log(chalk.green(
            `
            安装完成(${new Date().getTime() - installTime}ms):
            To get Starat :
            ================================
              cd ${this.projectName}
              npm run dev
            ================================
            `
          )
        )
        // await spawn("cnpm", ["run", "start"], { cwd: `./${this.directoryPath}` });
        // console.log(chalk.hex("#ff8800").bold(`\r\ncd ${this.projectName}`));
        // console.log(chalk.hex("#ff8800").bold("\rnpm run dev"));
      }
    })
  }
}

Error 集合

  1. require() of ES modules is not supported.

es modules not supported

原因:一般都是当前使用依赖的版本过高导致。

解决:降低依赖的版本号,我这里报错是因为 inquirer 版本号过高,将其降为 ^8.2.0 即可。

  1. download-git-repo 下载代码时遇到的坑
  1. Error: spawn npm ENOENT

spawn npm ENOENT

原因:在 windows 下 npm 的执行命令不同。

解决:做 Windows 下的兼容,确保在 win32 下可以运行 npm ,如果 npm 实在运行不了的话,可以使用 cnpm

  1. Unexpected identifier

Unexpected identifier

原因:没有告知项目要用什么模块导入。

解决package.json 下写入 "type": "module"

  1. Unknown file extension ".ts" for D:\code\node-koa-backend\src\app.ts

Unknown file extension ".ts"

解决使用ts-node运行ts脚本以及踩过的坑

  1. npm link 报错:EEXIST: file already exists

解决:根据报错提示找到下图目录位置,删除相关文件,然后重新执行 npm link

image.png

TODO

  • Monorepo 统一管理代码模板
  • 集成各前端框架,备好基本配置
    • 集成 React + ESlint 等规范配置
    • JWT 登录认证
    • WebSocket

参考资料