前言
看这篇文章之前,你肯定会疑惑,为什么你会写一个属于自己的脚手架?
脚手架相比大家都不陌生,比如我们经常使用 vue-cli
,它可以帮助我们快速的初始化一个项目,无需从零配置,极大方便我们的开发。但强大是有限的,公共的脚手架有时并不能满足我们的实际开发。
史上最贴心前端脚手架开发辅导 的作者大佬讲到几点理由,小包认为讲的很好:
公司中已经积累了部分项目逻辑,例如换肤、接口请求、项目架构、国际化等,如果此时公司新启一个项目,我们需要将原有项目的公共逻辑 ctrl + c
, ctrl + v
。
但这种复制粘贴的方式是存有很多缺点的:
重复性劳动,繁琐且浪费时间
容易忽略项目中的配置设置
copy
过来的模板会存有重复代码
诸如此类,如果我们自己开发一套脚手架,自己定制自己的模板,复制粘贴的人工流程就会转换为 cli
的自动化流程。怎么样,心动吗?
但对小包来说,小包的工作经验并没有那么多,还考虑不到工作中的种种繁琐,小包有自己的想法,主要有几点:
vue-cli
的痛击 : 小包最近使用 vue-cli
创建项目时总是爆出莫名其妙的错误,解决方案一直未能查找到,每次只能卸载重装一遍然后才能正常使用
多个 cli
的繁琐: 最近小包也开始学习 react
,小包一直幻想如果两者使用一个脚手架就好了
架构成长: 架构这两个字太吸引人了,脚手架的搭建在小包心里一直是架构的必备技能。
nodejs
成长: 利用 nodejs
实现一个脚手架也是对自己 nodejs
水平的一大锻炼。
学习本文,你能收获:
🌟 掌握开发脚手架的全流程
🌟 学会命令行开发常用的多种第三方模块
🌟 拥有一个属于自己的脚手架
文章最开始,我们可以先看一下 zc-cli
的功能展示。
脚手架实现分析
我们以 vue-cli
为例子,来分析一下简单脚手架需要具备的一些功能:
vue-cli
使用 vue
作为全局命令,同时提供了很多指令。
vue 创建项目
我们以 vue
创建项目为例子,分析一下脚手架应该具备的功能
Step1: 运行创建命令
Step2: 交互式用户选择
用户可以在命令行中选择自己需要的版本或者配置。
Step3: 用户选择完毕后,根据用户选择生成用户需求的项目文件
从上面的脚手架流程来看,我们可以大致总结出脚手架的功能:
流程分析
基于 vue-cli
的使用经验,我们来分析一下脚手架的基本实现流程:
首先我们要初始化一个项目
创建项目 zc-cli
,配置项目所需的信息
npm link
项目至全局,这样本地可以临时调用指令
项目开发
基础指令配置: 例如 --help --version
等
复杂指令配置: create
指令
实现命令行交互功能: 基于 inquirer
实现命令行交互
拉取项目模板
根据用户的选择动态生成项目
使用的三方库
我们在开发 cli
时,会用到很多第三方模块,接下来先给大家介绍一下使用的第三方模块。为了介绍起来更加清晰,我们先来创建一个 demo
项目用作演示。
初始化 demo 项目
创建 demo
文件夹,执行 npm init -y
初始化仓库,生成 package.json
文件
{
"name": "demo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
复制代码
在 demo
下创建 bin
文件夹,并在里面创建 node
入口文件 enter
编辑 enter
文件,并将其配置到 package.json
中的 bin
字段
// enter
#! /usr/bin/env node
// 为了方便测试
console.log("hello demo");
复制代码
// package.json
// bin 字段也支持对象模式配置
"bin": "bin/enter",
复制代码
为什么需要在文件头部添加 #! /usr/bin/env node?
npm link
到全局
在 demo
文件目录下运行 npm link
将项目链接到本地环境,就可以临时实现 demo
指令全局调用。(--force
参数可以强制覆盖原有指令)
运行 demo
命令,命令行成功打印出 hello demo
。 demo
项目配置成功。
commander —— 命令行指令配置
第三方库 commander
来实现脚手架命令的配置。更多详细信息可以参考commander 中文文档
Step1: 安装 commander
依赖,并导入 demo
项目中
// 安装依赖
npm install commmander
复制代码
// enter
const program = require("commander");
// 解析用户执行时输入的参数
// process.argv 是 nodejs 提供的属性
// npm run server --port 3000
// 后面的 --port 3000 就是用户输入的参数
program.parse(process.argv);
复制代码
commander
自身附带了 --help
指令,导入成功后,在命令行执行 demo --help
,可以打印出基本的帮助提示。
Step2: version 方法可以配置版本信息提示
Step3: name 和 usage 方法分别配置 cli 名称和 --help 第一行提示
program.name("demo").usage(`<command> [option]`).version(`1.0.0`);
复制代码
再次执行 demo --help
,命令行的消息提示就比较完善了。
更复杂的方法我们边使用边介绍。
chalk —— 命令行美化工具
chalk 可以美化我们在命令行中输出内容的样式,例如实现多种颜色,花里胡哨的命令行提示等。
Step1: 首先先安装 chalk 依赖并引入
Step2: 就可以开始输出各种花里胡哨的命令行提示
//enter
const chalk = require("chalk");
console.log(`hello ${chalk.blue("world")}`);
console.log(chalk.blue.bgRed.bold("Hello world!"));
console.log(
chalk.green(
"I am a green line " +
chalk.blue.underline.bold("with a blue substring") +
" that becomes green again!"
)
);
复制代码
怎么样,够花里胡哨吧。但安装 chalk 时一定要注意安装 4.x 版本(小包使用的是 4.0.0),否则会因为版本过高,爆出错误
inquirer —— 命令行交互工具
上面我们再使用 vue create
命令时,其中有一个步骤是交互式用户选择,这个交互式功能就是由 inquirer
实现的。
inquirer 支持 Confirm
确认,List
单选,Checkbox
多选等多种交互方式。
这里我们来模拟实现 vue
的多选功能:
new Inquirer.prompt([
{
name: "vue",
// 多选交互功能
// 单选将这里修改为 list 即可
type: "checkbox",
message: "Check the features needed for your project:",
choices: [
{
name: "Babel",
checked: true,
},
{
name: "TypeScript",
},
{
name: "Progressive Web App (PWA) Support",
},
{
name: "Router",
},
],
},
]).then((data) => {
console.log(data);
});
复制代码
ora —— 命令行 loading 效果
ora
使用非常简单,可以直接看下面的案例。更多使用: ora 文档
利用 ora
来实现一个简单的命令行 loading
效果。
const ora = require("ora");
// 定义一个loading
const spinner = ora("Loading unicorns");
// 启动loading
spinner.start();
setTimeout(() => {
spinner.color = "yellow";
spinner.text = "Loading rainbows";
}, 1000);
// loading 成功
spinner.succeed();
// loading 失败
spinner.fail();
复制代码
fs-extra —— 更友好的文件操作
fs-extra 模块是系统 fs
模块的扩展,提供了更多便利的 API
,并继承了 fs
模块的 API
。比 fs
使用起来更加友好。
download-git-repo —— 命令行下载工具
download-git-repo 可以从 git
中下载并提取一个 git repository
。
download-git-repo
仓库提供 的 download
函数接收四个参数(下面代码是 download-git-repo 源码中截取的):
/**
* download-git-repo 源码
* Download `repo` to `dest` and callback `fn(err)`.
*
* @param {String} repo 仓库地址
* @param {String} dest 仓库下载后存放路径
* @param {Object} opts 配置参数
* @param {Function} fn 回调函数
*/
function download(repo, dest, opts, fn) {}
复制代码
注意: download-git-repo
不支持 Promise
figlet —— 生成基于 ASCII 的艺术字
figlet 模块可以将 text
文本转化成生成基于 ASCII
的艺术字。具体效果不好解释,直接来看效果。
// enter 入口文件
console.log(
"\r\n" +
figlet.textSync("demo", {
font: "Ghost",
horizontalLayout: "default",
verticalLayout: "default",
width: 80,
whitespaceBreak: true,
})
);
复制代码
figlet
提供了多种字体,可以去官网选择你喜欢的字体。
命令配置
初始化项目部分与上文类似,就不多做赘述,直接进入正文。
配置版号
commander
提供了 version
方法,.version()
方法可以设置版本,其默认选项为 -V
和 --version
,设置了版本后,命令行会输出当前的版本号。
// package.json 中存取了项目的版本号 version
// 直接使用该属性
program.version(`zc-cli ${require("../package.json").version}`);
复制代码
在命令行执行 zc-cli --version
配置首行提示
commander
还提供了 .usage
和 .name
方法,通过这两个选项可以修改帮助提示的首行文字。利用这两个方法修改一下 --help
的首行提示。
// name 是配置脚手架名称
// usage 是配置命令格式
program.name("zc-cli").usage(`<command> [option]`);
复制代码
执行 zc-cli --help
,现在 --help
的打印就完善多了。
Usage: zcxiaobao <command> [option]
Options:
-V, --version output the version number
-h, --help display help for command
复制代码
配置 create 命令
commander
提供了 command
方法, command
方法的第一参数为命令名称,命令参数跟随在名称后面(必选参数使用 <>
表示,可选参数使用 []
表示)
我们来配置 create
命令,该命令负责创建项目。同时在这里我们添加 --force
参数,默认覆盖当前项目。(关于存在同名目录的情况,后文有详细处理)
option
方法可以定义选项,同时可以附加选项的简介。每个选项可以定义一个短选项名称(-后面接单个字符)和一个长选项名称(--后面接一个或多个单词),使用逗号、空格或|分隔。
program
.command("create <project-name>") // 增加创建指令
.description("create a new project") // 添加描述信息
.option("-f, --force", "overwrite target directory if it exists") // 强制覆盖
.action((projectName, cmd) => {
// 处理用户输入create 指令附加的参数
console.log(projectName, cmd);
});
复制代码
我们来测试一下 create
命令是否添加成功。
通过 --help
查看到 create [options] <project-name>
,接下来来测试一下 create
的功能。
$ zc-cli create
error: missing required argument 'project-name'
$ zc-cli create xxx
xxx {}
$ zc-cli create xxx --force
xxx { force: true }
复制代码
成功获取到命令行输入的参数信息。Yes!!!
配置 config 命令
脚手架中 config
命令也是经常使用的,因此我们再添加个 config
命令,同时也熟练一下 commander
的使用。
program
.command("config [value]") // config 命令
.description("inspect and modify the config")
.option("-g, --get <key>", "get value by key")
.option("-s, --set <key> <value>", "set option[key] is value")
.option("-d, --delete <key>", "delete option by key")
.action((value, keys) => {
// value 可以取到 [value] 值,keys会获取到命令参数
console.log(value, keys);
});
复制代码
优化 --help 提示
执行 vue --help
,我们可以发现帮助提示部分最下面还有一句提示,并且高亮了 vue <command> --help
,人性化啊,我们来模仿一下。
给我们 zc-cli
也添加上此功能:
commander
可以自动通过 on
方法来监听指令执行。
// 监听 --help 指令
program.on("--help", function () {
// 前后两个空行调整格式,更舒适
console.log();
console.log(
" Run zc-cli <command> --help for detailed usage of given command."
);
console.log();
});
复制代码
执行 zc-cli --help
测试一下:
给 --help 提示上色
文章第三方模块处提到 chalk
,可以美化命令行,因此我们利用 chalk
将 zc-cli <command> --help
高亮一下。
// 使用 cyan 颜色
program.on("--help", function () {
// 前后两个空行调整格式,更舒适
console.log();
console.log(
`Run ${chalk.cyan(
"zc-cli <command> --help"
)} for detailed usage of given command.`
);
console.log();
});
复制代码
命令行执行 zc-cli --help
,我们就可以看到高亮的 zc-cli <command> --help
。
指令配置部分暂时就可以告一段落,休息休息,进入核心部分。🎉🎉🎉
创建项目
create 模块
我们为创建功能单独建立一个模块,存放在 lib/create.js
中,同时在 zc
入口文件配置 create
指令处引入
// zc 入口文件
program
.command("create <project-name>") // 增加创建指令
.description("create a new project") // 添加描述信息
.option("-f, --force", "overwrite target directory if it exists") // 强制覆盖
.action((projectName, cmd) => {
// 引入 create 模块,并传入参数
require("../lib/create")(projectName, cmd);
});
// create.js
// 当前函数中可能存在很多异步操作,因此我们将其包装为 async
module.exports = async function (projectName, options) {
console.log(projectName, options);
};
复制代码
我们来测试一下 create
模块是否可以接收到参数。
$ zc-cli create xxx --force
xxx { force: true }
复制代码
存在同名目录
创建 create
命令时我们配置了 --force
参数,意为强制覆盖。那我们我们在创建一个项目目录时,就会出现三种情况:
创建项目时使用 --force
参数,不管是否有同名目录,直接创建
未使用 --force
参数,且当前工作目录中不存在同名目录,直接创建
未使用 --force
参数,且当前工作目录中存在同名项目,需要给用户提供选择,由用户决定是取消还是覆盖
我们来梳理一下这部分的实现逻辑:
通过 process.cwd
获取当前工作目录,然后拼接项目名得到项目目录
检查是否存在同名目录
存在同名目录
用户创建项目时使用了 --force
参数,直接删除同名目录
未使用 --force
参数,给用户提供交互选择框,由用户决定
不存在同名目录,继续创建项目
const path = require("path");
const fs = require("fs-extra");
const Inquirer = require("inquirer");
module.exports = async function (projectName, options) {
// 获取当前工作目录
const cwd = process.cwd();
// 拼接得到项目目录
const targetDirectory = path.join(cwd, projectName);
// 判断目录是否存在
if (fs.existsSync(targetDirectory)) {
// 判断是否使用 --force 参数
if (options.force) {
// 删除重名目录(remove是个异步方法)
await fs.remove(targetDirectory);
} else {
let { isOverwrite } = await new Inquirer.prompt([
// 返回值为promise
{
name: "isOverwrite", // 与返回值对应
type: "list", // list 类型
message: "Target directory exists, Please choose an action",
choices: [
{ name: "Overwrite", value: true },
{ name: "Cancel", value: false },
],
},
]);
// 选择 Cancel
if (!isOverrite) {
console.log("Cancel");
return;
} else {
// 选择 Overwirte ,先删除掉原有重名目录
console.log("\r\nRemoving");
await fs.remove(targetDirectory);
}
}
}
};
复制代码
我们在当前目录创建一个 aaa
文件夹,测试一下是否实现对重名目录的处理:
项目创建 Creator 类
为了项目更方便管理,我们将创建项目部分抽离成 Creator
类。
// Creator.js
class Creator {
// 项目名称及项目路径
constructor(name, target) {
this.name = name;
this.target = target;
}
// 创建项目部分
create() {
console.log(this.name, this.target);
}
}
module.exports = Creator;
// create.js
const creator = new Creator(projectName, targetDirectory);
creator.create();
复制代码
命令行执行 zc-cli create aaa
,成功打印出项目名与项目路径
aaa D:\workspace\forward\notes\cli\aaa
复制代码
项目的模板存放在 github
中,项目使用 zhurong-cli 的模板仓库,zhurong-cli
分别提供了 vue2
及 vue3
的仓库,并且每个仓库下提供多个版本。
github
提供了官方的 api
,我们可以通过调用官方的 api
获取到仓库及版本信息。
因此可以把创建项目整体划分成下面步骤:
通过获取仓库的 API
获取模板信息: Vue2 or Vue 3
将模板信息渲染为交互框,用户选择自己需要的模板
根据用户选择的模板,获取版本信息
将版本信息渲染成交互框,用户选择需要的版本
通过用户选取的模板及版本,下载对应模板到指定目录
将模板渲染为项目
获取模板及版本
api 请求模块
由于脚手架中要发送多种请求,因此我们单独设置 api.js
负责处理模板和版本信息的获取。
const axios = require("axios");
// 拦截全局请求响应
axios.interceptors.response.use((res) => {
return res.data;
});
/**
* 获取模板
* @returns Promise 仓库信息
*/
async function getZhuRongRepo() {
return axios.get("https://api.github.com/orgs/zhurong-cli/repos");
}
/**
* 获取仓库下的版本
* @param {string} repo 模板名称
* @returns Promise 版本信息
*/
async function getTagsByRepo(repo) {
return axios.get(`https://api.github.com/repos/zhurong-cli/${repo}/tags`);
}
module.exports = {
getZhuRongRepo,
getTagsByRepo,
};
复制代码
获取模板信息
api
部分已经封装了模板信息的获取方法,这里直接调用该方法,获取模板信息,然后使用 inquirer 渲染成命令行交互选择框。
// 获取模板信息及用户最终选择的模板
async function getRepoInfo() {
// 获取组织下的仓库信息
let repoList = await getZhuRongRepo();
// 提取仓库名
const repos = repoList.map((item) => item.name);
// 选取模板信息
let { repo } = await new inquirer.prompt([
{
name: "repo",
type: "list",
message: "Please choose a template",
choices: repos,
},
]);
return repo;
}
复制代码
测试一下模板选取是否成功。
获取版本信息
获取到模板信息后,我们就可以根据模板名调用第二个 api
获取到 tag
(版本信息)
// 获取版本信息及用户选择的版本
async getTagInfo(repo) {
let tagList = await getTagsByRepo(repo);
const tags = tagList.map((item) => item.name);
// 选取模板信息
let { tag } = await new inquirer.prompt([
{
name: "repo",
type: "list",
message: "Please choose a version",
choices: tags,
},
]);
return tag;
}
复制代码
测试版本是否拉取成功。
添加 loading 效果
模板拉取需要消耗一定的时间,添加一个 loading
效果让用户体验更好。
我们可以借助第三方库 ora
实现 loading
方法。
/**
* loading加载效果
* @param {String} message 加载信息
* @param {Function} fn 加载函数
* @param {List} args fn 函数执行的参数
* @returns 异步调用返回值
*/
async function loading(message, fn, ...args) {
const spinner = ora(message);
spinner.start(); // 开启加载
let executeRes = await fn(...args);
spinner.succeed();
return executeRes;
}
复制代码
我们将 loading
方法添加到模板获取和版本获取部分。
失败重新拉取
远程加载有时候有可能会遭遇网络不佳或者远程资源丢失情况,因此我们添加一个失败重拉功能,来提高用户体验。
但是我们要限制失败重拉的频率,太频繁拉取,给用户造成不好的体验。
/**
* 睡觉函数
* @param {Number} n 睡眠时间
*/
function sleep(n) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
}, n);
});
}
async loading(message, fn, ...args) {
const spinner = ora(message);
spinner.start(); // 开启加载
try {
let executeRes = await fn(...args);
// 加载成功
spinner.succeed();
return executeRes;
} catch (error) {
// 加载失败
spinner.fail("request fail, refetching");
await sleep(1000);
// 重新拉取
return loading(message, fn, ...args);
}
}
复制代码
模板下载
上文中我们已经成功获取到了模板及版本的信息,下面我们就可以去下载模板了。
download-git-repo
模块并不支持 Promise ,因此我们先借助 node
的 util
模块提供的 promisify
方法将其转化为支持 Promise
的方法。
// 把方法挂载到构造函数上
constructor(name, target) {
this.name = name;
this.target = target;
// 转化为 promise 方法
this.downloadGitRepo = util.promisify(downloadGitRepo);
}
复制代码
定义 download
下载方法,并在 create
入口中调用:
async download(repo, tag) {
// 模板下载地址
const templateUrl = `zhurong-cli/${repo}${tag ? "#" + tag : ""}`;
// 调用 downloadGitRepo 方法将对应模板下载到指定目录
await loading(
"downloading template, please wait",
this.downloadGitRepo,
templateUrl,
path.join(process.cwd(), this.target) // 项目创建位置
);
}
复制代码
同时也成功地在当前目录创建 xxx
项目,xxx
项目的目录结构如下:
美化项目
添加 logo
当调用 --help
命令时,我们在最后添加 logo
展示,经过精挑细选,最终小包选择了 3D-ASCII
字体。
我们来看一下效果,帅呆了,可以没找到上色的方法。
模板使用提示
当模板下载成功后,我们像 vue-cli
一样添加模板使用提示,保证用户能正常启动项目。但是要注意 zc-cli
目前下载的是模板,因此我们进入模板后需要首先执行 npm install
下载依赖包才能执行。
// 核心创建逻辑 —— 创建项目部分
async create() {
// 仓库信息 —— 模板信息
let repo = await this.getRepoInfo();
// 标签信息 —— 版本信息
let tag = await this.getTagInfo(repo);
// 下载模板到模板目录
await this.download(repo, tag);
// 模板使用提示
console.log(`\r\nSuccessfully created project ${chalk.cyan(this.name)}`);
console.log(`\r\n cd ${chalk.cyan(this.name)}`);
console.log(" npm install\r\n");
console.log(" npm run dev\r\n");
}
复制代码
成功添加模板使用提示,最后我们按照模板使用步骤执行,成功创建了一个 Vue 项目。
发布项目
接下来修改 package.json
,添加个人信息,然后就可以准备发布了。
要发布一个 npm
包流程非常简单。
Step1: 首先去npm 注册一个用户
Step2: 然后将本地登录 npm
Step3: 执行 npm publish 命令,发布 npm 包。
Step4: 然后我们就可以在 npm 官网找到发布后的 npm 包了。(小包版: zcxiaobao-cli)
总结与展望
上面我们成功的实现了创建项目,但其实距离一个完善的脚手架还有很大距离
zc-cli
距离成熟好用的脚手架还有很远,但已经交代了脚手架开发的大致流程,后面的功能小包会一步一步学习,一步一步完善,最终将 zc-cli
实现成一个比较完备的脚手架。
小包下一步决定去阅读 vue-cli
源码,吸收其精华,再回来完善 zc-cli
。
后语
我是 战场小包 ,一个快速成长中的小前端,希望可以和大家一起进步。
如果喜欢小包,可以在 InfoQ 关注我,同样也可以关注我的小小公众号——小包学前端。
一路加油,冲向未来!!!
疫情早日结束 人间恢复太平
评论