玩命加载中 . . .

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


项目来源

Q:业界内脚手架有很多,但我为什么还是想自研属于自己的脚手架呢?
A:自研脚手架是为了方便日后开发时能省去一些固定的开发步骤,比如项目架构、通用功能、常见函数等等,以此达到节省开发成本的目的。而我想自研的是适用于后端开发的一套脚手架,最好是能够封装那些很常见、写得很多的功能点,比如登录注册、路由鉴权等,于是就有了这样的一个想法。

coding 前准备

在开始编码前,我们需要理清开发步骤:知道自己要干什么事怎么做成这件事最终要达到什么效果。只有充分思考到这些点,我们才可能在编码时做到有的放矢,不至于像无头苍蝇一样乱撞。

开发流程

  1. 编写模板代码
  • 模板项目初始化
  • 集成常用配置
  • 封装功能函数/组件
  1. 开发 CLI 工具
  • 搜罗大量文章了解脚手架原理
  • 安装模块准备开发
  • 有逻辑性地开发各个功能点
    • 脚手架介绍
    • 自定义命令
    • 用户交互
    • 拉取远程仓库的代码
    • 自动安装依赖

当然,开发时还有其他的点要注意,比如拉取代码到本地时,判断本地文件夹是否已存在等等。不过这属于逻辑是否严密的范畴了,在开发时要多代入场景,尽量考虑周全。

模块介绍

开发 CLI 工具时,我们会用到很多模块工具,下面一一来看它们的作用:

  • commander:参数解析 –help其实就借助了他~ 解析用户输入的命令
  • inquirer:交互式命令行工具,用户可以进行命令行的选择
  • download-git-repo:拉取GitHub上的文件
  • chalk:帮我们在控制台中画出各种各样的颜色
  • ora:小图标 (loading、succeed、warn等)
  • metalsmith:读取所有文件,实现模板渲染
  • consolidate:统一模板引擎
  • handlebars:也是模板引擎,两者选其一即可

了解了这些后,我们就可以开始 coding 了。

实战过程

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

明确功能

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

创建项目

初始化 CLI

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

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"
  }
}

实现 CLI 功能

介绍脚手架信息

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

{1,4,6-7}
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

使用 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_url
image.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脚本以及踩过的坑

TODO

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

参考资料


文章作者: hcyety
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hcyety !
评论
  目录