项目来源
Q:业界内脚手架有很多,但我为什么还是想自研属于自己的脚手架呢?
A:自研脚手架是为了方便日后开发时能省去一些固定的开发步骤,比如项目架构、通用功能、常见函数等等,以此达到节省开发成本的目的。而我想自研的是适用于后端开发的一套脚手架,最好是能够封装那些很常见、写得很多的功能点,比如登录注册、路由鉴权等,于是就有了这样的一个想法。
coding 前准备
在开始编码前,我们需要理清开发步骤:知道自己要干什么事,怎么做成这件事,最终要达到什么效果。只有充分思考到这些点,我们才可能在编码时做到有的放矢,不至于像无头苍蝇一样乱撞。
开发流程
- 编写模板代码
- 模板项目初始化
- 集成常用配置
- 封装功能函数/组件
- 开发 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
中写入:
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
在终端执行命令 foursheep
和 foursheep -V
,可以看到这样的效果:
扩展命令参数
在 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
可以看到:
绘制专属 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
,可以看到如下界面:
细心的读者可以看到代码中注释掉了 clear()
,那这行代码有什么用呢,直接上图看效果就清楚了:
因此,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.js
的 createAction
方法:
// 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
}
- 创建文件夹
- 判断本地是否有重名的文件夹
- 有:是否强制创建文件夹
- 是:删除本地同名文件夹,然后创建新文件夹
- 不是:让用户自行决定是否覆盖
- 没有:直接创建新文件夹
- 有:是否强制创建文件夹
优化用户在命令行窗口的交互界面,这里使用了 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();
}
- 选择模板
// 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') {
...
}
}
}
- 拉取代码
// 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
:
封装拉取远程仓库代码的函数:
// 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();
}
}
)
})
};
- 安装依赖
// 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 集合
require() of ES modules is not supported.
原因:一般都是当前使用依赖的版本过高导致。
解决:降低依赖的版本号,我这里报错是因为 inquirer
版本号过高,将其降为 ^8.2.0
即可。
download-git-repo
下载代码时遇到的坑
Error: spawn npm ENOENT
原因:在 windows 下 npm 的执行命令不同。
解决:做 Windows 下的兼容,确保在 win32
下可以运行 npm
,如果 npm
实在运行不了的话,可以使用 cnpm
。
Unexpected identifier
原因:没有告知项目要用什么模块导入。
解决:package.json
下写入 "type": "module"
。
Unknown file extension ".ts" for D:\code\node-koa-backend\src\app.ts
TODO
Monorepo
统一管理代码模板- 集成各前端框架,备好基本配置
- 集成 React + ESlint 等规范配置
- JWT 登录认证
- WebSocket