引言
当一个团队需要同时开发多个具有相同框架和配置的项目时,为了节省搭建项目的时间,通常会将基础框架配置抽取成一个模板。当需要开设新项目时,只需要拉取该模板并配置少量的定制化参数,就可以快速进入业务开发阶段,从而大大提高了工作效率。因此,本文针对此场景设计了一个脚手架。在大多数公司中,内部使用gitlab作为协同开发工具,因此本文基于gitlab提供了相应的教程。如果使用github或gitee等其他工具,需要查阅相关资料并相应调整拉取项目的步骤。(文章最后有代码仓库链接)
graph TD
在本地使用脚手架命令创建项目 --> 选取需要拉取的远程模板 --> 将模板下载到本地 --> 配置定制化参数 --> 在本地根据模板以及参数创建对应项目 --> 开箱即用
Gitlab准备 - 如何获取模板列表
步骤1:获取 GitLab 的访问令牌(Access Token)
首先,你需要在 GitLab 上创建一个访问令牌,以便在 API 请求中进行身份验证。可以按照以下步骤创建访问令牌:
- 登录到 GitLab 帐户并导航到用户设置页,也可以进入你需要获取的群组下的设置页,这里用群组做演示。
- 在左侧导航栏中选择 "Access Tokens" 选项。
- 提供一个名称,选择所需的权限,并点击 "Create Access Token" 按钮。
- 复制生成的访问令牌,这将是后续 API 请求中的身份验证凭证。
步骤2: 了解 Gitlab 相关 api
| 功能 | 接口 | 功能描述 |
|---|---|---|
| 获取项目信息 | GET /projects/:id | 通过该接口可以获取指定项目的详细信息,包括项目的名称、描述、创建者、创建时间、最近更新时间、访问级别、仓库 URL、分支信息、标签信息等。 |
| 获取项目列表 | GET /projects | 通过该接口可以获取当前用户或指定用户下的项目列表,可以设置不同的参数来筛选和排序项目,例如项目的可见性、访问级别、所属组织、创建者、名称等。 |
| 获取项目文件内容 | GET /projects/:id/repository/files/:file_path | 通过该接口可以获取指定项目中某个文件的内容,可以设置文件路径、分支名称、文件格式等参数。 |
| 获取项目目录内容 | GET /projects/:id/repository/tree | 通过该接口可以获取指定项目中某个目录下的文件和子目录的信息,可以设置目录路径、分支名称、递归深度等参数。 |
| 获取项目提交列表 | GET /projects/:id/repository/commits | 通过该接口可以获取指定项目的提交列表,包括提交 ID、提交者信息、提交时间、提交信息、变更文件等。 |
| 获取项目分支列表 | GET /projects/:id/repository/branches | 通过该接口可以获取指定项目的分支列表,包括分支名称、最近提交信息、保护状态等。 |
| 获取项目标签列表 | GET /projects/:id/repository/tags | 通过该接口可以获取指定项目的标签列表,包括标签名称、标签信息、提交信息等。 |
| 获取项目合并请求列表 | GET /projects/:id/merge_requests | 通过该接口可以获取指定项目的合并请求列表,包括合并请求的标题、描述、状态、提交信息等。 |
步骤3:发送 API 请求获取仓库信息
使用 JavaScript 中的 HTTP 请求库,例如 Axios、Fetch 等,发送 GET 请求到 GitLab API 获取仓库信息。以下是一个使用 Axios 库发送请求的示例代码
// 导入 Axios 库
const axios = require('axios');
// 设置 GitLab API 的基本 URL 和访问令牌
const gitlabBaseUrl = 'https://gitlab.example.com/api/v4'; // 替换为你的 GitLab 实例的 URL
const accessToken = 'YOUR_ACCESS_TOKEN'; // 替换为你的访问令牌
// 设置请求的项目 ID 和项目路径
const projectId = '1'; // 替换为你的项目 ID
const projectPath = 'your-project-path'; // 替换为你的项目路径
// 构造 API 请求 URL
const apiUrl = `${gitlabBaseUrl}/projects/${projectId}`;
// 发送 GET 请求获取项目信息
axios.get(apiUrl, {
headers: {
'Authorization': `Bearer ${accessToken}` // 使用访问令牌进行身份验证
},
params: {
'path': projectPath // 设置项目路径作为查询参数
}
})
.then(response => {
// 处理 API 响应
console.log('项目信息:', response.data);
})
.catch(error => {
// 处理错误
console.error('获取项目信息失败:', error);
});
npm工具库准备
| 库名称 | 详细功能 | 仓库链接 | npm 链接 |
|---|---|---|---|
| axios | Axios 是一个基于 Promise 的 HTTP 客户端,用于浏览器和 Node.js 环境中进行 HTTP 请求。它支持拦截器、取消请求、自动转换响应数据等丰富的功能,适用于前端和后端的 HTTP 通信。 | GitHub 仓库 | npm |
| commander | "commander" 是一个强大的 Node.js 命令行解析器,它可以帮助开发者轻松地创建命令行工具。它提供了丰富的功能,包括定义命令、选项、参数,处理用户输入,生成帮助信息等,使得创建复杂的命令行工具变得简单和高效。 | GitHub 仓库 | npm |
| consolidate | "consolidate" 是一个模板引擎的统一接口库,它可以在多个模板引擎之间进行切换,使得在应用中使用不同的模板引擎变得更加灵活和方便。"consolidate" 支持众多流行的模板引擎,如 EJS、Handlebars、Pug 等,并提供了一致的 API 来渲染模板和处理数据。 | GitHub 仓库 | npm |
| download-git-repo | "download-git-repo" 是一个用于从 Git 仓库下载代码库的 Node.js 模块。它提供了简单的 API,用于从远程 Git 仓库下载代码并将其保存到本地文件系统,支持多种协议和认证方式,并且具有下载进度、错误处理等功能。 | GitHub 仓库 | npm |
| ejs | "ejs" 是一个简单高效的 JavaScript 模板引擎,用于在 Node.js 和浏览器中生成动态 HTML 页面。它支持嵌套模板、动态数据绑定、条件和循环语句等,使得在应用中生成 HTML 变得更加简单和灵活。 | GitHub 仓库 | npm |
| inquirer | "inquirer" 是一个功能强大的 Node.js 命令行交互工具,用于与用户进行交互式的命令行界面。它支持多种问题类型,如输入框、选择器、确认框等,还可以自定义问题和答案的样式、验证用户输入等,使得在命令行中获取用户输入变得更加方便和灵活。 | GitHub 仓库 | npm |
| metalsmith | "metalsmith" 是一个简单灵活的静态网站生成器,使用 Node.js 编写,支持自定义插件和模板引擎,可以用于构建各种类型的静态网站,如博客、文档、API 文档等。 | GitHub 仓库 | npm |
| ncp | "ncp" 是一个简单而强大的 Node.js 文件复制工具,可以用于在文件系统中复制文件和目录,支持递归复制、忽略文件和目录、设置权限等功能,用于在 Node.js 应用中进行文件复制操作非常方便。 | GitHub 仓库 | npm |
| ora | "ora" 是一个简单易用的 Node.js 终端加载动画库,用于在命令行界面中展示加载状态。它支持多种样式和颜色,可以自定义加载文本、加载速度等,非常适合在 Node.js 命令行工具中显示加载状态。 | GitHub 仓库 | npm |
项目实战流程
1. 初始化项目和命令
1.1 新建项目文件夹,并初始化项目
mkdir hi-cli
cd hi-cli
npm init -y #初始化项目,得到package.json
1.2 按照目录格式新建对应文件
├── bin
│ └── www // 全局命令执行的根文件(无后缀名)
├── package.json
└── src
├── const
│ ├── index.js // 对外暴露的公共常量
│ └── private.js // 私有常量
├── lib
│ └── create.js // 入口文件
└── main.js
1.3 链接全局包
设置在命令下执行hi-cli时调用bin目录下的www文件, 编辑package.json
添加
"bin": {
"hi-cli": "./bin/www"
},
结果
{
"name": "hi-cli",
"version": "1.0.0",
"description": "",
"main": "index.js",
+ "bin": {
+ "hi-cli": "./bin/www"
+ },
"scripts": {}
...
}
www文件中使用main.js作为入口文件,并且以node环境执行此文件, 编辑www
#! /usr/bin/env node
require('../src/main.js');
链接包到全局下使用,运行
npm link
我们已经可以成功的在命令行中使用hi-cli命令,并且可以执行main.js文件!
为了测试可以在main.js里面添加测试脚本
console.log("Welcome to hi-cli");
则
2. 解析命令行参数
一般我们使用脚手架命令,会添加参数,比如
hi-cli create my-project
或者使用
hi-cli --help
来查看该命令有什么帮助选项。这些都能使用commander来实现
2.1 使用commander
npm install commander
编辑main.js
const program = require('commander');
program.version('0.0.1')
.parse(process.argv); // process.argv就是用户在命令行中传入的参数
这时候运行hi-cli --help会看到自动生成的help信息
运行hi-cli -V会看到我们配置的版本号0.0.1
保持版本和我们脚手架package.json文件中的一致,我们提取package.json文件中的版本及项目名字。
编辑const/index.js文件
const { name, version } = require("../../package.json");
module.exports = {
cliName: name,
version,
};
接着在main.js中引入该参数
const program = require("commander");
const { version } = require("./const");
program.version(version).parse(process.argv);
这时候运行命令就会得到
2.2 初始化命令,获取用户终端参数,比如项目文件名
根据我们想要实现的功能配置执行动作,遍历产生对应的命令,同时添加监听help命令,打印帮助信息。编辑文件main.js
const program = require("commander");
const path = require("path");
const { version, cliName } = require("./const");
// 定义命令
const actionsMap = {
create: {
description: "create project",
alias: "cr",
examples: [`${cliName} create <template-name>`],
},
"*": {
alias: "",
description: `command not found, please use \n ${cliName} --help`,
},
};
Object.keys(actionsMap).forEach((action) => {
program
.command(action)
.alias(actionsMap[action].alias)
.description(actionsMap[action].description)
.action(() => {
if (action === "*") {
console.log(actionsMap[action].description);
} else {
const argv = process.argv.slice(3);
// 当使用create命令,获取用户输入后,当作参数调用 lib/create.js
// 这时候我们关注create.js文件即可
require(path.resolve(__dirname, `lib/${action}`))(...argv);
}
});
});
// 添加监听help命令,打印帮助信息
program.on("--help", () => {
console.log("\n Examples");
Object.keys(actionsMap).forEach((action) => {
(actionsMap[action].examples || []).forEach((example) => {
console.log(` ${example}`);
});
});
});
program.version(version).parse(process.argv);
这时候就会得到
2.3 create命令逻辑编写
当我们拿到项目名称时候,就调用create.js, 创建Creator实例,调用create方法。 create方法主要步骤如下:
graph TD
从拿到create命令的projectName参数开始 --> 1.判断当前目录下是否有重名文件 --> 2.获取当前组织下的所有仓库列表 --> 3.用户选择需要下载的模板仓库 --> 4.决定是依据标签还是分支来获取具体版本代码 --> 5.根据选择结果获取资源列表 --> 6.选定资源模板后进行下载,会判断是否下载过该模板 --> 7.模板下载完成后,获取用户定制化参数,渲染模板,并在当前目录生成新项目 --> 完成
编辑文件create.js
// 各种工具库,详细见工具库介绍章节
const axios = require("axios"); // 接口请求封装
const path = require("path");
const chalk = require("chalk"); // 终端输出字符美化
const ora = require("ora"); // 加载
const fs = require("fs");
const Inquirer = require("inquirer"); // 交互式命令行。
const { promisify } = require("util");
const MetalSmith = require("metalsmith"); // 遍历文件夹 找需不需要渲染
let { render } = require("consolidate").ejs;
render = promisify(render);
const { token, url, clonePre, downloadDirectory } = require("../const");
let downLoadGit = require("download-git-repo");
downLoadGit = promisify(downLoadGit);
let ncp = require("ncp");
ncp = promisify(ncp);
class Creator {
// 从拿到create命令的projectName参数开始
constructor(projectName) {
this.projectName = projectName;
}
async create() {
// 1.判断当前目录下是否有重名文件
const currentPath = path.resolve(this.projectName);
const continued = await this.checkFileExistAndIfOverwrite(currentPath);
if (!continued) {
console.log(chalk.cyan("Nothing happened, bye"));
return;
}
// 2.获取当前组织下的所有仓库列表
const repos = await this.wrapFetchAddLoding(
this.fetchRepoList,
"fetching repo list"
)();
// 3.用户选择需要下载的模板仓库
const templates = repos
.map((item) => ({
name: item.name,
value: `${item.name}_${item.id}`,
}))
.filter((item) => item.name.includes("template"));
const { template } = await Inquirer.prompt({
name: "template",
type: "list",
message: "please choose template to create project",
choices: templates, // 选择模式
});
let [templateName, templateId] = template.split("_");
// 4.决定是依据标签还是分支来获取具体版本代码
const { type } = await Inquirer.prompt({
name: "type",
type: "list",
message: "Which resource do you want, branches or tags",
choices: [
{
name: "branches",
value: "branches",
},
{
name: "tags",
value: "tags",
},
], // 选择模式
});
// 5.根据选择结果获取资源列表,
let resources = await this.wrapFetchAddLoding(
this.fetchRepoResourceList,
`fetching ${type} list`
)(templateId, type);
// 可能是tag列表,或者是分支列表
resources = resources.map((item) => item.name);
const { resource } = await Inquirer.prompt({
name: "resource",
type: "list",
message: "please choose template to create project",
choices: resources, // 选择模式
});
// 6.选定资源模板后进行下载,会判断是否下载过该模板
const result = await this.download(templateName, resource);
console.log("downloaded in\n", result);
// 7.模板下载完成后,获取用户定制化参数,渲染模板,并在当前目录生成新项目
try {
await this.renderTemplate(result, currentPath);
console.log(
chalk.green(
`Your project has been created successfully in ${currentPath}`
)
);
} catch (e) {
console.log(chalk.red("error", e));
}
}
// 完成。
// 以下是封装的函数
// 获取仓库列表
async fetchRepoList() {
const { data } = await axios.get(url, {
headers: {
"PRIVATE-TOKEN": token,
},
});
return data;
}
// type: tags | branches
async fetchRepoResourceList(id, type) {
const { data } = await axios.get(`${url}/${id}/repository/${type}/`, {
headers: {
"PRIVATE-TOKEN": token,
},
});
return data;
}
// 下载项目
async copyToMyProject(currentPath) {
await ncp(target, currentPath);
}
// 判断当前文件夹下是否有重名文件,如果有则咨询是否覆盖,覆盖则删掉原有的目录
async checkFileExistAndIfOverwrite(dest) {
if (fs.existsSync(dest)) {
const { action } = await Inquirer.prompt([
{
name: "action",
type: "list",
message: "File exists, do you want to overwrite or cancel?",
choices: [
{
name: "overwrite",
value: "overwrite",
},
{
name: "cancel",
value: false,
},
],
},
]);
if (action === "overwrite") {
console.log(chalk.yellow("Removing the file..."));
fs.rmSync(dest, { recursive: true });
return true;
} else {
return false;
}
}
return true;
}
// 用克隆的方式从gitlab下载项目
async download(template, resource) {
let api = `${clonePre}/${template}${resource ? "#" + resource : ""}`;
const dest = `${downloadDirectory}/${template}#${resource}`; // 将模板下载到对应的目录中
// 判断是否已经下载过该模板
const continued = await this.checkFileExistAndIfOverwrite(dest);
// 如果没有删除,则停止后续下载操作。
if (!continued) return dest;
await this.wrapFetchAddLoding(downLoadGit, "Downloading the template")(
api,
dest,
{
clone: true,
}
);
return dest; // 返回下载目录
}
// 7.模板下载完成后,获取用户定制化参数,渲染模板,并在当前目录生成新项目
async renderTemplate(result, currentPath) {
// 7.1.下载完成后判断模板是否有ask.json文件,是的话就是带有模板需要渲染的目录
const askFileName = "ask.json";
const askPath = path.join(result, askFileName);
// 如果不是带模板的项目,直接拷贝
if (!fs.existsSync(askPath)) {
// 将下载的文件拷贝到当前执行命令的目录下
await this.wrapFetchAddLoding(copyToMyProject, currentPath);
} else {
// 7.2.是的话则根据json文件获取用户定制化参数
await new Promise((resovle, reject) => {
MetalSmith(__dirname) // 如果你传入路径 他默认会遍历当前路径下的src文件夹
.source(result)
.destination(currentPath)
.use(async (files, metal, done) => {
// 根据ask.json询问用户
const args = require(askPath);
const result = await Inquirer.prompt(args);
const data = metal.metadata();
Object.assign(data, result);
// 删掉复制项目的ask.json文件
delete files[askFileName];
done();
})
.use((files, metal, done) => {
const data = metal.metadata();
Reflect.ownKeys(files).forEach(async (file) => {
// 根据项目需求过滤你需要渲染的模板
if (
file.includes(".js") ||
file.includes(".json") ||
file.includes(".env") ||
file.includes(".md")
) {
let content = files[file].contents.toString(); // 文件的内容
if (content.includes("<%")) {
content = await render(content, data);
files[file].contents = Buffer.from(content); // 渲染
}
}
});
// 根据用户的输入 下载模板
done();
})
.build((err) => {
if (err) {
reject(err);
} else {
resovle();
}
});
});
}
}
// 对于promise函数,在开始时候开启loading提示,更用户友好
wrapFetchAddLoding(fn, message) {
return async (...args) => {
const spinner = ora(message);
spinner.start(); // 开始loading
let r;
try {
r = await fn(...args);
spinner.succeed(); // 结束loading
} catch (e) {
spinner.fail(); // 结束loading
}
return r;
};
}
}
module.exports = async (projectName) => {
const creator = new Creator(projectName);
creator.create();
};
以上用到了很多常量,我们需要编辑const/index.js
const { token, url, clonePre } = require("./private");
// 存放用户的所需要的常量
const { version, name } = require("../../package.json");
// 存储模板的位置, for macOS
const downloadDirectory = `${
process.env[process.platform === "darwin" ? "HOME" : "USERPROFILE"]
}/.template`;
module.exports = {
version,
downloadDirectory,
cliName: name,
// private
token,
url,
clonePre,
};
以及根据团队gitlab信息编辑const/private.js
// for templates group
// 你创建的 GitLab 的访问令牌(Access Token)
const token = "";
// 假设你的公司gitlab地址为, https://gitlab.company.vip
// 那么url就为 https://gitlab.company.vip/api/v4/projects
const url = "";
// 同时你的group为templates,
// 那么clonePre就为 gitlab.company.vip:templates
const clonePre = "";
module.exports = { token, url, clonePre };
配置完这些,就完成了cli项目的代码,那么你就可以在公司的npm私有库发布你的cli脚手架,要保证用户有模板仓库的访问权限,这部分就没有教程了。
模板代码和ask.json
模板代码,举例如果要配置package.json。
{
"name": "my-project",
"version": "0.1.0",
"private": true,
"autor": "<%=author%>",
"description": "<%=description%>",
"license": "<%=license%>",
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"core-js": "^3.8.3",
"vue": "^2.6.14"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"vue-template-compiler": "^2.6.14"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}
ask.json如下
[
{
"type": "input",
"name": "author",
"message": "author?"
},
{
"type": "input",
"name": "description",
"message": "description?"
},
{
"type": "input",
"name": "license",
"message": "license?"
}
]
注意到我们在模板仓库里,需要配置的地方用到编写方式 <%=${paramName}%>,在ask.json里就是name属性值。 根据自己的需要,在相应的文件里添加需要的自定义配置。同时注意修改渲染部分脚本,增加需要扫描的文件类型。
// 根据项目需求过滤你需要渲染的模板
if (
file.includes(".js") ||
file.includes(".json") ||
file.includes(".env") ||
file.includes(".md")
) {
let content = files[file].contents.toString(); // 文件的内容
if (content.includes("<%")) {
content = await render(content, data);
files[file].contents = Buffer.from(content); // 渲染
}
}
优化代码介绍
1.文件夹是否存在判断
使用fs库,来判断是否当前目录下存在相同文件
// 判断当前文件夹下是否有重名文件,如果有则咨询是否覆盖,覆盖则删掉原有的目录
async checkFileExistAndIfOverwrite(dest) {
if (fs.existsSync(dest)) {
const { action } = await Inquirer.prompt([
{
name: "action",
type: "list",
message: "File exists, do you want to overwrite or cancel?",
choices: [
{
name: "overwrite",
value: "overwrite",
},
{
name: "cancel",
value: false,
},
],
},
]);
if (action === "overwrite") {
console.log(chalk.yellow("Removing the file..."));
fs.rmSync(dest, { recursive: true });
return true;
} else {
return false;
}
}
return true;
}
2.对于需要花费较长时间处理的操作,添加加载中提示
使用ora库,创建spinner
// 对于promise函数,在开始时候开启loading提示,更用户友好
wrapFetchAddLoding(fn, message) {
return async (...args) => {
const spinner = ora(message);
spinner.start(); // 开始loading
let r;
try {
r = await fn(...args);
spinner.succeed(); // 结束loading
} catch (e) {
spinner.fail(); // 结束loading
}
return r;
};
}
3.使用chalk库优化提示文字
可以自行定义