1、为什么要做自定义脚手架
前端日常开发中有各种各样的脚手架:比如vue-cli, create-react-app, vite还有其他很多社区的脚手架。通过这些脚手架我们可以很快的生成需要的基础项目模版。但是这些脚手架都注重通用性,而在具体的公司业务中项目多了会封装很多项目模版(多端H5、PC、小程序等),为了更方便的拉取所需的模版,这时候就需要我们自己写一个cli脚手架工具,来更加高效的进行项目开发,减少重复操作!
2、自定义脚手架基本原理
工作中会经常使用到cli脚手架,对它的使用方式肯定不陌生,一般cli脚手架都有以下特点:
- 可以通过npm i xxx-cli -g全局安装,然后在命令行直接使用安装的脚手架创建项目。
- 不全局安装可以通过npx xxx-cli直接从npm上访问该脚手架
- 在命令行执行脚手架时,可以在命令行直接传参数,脚手架会把参数传到配置中。
- 脚手架会出来交互式的命令行让我们选择不同的选项,并且获取到选择的内容。
- 最后根据最终选择结果生成下载对应的模版。
有了上面的cli脚手架的核心逻辑,我们就来实现一个cli脚手架。
3、核心功能
1、bin字段解析
在 package.json 中的 bin 字段,用以指定最终的命令行工具的名字,用作该 npm 包可执行文件的入口。以 vite 为例
{
"bin": {
"vite": "bin/vite.js"
}
}
vite 是最终在终端执行的命令,而 ./bin/vite.js 是该命令实际执行的脚本文件。
对于最终可执行的命令行工具,node.js 项目一般倾向置文件于 bin 目录下,再如以下 Typescript 的命令行配置:
{
"bin": {
"tsc": "./bin/tsc",
"tsserver": "./bin/tsserver"
},
}
2、快速搭建脚手架项目
1、初始化
mkdir xxx
cd xxx
npm init -y
2、全局访问
更目录新建bin/index.js
#! /usr/bin/env node
console.log('zkz~~~~')
- #! /usr/bin/env node 告诉操作系统用node环境来执行index.js文件
3、配置package.json
"bin": {
"zkz": "./bin/index.js"
}
4、npm link 软链接 使得 zkz可以全局访问
npm link 原理简要说明:执行
npm link,它会自动寻找当前目录的package.json中的name字段,并创建全局目录(~/.config/npm/link)软链接至当前项目,然后项目中可以使用该模块
5、完毕可以访问
3、自定义命令行交互(核心)
在使用cli脚手架的时候,通常有一些些命令,比如--version, --help来查看版本和帮助信息等
1、配置可执行命令commander:
// 解析用户输入的参数
program.parse(process.argv);
// 获取版本号
program
.version(require("../package.json").version)
.usage("<command> [options]");
// 同理配置 create 命令
program
.command("create <app-name>")
.description("create a new project powered by zkz-cli")
.option("-f, --force", "overwrite target directory if it exist")
.action((name, options) => {
// 调用create模块
require("../lib/create.js")(name, options);
// console.log("%c Line:12 🥖 name, options", "color:#42b983", name, options);
});
2、实现命令行交互inquirer:
当进行了步骤1、配置可执行命令之后,我们要去解析用户输入参数: 新建lib/create.js
const path = require("path");
const fs = require("fs-extra");
const inquirer = require("inquirer");
const Creator = require("./Creator");
module.exports = async (projectName, options) => {
console.log("%c Line:6 🥓 options", "color:#ed9ec7", options);
// 1. 创建项目
const cwd = process.cwd(); //获取当前命令执行时的工作目录
const targetDir = path.join(cwd, projectName); // 目标目录
if (fs.existsSync(targetDir)) {
// 如果目标目录已经存在
if (options.force) {
// 如果用户传入了 --force 参数,则删除已存在的目录
await fs.remove(targetDir);
} else {
// 提示用户目标目录已存在, 是否覆盖
let { action } = await inquirer.prompt([
{
name: "action",
type: "list",
message: "Target directory already exists Pick an action:",
choices: [
{ name: "Overwrite", value: "overwrite" },
{ name: "Cancel", value: false },
],
},
]);
// 取消创建
if (!action) {
return;
} else if (action === "overwrite") {
// 移除已存在的目录
console.log(`\r\nRemoving...😊`);
await fs.remove(targetDir);
}
}
}
// 创建项目, 封装为Creator类
const creator = new Creator(projectName, targetDir);
creator.create(); // 开始创建项目
};
3、远程下载模版
接上一步开始创建项目后,这里我们选择远程下载项目模版,可分为三步
class Creator {
constructor(projectName, targetDir) {
this.name = projectName;
this.target = targetDir;
// promisify downloadGitRepo, 使其返回promise
this.downloadGitRepo = util.promisify(downloadGitRepo);
}
async fetchRepo() {
...
}
async fetchTag(repo) {
...
}
async download(repo, tag) {
...
}
async create() {
// 开始创建项目
// 远程获取模版github
// 1、 获取当前组织下的模版
let repo = await this.fetchRepo();
// 2、 获取模版的版本号
let tag = await this.fetchTag(repo);
// 3、 下载模版到模版目录
let downloadUrl = await this.download(repo, tag);
// 4、编译模版
}
}
- 获取当前组织下的模版
- 获取模版的版本号
- 下载模版到模版目录
涉及到的github api 官网地址
| 功能 | api地址 | 请求方式 | 请求参数 | 返回参数 |
|---|---|---|---|---|
| 获取用户信息 | api.github.com/users/ | get | path路径: 用户名 | 一个用户对象 |
| 获取用户所有仓库 | api.github.com/users/{用户名}… | get | path路径: 用户名 | 返回一个数组 |
| 获取某个仓库的详细信息 | api.github.com/repos/{用户名}… | get | path路径: 用户名 和 仓库名 | 返回一个仓库对象 |
| 获取某个仓库里根目录文件或文件夹数组 | api.github.com/repos//{用户名… | get | path路径: 用户名 和 仓库名 | 返回一个首层文件或文件夹数组 |
| 获取某个仓库里子目录文件或文件夹数组 | api.github.com/repos//{用户名… | get | path路径: 用户名 和 仓库名和文件名或文件夹名 | 返回一个文件数组 |
| 获取某文件的原始内容(Raw) | 1. 通过上面的文件信息中提取download_url这条链接,就能获取它的原始内容了。2. 或者直接访问:raw.githubusercontent.com/{用户名}/{仓库名}… | get | path路径: 用户名 和 仓库名和文件l路径 | 返回一个文件内容的字符串 |
| 获取某个用户的跟随者列表 | api.github.com/users/{用户名}… | get | path路径: 用户名 | 返回一个数组 |
| 获取某个用户正在关注谁列表 | api.github.com/users/{用户名}… | get | path路径: 用户名 | 返回一个数组 |
| 获取某个用户加入的组织列表 | api.github.com/users/{用户名}… | get | path路径: 用户名 | 返回一个数组 |
| repo中所有的commits列表 | api.github.com/repos/{用户名}… | get | - | - |
| 某一条commit详情 | api.github.com/repos/{用户名}… | get | - | - |
| issues列表 | api.github.com/repos/{用户名}… | get | - | - |
| 某条issue详情 | api.github.com/repos/{用户名}… | get | issues都是以1,2,3这样的序列排号的 | - |
| 某issue中的comments列表 | api.github.com/repos/{用户名}… | get | - | - |
| 某comment详情 | api.github.com/repos/{用户名}… | get | 评论ID是从issues列表中获得的 | - |
4、 编译模版 + 自动安装依赖 + 最终提示
给项目模板添加变量占位符 模板引擎我选择handlebars
async templateCompilation() {
// 初始化package.json命令行交互
let answers = await inquirer.prompt([
...
]);
const pathString = `${this.name}/package.json`;
// 读取package.json
let packCont = fs.readFileSync(pathString, "utf8");
console.log("%c Line:112 🥟 packCont", "color:#465975", packCont.script);
let compileCont = handleBars.compile(packCont)(answers);
// 重新写入
fs.writeFileSync(pathString, compileCont);
chalk.green("Initialization template completed, You are so great!");
console.log(chalk.yellowBright('start install dependencies...'))
//依赖安装
await install({
cwd: path.join(process.cwd(), this.name),
package: answers.package,
}).then(async () => {
// 最终 console 提示信息
console.log(chalk.gray(zkzCli));
console.log()
console.log('We suggest that you begin by typing:')
console.log()
console.log(chalk.cyan('cd'), this.name)
console.log(`${chalk.cyan(`${answers.package} run dev`)}`)
})
}
4、优化+缺陷:
1、美化
- ora 加载 loading
const ora = require("ora");
// 睡眠函数
function sleep(n) {
return new Promise((resolve, reject) => setTimeout(resolve, n));
}
async function waitFnLoading(fn, message, ...args) {
const spinner = ora(message);
spinner.start();
try {
let result = await fn(...args);
spinner.succeed("Loaded successfully");
return result;
} catch (e) {
if (e.message.includes("download failed")) {
spinner.fail("request failed, refetch ...");
// 休息1秒
await sleep(2000);
spinner.stop();
// 重新请求
return waitFnLoading(fn, message, ...args);
} else {
spinner.fail(e.message);
throw e;
}
}
}
module.exports = {
waitFnLoading,
sleep,
};
- chalk 文本样式
console.log(chalk.gray(zkzCli));
console.log(`Done. Now run:\r\n`);
console.log(chalk.green(`cd ${this.name}`));
console.log(chalk.blue("npm install"));
console.log(chalk.magenta("npm run dev\r\n"));
2、最终输出的logo
- 可借助:figlet
3、多次下载相同模版时需要缓存
这里耗时有点久,可以考虑缓存:
async download(repo, tag) {
// 定义缓存模板的路径
const cachePath = path.join(this.cacheDir, repo + (tag ? "#" + tag : ""));
// 检查缓存模板是否存在
if (fs.existsSync(cachePath)) {
// 缓存命中,则从缓存复制到目标目录
fse.copySync(cachePath, this.target);
return this.target;
}
// 缓存未命中,则下载模板
let requestUrl = `CAN1177Silva/${repo}${tag ? "#" + tag : ""}`;
await waitFnLoading(
() => this.downloadGitRepo(requestUrl, this.target),
"waiting download template"
);
// 将下载的模板复制到缓存目录
fse.copySync(this.target, cachePath);
return this.target;
}
5、小结
1、思考:
1、换个思路,是否可以做一个前端研发平台,里面集成脚手架的功能,但是这个只是一部分,之后可以扩展更多的功能。专注于研发提效:
- 比如我们现在有很多npm组件,单纯靠组件文档很鸡肋,不知道项目和组件的依赖关系、组件查找也不方便: 是否可以在前端研发平台内置组件管理模块,然后有个chrome插件去看依赖信息,比如政采云的chrome插件‘天桥’ :显示⻚⾯所属项⽬信息、依赖远端服务组件信息
- 同样这里也可以内置模版管理、项目发布等
2、可以支持自定义配置下载模板? 比如, 其他团队, 有自己的模板, 只需要添加选项和git的下载地址, 就可以使用
2、自定义脚手架的优缺点:
优点:
- 自定义:可以根据团队的特定需求制定具体的框架,语言和工具,使生成的项目更加匹配团队的技术栈和工作流程。
- 效率:通过快速生成预定模板的方式,减少了新项目启动的准备时间。
- 规范:脚手架提供了一种规范化的开发模式,可以使团队的代码更具可读性和可维护性。
缺点:
- 维护成本:随着技术的发展,脚手架可能需要定期更新和维护,这会带来额外的时间和成本。
- 学习曲线:新加入的团队成员可能需要一段时间来适应和学习这个特制的工具。
- 依赖:如果脚手架太过定制,可能导致团队过度依赖特定的工具和流程,失去灵活性。
目前次cli比较简陋,只是一个入门版本, 小开发团队使用基本足够,如果是团队工程需要多种脚手架管理可以使用专门做脚手架的生态工具 yeoman