1 功能演示
1.1 脚手架安装
npm i @coderyjw/cli -g
输入 jw-cli -h
查询帮助
1.2 init 命令演示:下载项目模板
1. 输入 jw-cli init -h
查询帮助
2. 输入 jw-cli init
初始化项目,选择类型:项目
3. 输入项目名称: react-app
4. 选择项目模板:react模板
5. 下载成功
1.3 install 命令演示:源码下载
1. 输入 jw-cli install
,选择 Gitee
2. 输入 token
3. 输入关键词: vue-admin-template
4. 输入开发语言(可选择不输入)
5. 选择项目:vue-admin-template
6. 选择版本: 4.3.1
7. 下载成功,启动项目
1. 脚手架整体框架搭建
1.1 脚手架入口文件开发
1. 使用 Lerna
创建项目
Lerna
是一个用于管理包含多个包的 JavaScript
项目的管理工具。
npm i lerna -g
mkdir jw-cli
cd jw-cli
lerna init
此时我们的目录结构应该是这样的:
2. 通过 Lerna
创建 cli package
lerna create cli
3. 创建 packages/cli/bin/cli.js
,通常来说脚手架文件都会放在 bin
目录下
4. 将 packages/cli/lib/cli.js
修改为 packages/cli/lib/index.js
。这个就是我们未来要下载和安装的入口文件
5. 到 npm
上创建对应的组织
6. 修改 packages/cli/package.json
注意上面填入的 package name(@coderyjw/cli)
要填入你自己在 npm
上创建的组织,
7. 让入口文件生效
cd packages/cli
npm link
8. 修改 bin/cli.js
与 lib/index.js
测试是否生效
bin/cli.js
#!/usr/bin/env node
import entry from "../lib/index.js";
entry(process.argv.slice(2));
lib/index.js
export default function (args) {
console.log("this is entry", args);
}
执行 jw-cli
,控制台打印如下内容说明入口文件已生效
至此我们脚手架的入口就已经开发好了。
1.2 脚手架注册 + 命令注册
1. 在 packages/cli
目录下安装 commander
插件
lerna add commander packages/cli
commander
是一个很好用的 node.js
命令行解决方案,这里就不详细讲了。
2. 创建 packages/cli/lib/createCLI.js
文件用于封装脚手架注册逻辑
import fse from "fs-extra";
import path from "path";
import { dirname } from "dirname-filename-esm";
const __dirname = dirname(import.meta);
const pkgPath = path.resolve(__dirname, "../package.json");
const pkg = fse.readJsonSync(pkgPath);
export default function createCLI(program) {
// 命令注册
program
.name(Object.keys(pkg.bin)[0])
.usage("<command> [options]")
.version(pkg.version, "-v, --version", "输出当前版本号")
.option("-d, --debug", "是否开启调试模式", false)
.helpOption("-h, --help", "命令显示帮助")
.addHelpCommand("help [command]", "命令显示帮助");
// 监听未知的命令
program.on("command:*", function (obj) {
console.error("未知的命令:" + obj[0]);
});
// 监听 debug 选项
program.on("option:debug", function () {
if (program.opts().debug) {
console.log("开启调试模式");
}
});
}
3. 在 packages/cli/lib/index.js
中引入 createCLI
import { program } from "commander";
import createCLI from "./createCLI.js";
export default function (args) {
createCLI(program);
program.parse(process.argv);
}
这里需要引入两个包:
fs-extra
:它可以看做是node
中fs
模块的升级版,比fs
更加的好用dirname-filename-esm
:因为在esm
中,是没有cjs
中的__dirname
变量的,但是我们可以通以下下方法获得__dirname
import { dirname } from "dirname-filename-esm";
const __dirname = dirname(import.meta);
所以我们要先安装以下这两个包
lerna add dirname-filename-esm packages/cli
lerna add fs-extra packages/cli
4. 最后测试能出现下面的效果则说明我们已经成功注册了命令
1.3 log 日志功能封装
在上面的代码中,我们使用的是 cosole.log
进行日志打印,实际上我们有更好的选择,那就是 npmlog
,虽然它的功能已经非常完善了,但是我们最好对它再进行一层封装。
1. 创建 utils
文件夹作为我们的一个工具包,后续我们所有的工具类都放在这个包下
lerna create utils
此时的目录结构
2. 打开 packages/utils/packages.json
修改
3. 将 packages/utils/utils.js
改为 packages/utils/index.js
4. 安装 npmlog
依赖
leran add npmlog pacakges/utils
5. 创建 packages/utils/log.js
文件并做如下配置
import log from "npmlog";
import isDebug from "./isDebug.js";
if (isDebug()) {
log.level = "verbose";
} else {
log.level = "info";
}
log.heading = "jw-cli";
log.addLevel("success", 2000, { fg: "green", bold: true });
export default log;
6. 创建 packages/utils/isDebug.js
/**
* 判断是否是 debug 模式
* @returns boolean
*/
export default function isDebug() {
return process.argv.includes("--debug") || process.argv.includes("-d");
}
7. 在 packages/utils/index.js
导入并导出这两个模块
import isDebug from "./isDebug.js";
import log from "./log.js";
export { log, isDebug };
8. 在 cli
包中引入 utils
包
lerna add @coderyjw/utils packages/cli
9. 修改 packages/cli/bin/createCLI.js
,引入 log
模块,并将 console
改为 log
import { log } from "@coderyjw/utils";
...
export default function createCLI(program) {
...
// 监听未知的命令
program.on("command:*", function (obj) {
log.error("未知的命令:" + obj[0]);
});
// 监听 debug 选项
program.on("option:debug", function () {
if (program.opts().debug) {
log.verbose("debug", "开启调试模式");
}
});
10. 测试成功
1.4 Commander 类封装
与 log
一样,虽然 commander
已经很好用了,但是我们还是希望能对其进行封装,这样对我们后续的命令注册也会非常的方便。对于 Commander
类的封装,这里提供给大家一种思路。
1. 创建 command
一个新的 package
lerna create command
2. 修改 packages/command/package.json
3. 将 packages/command/command.js
改为 packages/command/index.js
,修改 index.js
class Command {
constructor(instance) {
if (!instance) {
throw new Error("command instance must not be null!");
}
this.program = instance;
const cmd = this.program.command(this.command);
cmd.description(this.description);
cmd.hook("preAction", () => {
this.preAction();
});
cmd.hook("postAction", () => {
this.postAction();
});
if (this.options?.length > 0) {
this.options.forEach((option) => {
cmd.option(...option);
});
}
cmd.action((...params) => {
this.action(params);
});
}
get command() {
throw new Error("command must be implements");
}
get description() {
throw new Error("description must be implements");
}
get options() {
return [];
}
action() {
throw new Error("action must be implements");
}
preAction() {
// empty
}
postAction() {
// empty
}
}
export default Command;
在上面的代码中,我们封装了一个 Command
类最后将其导出,在 Command
类的构造函数中,我们主要做了以下几件事:
- 通过
program.command
注册命令 - 添加
description
命令描述信息 - 添加了命令生命周期
preAction
和postAction
两个hook
,这两个函数分别会在action
的前后触发 - 调用
command.action()
触发我们命令的逻辑
之后的所有命令只要继承该类即可
1.5 init 命令封装
接下来我们终于可以开始我们的第一个命令的注册了。
1. 创建 init
包
lerna create init
2. 修改 packages/init/package.json
3. 将 packages/init/init.js
改为 packages/init/index.js
,修改 index.js
import Command from "@coderyjw/command";
import { log } from '@coderyjw/utils'
class InitCommand extends Command {
get command() {
return "init [name]";
}
get description() {
return "项目初始化";
}
get options() {
return [
["-f, --force", "是否强制更新", false],
["-t, --type <type>", "项目类型(project/page)"],
["-tp, --template <template>", "模版名称"],
];
}
async action([name, opts]) {
log.verbose("init", name, opts);
}
preAction() {}
postAction() {}
}
function Init(instance) {
return new InitCommand(instance);
}
export default Init;
在上面的代码中我们封装的 InitCommand
继承自 Command
类,最后导出了一个创建 InitCommand
类的函数,这样,我们只要到 packages.cli/lib/index.js
脚手架入口文件中调用这个函数即可
4. 修改 packages.cli/lib/index.js
import { program } from "commander";
import createCLI from "./createCLI.js";
import createInitCommand from "@coderyjw/init";
export default function (args) {
createCLI(program);
createInitCommand(program)
// 解析配置
program.parse(process.argv);
}
5. 当然我们还要导入一下所需的依赖
lerna add @coderyjw/command packages/init
lerna add @coderyjw/utils packages/init
lerna add @coderyjw/init packages/cli
6. 测试成功
1.6 node 最低版本检查功能
到现在我们脚手架的基本逻辑其实已经差不多了,我们可以再回来优化一下启动逻辑,比如检查 Node
版本,这个启动的逻辑可以放在 commander
的钩子函数 preAction
中来实现
1. 修改 packages/cli/bin/createCLI.js
:
import semver from "semver";
import chalk from "chalk";
...
...
const LOWEST_NODE_VERSION = "14.0.0";
const checkNodeVersion = () => {
log.verbose("node version", process.version);
// 当前 node 版本低于 LOWEST_NODE_VERSION 时 抛出错误
if (!semver.gte(process.version, LOWEST_NODE_VERSION)) {
throw new Error(
chalk.red(`jw-cli 需要安装 ${LOWEST_NODE_VERSION} 以上版本的 Node.js`)
);
}
};
const preAction = () => {
// 检查 Node 版本
checkNodeVersion();
};
export default function createCLI(program) {
// 命令注册
program
.name(Object.keys(pkg.bin)[0])
.usage("<command> [options]")
.version(pkg.version, "-v, --version", "输出当前版本号")
.option("-d, --debug", "是否开启调试模式", false)
.helpOption("-h, --help", "命令显示帮助")
.addHelpCommand("help [command]", "命令显示帮助")
.hook("preAction", preAction);
...
...
在上面的代码中:
2. 接下来我们安装下这两个包
lerna add chalk packages/cli
lerna add semver packages/cli
3. 分别修改 LOWEST_NODE_VERSION
的值为 17.0.0
和 14.0.0
进行测试,测试成功
1.7 添加异常监听
到现在为止,我们已经基本把脚手架的框架维护好了,最后再做一些收尾工作,比如异常监听。
Node.js
程序运行在单进程上,应用开发时一个难免遇到的问题就是异常处理,
关于异常监听,我们可以这样优化,判断如果当前是调试模式时再打印出函数栈,如果不是就仅打印出 message
即可
1. 修改 packages/utils/lib/log.js
,封装 printErrorLog
方法,之后如果我们主动捕捉到的异常时都可以用这个方法打印日志
export function printErrorLog(e, type) {
if (isDebug()) {
log.error(type, e);
} else {
log.error(type, e.message);
}
}
2. 修改在 packages/utils/lib/index.js
, 导出 printErrorLog
import isDebug from "./isDebug.js";
import log, { printErrorLog } from "./log.js";
export { log, isDebug, printErrorLog };
3. 另外除了主动捕捉到的异常,还有一些可能漏掉的异常,可能导致程序退出,我们创建 packages/cli/lib/exception.js
:
import { printErrorLog } from "@coderyjw/utils";
process.on("uncaughtException", (e) => printErrorLog(e, "error"));
process.on("unhandledRejection", (e) => printErrorLog(e, "promise"));
4. 在 packages/cli/index.js
中引入 exception.js
import "./exception.js";
5. 我们以上一小节的报错为例进行测试
可以看到加上 -d
的有报错的函数调用栈,而没加的就只打印出了报错 message
2. 项目创建脚手架开发
我们为什么要开发创建项目的脚手架?为什么不直接使用
vue-cli
或者create-react-app
?- 因为项目在迭代过程中会添加很多本土化的元素,如:H5 兼容、接口请求、埋点上报、组件封装、通用方法等,甚至会对整块业务逻辑进行复用,比如登录;
- 而且每次在创建项目的时候都要重新填写这些代码是非常耗时的,而且无法对每个团队成员进行复用,可以利用脚手架来完成项目模板的沉淀和标准化建设。
2.1 项目模板开发
1. 新建一个 jw-cli-template
项目,并按如下目录结构创建文件夹
2. 修改分别修改各个模板的 package.json
react
模板
{
"name": "@coderyjw/template-react18",
"version": "1.0.0",
"description": "jw-cli react18 template",
"author": "yejw6",
"license": "ISC",
"publishConfig": {
"access": "public"
}
}
vue
模板
{
"name": "@coderyjw/template-vue3",
"version": "1.0.0",
"description": "jw-cli vue3 template",
"author": "yejw6",
"license": "ISC",
"publishConfig": {
"access": "public"
}
}
vue-element-admin
模板
{
"name": "@coderyjw/template-vue-element-admin",
"version": "1.0.0",
"description": "jw-cli vue-element-admin template",
"main": "index.js",
"author": "yejw6",
"license": "ISC",
"publishConfig": {
"access": "public"
}
}
3. 这三个模板的 template
就是存放我们最终代码的位置,关于模板大家可以按照自己的沉淀配置,这里我分享一下我 react模板的配置
4. 通过 npm
依次发布模板
npm publish
5. 可以在 npm
仓库中看到我们的三个模板
2.2 整体逻辑搭建
有了模板之后我们就可以开始写代码去下载模板了。
我们的这个脚手架最主要的逻辑应该包含以下三条:
- 我们应该有一个命令行来让我们选择生成哪份模板,甚至还可以选择生成一个页面模块(包含页面、状态、路由、网络请求等等)
- 选择完之后最好是能缓存到本地硬盘上,这样下次再去生成时就不要再去下载了
- 最后从缓存中下载到本地目录,提示命令运行项目
1. 创建 packages/init/lib/createTemplate.js
import { log } from "@coderyjw/utils";
export default async function createTemplate(name, opts) {
log.verbose("createTemplate", "选择项目模板,生成项目信息");
return null
}
2. 创建 packages/init/lib/downloadTemplate.js
import { log } from "@coderyjw/utils";
export default async function downloadTemplate(selectedTemplate) {
log.verbose("downloadTemplate", "下载项目模板值缓存目录");
log.verbose("template", selectedTemplate);
}
3. 创建 packages/init/lib/installTemplate.js
import { log } from "@coderyjw/utils";
export default async function installTemplate(name, opts) {
log.verbose("installTemplate", "安装项目模板至目录");
}
上面创建的三份文件分别对应我们上面说的三个逻辑,目前还是空的需要我们去完善代码。
4. 修改 packages/init/lib/index.js
import createTemplate from "./createTemplate.js";
import downloadTemplate from "./downloadTemplate.js";
import installTemplate from "./installTemplate.js";
...
...
async action([name, opts]) {
// 1. 选择项目模板,生成项目信息
const selectedTemplate = await createTemplate(name, opts);
// 2. 下载项目模板值缓存目录
await downloadTemplate(selectedTemplate);
// 3. 安装项目模板至目录
await installTemplate(selectedTemplate, opts);
}
至此我们整体的代码已经创建好了,接下来就是去实现各个逻辑了
2.3 选择项目模板,生成项目信息
要想在命令行进行选择模板,就要用到 inquirer
了,inquirer
拥有一组通用的交互式命令行用户界面,支持常见的 input
输入、单选、多选、是/否等常见提问类型
1. 创建 packages/utils/inquirer.js
,分别对其以上能力进行封装
import inquirer from "inquirer";
function make({
choices,
defaultValue,
message = "请选择",
type = "list",
require = true,
mask = "*",
validate,
pageSize,
loop,
}) {
const options = {
name: "name",
default: defaultValue,
message,
type,
require,
mask,
validate,
pageSize,
loop,
};
if (type === "list") {
options.choices = choices;
}
return inquirer.prompt(options).then((answer) => answer.name);
}
export function makeList(params) {
return make({ ...params });
}
export function makeInput(params) {
return make({ type: "input", ...params });
}
export function makePassword(params) {
return make({ type: "password", ...params });
}
2. 在 package/utils/lib/index.js
中导出
import isDebug from "./isDebug.js";
import log, { printErrorLog } from "./log.js";
import { makeList, makeInput, makePassword } from "./inquirer.js";
export { log, isDebug, printErrorLog, makeList, makeInput, makePassword };
3. 安装 inquirer
lerna add inquirer packages/utils
4. 修改 package/init/lib/createTemplate.js
文件
import { log, makeList, makeInput } from "@coderyjw/utils";
const ADD_TYPE_PROJECT = "project";
const ADD_TYPE_PAGE = "page";
const ADD_TYPE = [
{ name: "项目", value: ADD_TYPE_PROJECT },
{ name: "页面", value: ADD_TYPE_PAGE },
];
const GLOBAL_ADD_TEMPLATE = [
{
name: "vue3 项目模板",
value: "template-vue3",
npmName: "@coderyjw/template-vue3",
version: "1.0.1",
},
{
name: "react18 项目模板",
value: "template-react18",
npmName: "@coderyjw/template-react18",
version: "1.0.0",
},
{
name: "vue-element-admin 项目模板",
value: "template-vue-element-admin",
npmName: "@coderyjw/template-vue-element-admin",
version: "1.0.0",
},
];
// 获取创建类型
function getAddType() {
return makeList({
choices: ADD_TYPE,
message: "请选择初始化类型",
defaultValue: ADD_TYPE_PROJECT,
});
}
// 获取项目名称
function getAddName() {
return makeInput({
message: "请输入项目的名称",
defaultValue: "",
validate(name) {
if (name.length > 0) return true;
return "项目名称不能为空";
},
});
}
// 选择项目模版
function getAddTemplate(ADD_TEMPLATE) {
return makeList({
choices: ADD_TEMPLATE,
message: "请选择项目模版",
});
}
export default async function createTemplate(name, opts) {
log.verbose("createTemplate", "选择项目模板,生成项目信息");
const ADD_TEMPLATE = GLOBAL_ADD_TEMPLATE;
// 项目类型,项目名称,项目模版
let addType, addName, addTemplate;
addType = await getAddType();
log.verbose("addType", addType);
if (addType === ADD_TYPE_PROJECT) {
addName = await getAddName();
log.verbose("addName", addName);
addTemplate = await getAddTemplate(ADD_TEMPLATE);
log.verbose("addTemplate", addTemplate);
const selectedTemplate = ADD_TEMPLATE.find((_) => _.value === addTemplate);
if (!selectedTemplate) throw new Error(`项目模版 ${template} 不存在!`);
log.verbose("selectedTemplate", selectedTemplate);
return {
type: addType,
name: addName,
template: selectedTemplate,
};
} else {
throw new Error(`抱歉,创建的项目类型 ${addType} 暂不支持!`);
}
}
在上面的代码中我们分别封装 getAddName
getAddType
getAddTemplate
三个方法,用于获取要创建的类型、名称和模板
5. 测试
不出意外的话就会生成我们选中模板的信息了。
6. 修改 package/init/lib/createTemplate.js
,优化代码,使之能通过一条命令直接生成模板
+ const { type, template } = opts;
// 项目类型,项目名称,项目模版
let addType, addName, addTemplate;
- addType = await getAddType()
+ if (type) {
+ addType = type;
+ } else {
+ addType = await getAddType();
+ }
...
- addName = await getAddName();
+ if (name) {
+ addName = name;
+ } else {
+ addName = await getAddName();
+ }
- addTemplate = await getAddTemplate(ADD_TEMPLATE);
+ if (template) {
+ addTemplate = template;
+ } else {
+ addTemplate = await getAddTemplate(ADD_TEMPLATE);
+ }
7. 测试成功
上面的代码还会有一个小问题,就是生成的模板信息中 version
一直是 1.0.0
固定,我们知道我们的版本是会更新的,如果每次更新都要重新改一下脚手架的代码是会十分麻烦的,那么应该如何解决呢?我这里的提供一种方法是每次都去下载最新的版本,或者你也可以做一个功能下载指定版本的模板。
npm
给我们提供了一个 api
让我们可以获取到一个包的所有信息,我们只需要通过请求 https://registry.npmjs.org/ + 包名
即可
8. npm API
接入,创建 packages/utils/npm.js
import urlJoin from "url-join";
import axios from "axios";
import { log } from "./log.js";
function getNpmInfo(npmName) {
// npm API 地址 https://registry.npmjs.org/
// 如果 npm 过慢,可以使用淘宝镜像 https://registry.npmmirror.com/
const registry = "https://registry.npmmirror.com/";
const url = urlJoin(registry, npmName);
return axios.get(url).then((res) => {
try {
return res.data;
} catch (e) {
Promise.reject(e);
}
});
}
export default async function getLatestVersion(npmNmae) {
const result = await getNpmInfo(npmNmae);
if (result?.["dist-tags"]?.["latest"]) {
return result["dist-tags"]["latest"];
}
log.error("没有 latest 版本号");
return Promise.reject("没有 latest 版本号");
}
9. 修改 packages/utils/index.js
导出 getLatestVersion
方法
import isDebug from "./isDebug.js";
import log, { printErrorLog } from "./log.js";
import { makeList, makeInput, makePassword } from "./inquirer.js";
import getLatestVersion from "./npm.js";
export {
log,
isDebug,
printErrorLog,
makeList,
makeInput,
makePassword,
getLatestVersion,
};
10. 最后不要忘了安装 axios
和 url-join
两个包
lerna add axios packages/utils
lerna add url-join packages/utils
11. 在 packages/init/lib/createTemplate.js
中引入 getLatestVersion
,获取最新版本
// 获取最新的版本
const latestVersion = await getLatestVersion(selectedTemplate.npmName);
log.verbose("latestVersion", latestVersion);
selectedTemplate.version = latestVersion;
12. 最后因为我们之后要下载模板,最好将缓存目录获取的逻辑在这一步给实现了传过去
import path from "path";
import { homedir } from "os";
const TEMP_HOME = ".jw-cli";
...
// 安装缓存目录
function makeTargetPath() {
return path.resolve(`${homedir()}/${TEMP_HOME}`, "addTemplate");
}
...
const targetPath = makeTargetPath();
log.verbose("targetPath", targetPath);
return {
type: addType,
name: addName,
template: selectedTemplate,
targetPath,
};
2.4 下载项目模板至缓存目录
接下来我们就来到了第二步逻辑:下载项目模板至缓存目录
1. 修改 packages/init/downloadTemplate.js
import path from "node:path";
import { pathExistsSync } from "path-exists";
import fse from "fs-extra";
import ora from "ora";
import { execa } from "execa";
import { printErrorLog, log } from "@yejiwei/utils";
function getCacheDir(targetPath) {
return path.resolve(targetPath, "node_modules");
}
function makeCacheDir(targetPath) {
const cacheDir = getCacheDir(targetPath);
if (!pathExistsSync(cacheDir)) {
fse.mkdirpSync(cacheDir);
}
}
async function downloadAddTemplate(targetPath, selectedTemplate) {
const { npmName, version } = selectedTemplate;
const installCommand = "npm";
const installArgs = ["install", `${npmName}@${version}`];
const cwd = targetPath;
const subprocess = execa(installCommand, installArgs, { cwd });
await subprocess;
}
export default async function downloadTemplate(selectedTemplate) {
const { targetPath, template } = selectedTemplate;
makeCacheDir(targetPath);
const spinner = ora("正在下载模版....").start();
try {
await downloadAddTemplate(targetPath, template);
spinner.stop();
log.success("模版下载成功");
} catch (e) {
spinner.stop();
printErrorLog(e);
}
}
在上面的代码中:
- 我们主要通过封装了
downloadAddTemplate
来下载模板 - 引入
execa
模块 来执行npm install 包名
的方式将模板下载至缓存目录中 - 另外还引入了
ora
模块做了一个下载中的动画
- 我们主要通过封装了
2. 引入这些依赖
lerna add path-exists packages/init
lerna add execa packages/init
lerna add ora packages/init
3. 输入命令测试
可以看到命令行显示模板下载成功,并且模板代码已经成功下载至我们的路径 C:\Users\yjw\.jw-cli\addTemplate\node_modules\@coderyjw\template-react18
下面
2.5 安装项目模板至目录
接下来我们终于来到第三步逻辑了:安装项目模板至目录
1. 修改 packages/init/lib/installTemplate.js
import fse from "fs-extra";
import path from "path";
import ora from "ora";
import { log } from "@coderyjw/utils";
function getCacheFilePath(targetPath, template) {
return path.resolve(targetPath, "node_modules", template.npmName, "template");
}
function copyFile(targetPath, template, installDir) {
const originFilePath = getCacheFilePath(targetPath, template);
log.verbose("originFilePath", originFilePath);
const fileList = fse.readdirSync(originFilePath);
log.verbose("fileList", fileList);
const spinner = ora("正在拷贝文件...").start();
fileList.map((file) => {
fse.copySync(`${originFilePath}/${file}`, `${installDir}/${file}`);
});
spinner.stop();
log.success("模版拷贝成功");
}
export default async function installTemplate(selectedTemplate, opts) {
log.verbose("installTemplate", "安装项目模板至目录");
const { force = false } = opts;
const { targetPath, template, name } = selectedTemplate;
const rootDir = process.cwd();
fse.ensureDirSync(targetPath);
const installDir = path.resolve(`${rootDir}/${name}`);
fse.ensureDirSync(installDir);
copyFile(targetPath, template, installDir);
log.info(`输入以下命令运行项目`)
log.info(`cd ${name}`)
log.info(`npm install`)
log.info(`npm start`)
}
在上面的代码中我们主要封装了 copyFile
方法用来从缓存目录中拷贝文件到当前目录下,但是有一个小问题就是,如果当前目录下我们填入 name
的文件夹已存在时就会有点问题,这里我们可以通过添加一个 option
让用户选择是否强制更新
2. 代码逻辑优化
+ import { pathExistsSync } from "path-exists";
...
+ const { force = false } = opts;
...
- fse.ensureDirSync(installDir);
+ if (pathExistsSync(installDir)) {
+ if (!force) {
+ log.error(`当前目录已存在 ${installDir} 文件夹`);
+ return;
+ } else {
+ fse.removeSync(installDir);
+ fse.ensureDirSync(installDir);
+ }
+ } else {
+ fse.ensureDirSync(installDir);
+ }
3. 输入 jw-cli init react-app -t project -tp template-react18 -d
命令测试
模板成功安装
再次输入命令显示
输入 jw-cli init react-app -t project -tp template-react18 -d -f
命令强制更新
至此,我们的项目创建脚手架的开发就已经完成了。
3. 源码下载脚手架开发
在上一章中,我们完成了项目创建脚手架的开发,代码全部放置 init文件夹下
,从本章开始我们将进行源码下载器的开发。
为什么要实现源码下载器?
- 因为
npm
只能下载npm registry
中的包,而对于未上传npm
的包则无法下载 - 对于
github
和gitee
的项目或包如果手动下载效率低下,则可以通过脚手架开发前端项目下载器提升效率
- 因为
- 那么,实现一个源码下载器需要怎么样的流程呢?
- 首先我们需要掌握
github API
和gitee API
的使用方法 - 通过
github
和gitee
实现根据仓库名称搜索项目的能力(github
能力更强,甚至可以根据源码进行搜索) - 存储
github
或gitee
的token
,具备github API
或gitee API
的调用条件 - 通过搜索获取仓库信息,选取指定版本(实现翻页功能)
- 将指定版本的源码拉取到本地
- 在本地安装依赖,安装可执行文件,启动项目
3.1 整理逻辑搭建
1. 创建 install
文件夹作为源码下载器的包
lerna create install
2. 修改 packages/install/package.json
{
"name": "@coderyjw/install",
"version": "0.0.0",
"description": "jw-cli脚手架 install 命令",
"author": "叶继伟 <yejw6@asiainfo.com>",
"homepage": "",
"license": "ISC",
"main": "lib/index.js",
"directories": {
"lib": "lib",
"test": "__tests__"
},
"files": [
"lib"
],
"type": "module",
"scripts": {
"test": "node ./__tests__/install.test.js"
}
}
3. 修改 packages/install/lib/install.js
为 packages/install/lib/index.js
import Command from "@coderyjw/command";
class InstallCommand extends Command {
get command() {
return "install";
}
get description() {
return "项目下载、安装依赖、启动项目";
}
get options() {
return [["-c, --clear", "清空缓存", false]];
}
async action() {}
}
function Install(instance) {
return new InstallCommand(instance);
}
export default Install;
4. 在 packages/cli/lib/index.js
中引入 install
包
import { program } from "commander";
import createCLI from "./createCLI.js";
import createInitCommand from "@coderyjw/init";
import createInstall from "@coderyjw/install";
import "./exception.js";
export default function (args) {
createCLI(program);
createInitCommand(program);
createInstall(program)
// 解析配置
program.parse(process.argv);
}
5. 安装依赖
lerna add @coderyjw/command packages/install
lerna add @coderyjw/install packages/cli
6. 运行 jw-cli
,可以看到此时我们的脚手架已经有了 install
命令
3.2 平台选择 + token 缓存
1. 创建 packages/utils/git/GitServer.js
文件
import { homedir } from "os";
import path from "path";
import { pathExistsSync } from "path-exists";
import fse from "fs-extra";
import { makePassword } from "../lib/inquirer.js";
import { log } from "../lib/index.js";
const TEMP_HOME = ".jw-cli";
const TEMP_GITHUB_TOEN = ".github-token";
const TEMP_GITEE_TOEN = ".gitee-token";
function createTokenPath(platForm) {
if (platForm === "github") {
return path.resolve(homedir(), TEMP_HOME, TEMP_GITHUB_TOEN);
} else {
return path.resolve(homedir(), TEMP_HOME, TEMP_GITEE_TOEN);
}
}
export default class GitServer {
constructor() {}
async init(platForm) {
// 判断 token 是否录入
const tokenPath = createTokenPath(platForm);
if (pathExistsSync(tokenPath)) {
this.token = fse.readFileSync(tokenPath).toString();
} else {
this.token = await this.getToken();
fse.writeFileSync(tokenPath, this.token);
}
log.verbose("token", this.token);
}
async getToken() {
return await makePassword({ message: "请输入 token 信息" });
}
}
2. 创建 packages/utils/git/Gitee.js
文件
import GitServer from "./GitServer.js";
import { log } from "../lib/index.js";
const BASE_URL = "https://gitee.com/api/v5";
export default class Gitee extends GitServer {
constructor() {
super();
}
}
3. 创建 packages/utils/git/Github.js
文件
import GitServer from "./GitServer.js";
import { log } from "../lib/index.js";
const BASE_URL = "https://api.github.com";
export default class GitHub extends GitServer {
constructor() {
super();
}
}
4. 创建 packages/utils/git/GitUtils.js
文件
import { makeList, log } from "../lib/index.js";
import Github from "./Github.js";
import Gitee from "./Gitee.js";
export async function chooseGitPlatForm() {
let platForm = await makeList({
message: "请选择 Git 平台",
choices: [
{
name: "Github",
value: "github",
},
{ name: "Gitee", value: "gitee" },
],
});
log.verbose("platForm", platForm);
return platForm;
}
export async function initGitServer(platForm) {
let gitAPI;
if (platForm === "github") {
gitAPI = new Github();
} else if (platForm === "gitee") {
gitAPI = new Gitee();
}
await gitAPI.init(platForm);
return gitAPI;
}
5. 在 packages/utils/index.js
中导入并导出
+ import Github from "../git/Github.js";
+ import Gitee from "../git/Gitee.js";
+ import { chooseGitPlatForm, initGitServer } from "../git/GitUtils.js";
export {
log,
isDebug,
printErrorLog,
makeList,
makeInput,
makePassword,
getLatestVersion,
+ Github,
+ Gitee,
+ chooseGitPlatForm,
+ initGitServer
};
在上面的代码中我们分别封装了三个类 GitSever
、Github
、Gitee
,后两者继承自前者,我们之后会使用这两个类来接入 gitee
和 github
的 api
来完成源码的查询与下载,但是,我们当前的目标还是先进行平台(github
/gitee
)的选择和 token
的缓存,因为调用这两个平台的 api
都是需要提供它们的 token
的,所以我们最好在第一次输入之后缓存一下,之后就不用再输入了。
6. 修改 packages/install/lib/index.js
import { chooseGitPlatForm, initGitServer } from "@coderyjw/utils";
...
async action() {
await this.generateGitAPI();
}
async generateGitAPI() {
this.platForm = await chooseGitPlatForm();
this.gitAPI = await initGitServer(this.platForm);
}
...
7. 安装依赖
lerna add path-exists packages/utils
lerna add fs-extra packages/utils
lerna add @coderyjw/utils packages/install
8. 测试
我们到 C:\Users\yjw\.jw-cli
目录下可以看到两个平台的 token
数据都已经缓存到了本地
- 关于
Github token
如何获取 token 可以查看 文档 - Gitee 创建 token
3.3 仓库搜索 + 翻页功能开发
1. Github / Gitee Search API
接入,分别修改 packages/utils/git/Github.js
和 packages/utils/git/Gitee.js
Github.js
import axios from "axois";
import GitServer from "./GitServer.js";
import { log } from "../lib/index.js";
const BASE_URL = "https://api.github.com";
export default class GitHub extends GitServer {
constructor() {
super();
this.service = axios.create({
baseURL: BASE_URL,
timeout: 50000,
});
this.service.interceptors.request.use(
(config) => {
config.headers["Authorization"] = `Bearer ${this.token}`;
config.headers["Accept"] = "application/vnd.github+json";
return config;
},
(error) => {
return Promise.reject(error);
}
);
this.service.interceptors.response.use(
(response) => {
return response.data;
},
(err) => {
return Promise.reject(err);
}
);
}
get(url, params, headers) {
return this.service({
url,
params,
method: "GET",
headers,
});
}
post(url, data, headers) {
return this.service({
url,
data,
params: {
access_token: this.token,
},
method: "post",
headers,
});
}
searchRepositories(params) {
return this.get("search/repositories", params);
}
searchCode(params) {
return this.get("search/code", params);
}
}
Gitee.js
import axios from "axois";
import GitServer from "./GitServer.js";
import { log } from "../lib/index.js";
const BASE_URL = "https://gitee.com/api/v5";
export default class Gitee extends GitServer {
constructor() {
super();
this.service = axios.create({
baseURL: BASE_URL,
timeout: 5000,
});
this.service.interceptors.response.use(
(response) => {
return response.data;
},
(err) => {
return Promise.reject(err);
}
);
}
get(url, params, headers) {
return this.service({
url,
params: {
...params,
access_token: this.token,
},
method: "GET",
headers,
});
}
post(url, data, headers) {
return this.service({
url,
data,
params: {
access_token: this.token,
},
method: "post",
headers,
});
}
searchRepositories(params) {
return this.get("search/repositories", params);
}
}
2. 安装 axios
lerna add axios packages/utils
3. 修改 packages/install/lib/index.js
import Command from "@coderyjw/command";
import {
chooseGitPlatForm,
initGitServer,
makeList,
makeInput,
} from "@coderyjw/utils";
const PREV_PAGE = "prev_page";
const NEXT_PAGE = "next_page";
const SEARCH_MODE_REPO = "search_mode_repo";
const SEARCH_MODE_CODE = "search_mode_code";
...
async action() {
await this.generateGitAPI();
await this.searchGitAPI();
log.verbose("selectedProject", this.selectedProject);
}
...
async searchGitAPI() {
log.verbose("this.platForm", this.platForm);
if (this.platForm === "github") {
this.mode = await makeList({
message: "请选择搜索模式",
choices: [
{ name: "仓库名称", value: SEARCH_MODE_REPO },
{ name: "源码", value: SEARCH_MODE_CODE },
],
});
}
// 1. 收集搜索关键词和开发语言
this.q = await makeInput({
message: "请输入搜索关键词",
validate(value) {
if (value) {
return true;
} else {
return "请输入搜索关键词";
}
},
});
this.language = await makeInput({
message: "请输入开发语言",
});
this.keywords =
this.q + (this.language ? `+language:${this.language}` : "");
log.verbose("search keywords", this.keywords, this.platForm);
this.page = 1;
this.perPage = 10;
await this.doSearch();
}
async doSearch() {
// 2. 根据平台生成搜索参数
let params;
let count = 0;
let list;
let searchResult;
if (this.platForm === "github") {
params = {
q: this.keywords,
order: "desc",
per_page: this.perPage,
page: this.page,
};
log.verbose("search project params", params);
log.verbose("mode", this.mode);
if (this.mode === SEARCH_MODE_REPO) {
searchResult = await this.gitAPI.searchRepositories(params);
list = searchResult.items.map((item) => ({
name: `${item.full_name}(${item.description})`,
value: item.full_name,
}));
} else if (this.mode === SEARCH_MODE_CODE) {
searchResult = await this.gitAPI.searchCode(params);
list = searchResult.map((item) => ({
name:
item.repository.full_name +
(item.repository.description &&
`(${item.repository.description})`),
value: item.repository.full_name,
}));
}
count = searchResult.total_count; // 整体数据量
} else if (this.platForm === "gitee") {
params = {
q: this.q,
order: "desc",
per_page: this.perPage,
page: this.page,
};
if (this.language) {
params.language = this.language; // 注意输入格式: JavaScript
}
log.verbose("search params", params);
searchResult = await this.gitAPI.searchRepositories(params);
count = 99999;
list = searchResult.map((item) => ({
name: `${item.full_name}(${item.description})`,
value: item.full_name,
}));
}
// 判断当前页面,已经是否达到最大页数
if (
(this.platForm === "github" && this.page * this.perPage < count) ||
(this.platForm === "gitee" && list?.length > 0)
) {
list.push({
name: "下一页",
value: NEXT_PAGE,
});
}
if (this.page > 1) {
list.unshift({
name: "上一页",
value: PREV_PAGE,
});
}
if (count > 0) {
const selectedProject = await makeList({
message:
this.platForm === "github"
? `请选择要下载的项目(共 ${count} 条数据)`
: "请选择要下载的项目",
choices: list,
});
if (selectedProject === NEXT_PAGE) {
await this.nextPage();
} else if (selectedProject === PREV_PAGE) {
await this.prevPage();
} else {
// 选中项目 去查询 tag
this.selectedProject = selectedProject;
}
}
}
async nextPage() {
this.page++;
await this.doSearch();
}
async prevPage() {
this.page--;
await this.doSearch();
}
解释上面的代码:
- 我们首先调用了封装的
searchGitAPI
方法,这个方法是专门查询源码仓库的 - 在
searchGitAPI
我们先是进行了平台的判断,因为github
提供的api
是可以通过源码查询仓库的 - 之后我们进行了一些数据的收集,包括搜索的关键字和开发语言,这些都是调用
github/gitee api
所需要的参数 - 然后调用了另一个封装的
doSearch
方法,封装这个方法的目的是为了翻页,因为在翻页的时候还需要再次做查询的操作,代码会重复。 - 在
doSearch
方法中我们对不同平台(github/gitee
)不同搜索模式(通过仓库名称搜索、通过源码搜索)进行了区分,它们会调用不同的api
- 再拿到搜索到的数据之后,在命令行中以列表的形式显示出来让用户选择
- 最后分别封装了
nextPage
和prePage
进行翻页的操作
- 我们首先调用了封装的
4. 我们通过 jw-cli install -d
命令进行测试
3.4 tag 选择 + 翻页功能开发
tag
的选择和翻页的功能和上一小节是相同的逻辑,这里就不再解释,直接看代码吧
1. 修改 packages/utils/Gitee.js
,接入查询 giee tag 的 api
getTags(fullName) {
return this.get(`/repos/${fullName}/tags`);
}
2. 修改 packages/utils/Github.js
,接入查询 github tag 的 api
getTags(fullName, params) {
return this.get(`/repos/${fullName}/tags`, params);
}
3. 修改 packages/install/lib/index.js
...
async action() {
await this.generateGitAPI();
await this.searchGitAPI();
log.verbose("selectedProject", this.selectedProject);
log.verbose("selectedTag", this.selectedTag);
}
...
async searchGitAPI() {
...
...
await this.selectTags();
}
...
async selectTags() {
let tagsList;
this.tagPage = 1;
this.tagPerPage = 30;
tagsList = await this.doSelectTags();
}
async doSelectTags() {
let tagsListChoices = [];
let tagsList
if (this.platForm === "github") {
const params = {
page: this.tagPage,
per_page: this.tagPerPage,
};
log.verbose("search tags params", this.selectedProject, params);
tagsList = await this.gitAPI.getTags(this.selectedProject, params);
tagsListChoices = tagsList.map((item) => ({
name: item.name,
value: item.name,
}));
} else {
log.verbose("search tags params", this.selectedProject);
tagsList = await this.gitAPI.getTags(this.selectedProject);
tagsListChoices = tagsList.map((item) => ({
name: item.name,
value: item.name,
}));
}
if (tagsList.length === 0) return;
if (tagsList.length > 0) {
tagsListChoices.push({
name: "下一页",
value: NEXT_PAGE,
});
}
if (this.tagPage > 1) {
tagsListChoices.unshift({
name: "上一页",
value: PREV_PAGE,
});
}
const selectedTag = await makeList({
message: "请选择tag",
choices: tagsListChoices,
});
if (selectedTag === NEXT_PAGE) {
await this.nextTags();
} else if (selectedTag === PREV_PAGE) {
await this.prevTags();
} else {
this.selectedTag = selectedTag;
}
}
async prevTags() {
this.tagPage--;
await this.doSelectTags();
}
async nextTags() {
this.tagPage++;
await this.doSelectTags();
}
4. 测试
3.5 下载指定分支源码
选择完仓库名和分支后就可以去下载仓库了,这个功能我们可以通过 execa
模块执行 git clone
命令实现
- 修改
packages/install/lib/index.js
async action() {
await this.generateGitAPI();
await this.searchGitAPI();
log.verbose("selectedProject", this.selectedProject);
log.verbose("selectedTag", this.selectedTag);
+ await this.downloadRepo();
}
...
+ async downloadRepo() {
+ const spinner = ora(
+ `正在下载:${this.selectedProject}` +
+ (this.selectedTag ? `(${this.selectedTag})` : "")
+ ).start();
+ try {
+ await this.gitAPI.cloneRepo(this.selectedProject, this.selectedTag);
+ spinner.stop();
+ log.success(
+ `下载模板成功:${this.selectedProject}` +
+ (this.selectedTag ? `(${this.selectedTag})` : "")
+ );
+ } catch (err) {
+ spinner.stop();
+ printErrorLog(err);
+ }
+ }
- 修改
packages/utlis/git/GitServer.js
+ cloneRepo(fullName, tag) {
+ if (tag) {
+ return execa("git", ["clone", this.getRepoUrl(fullName), "-b", tag]);
+ }
+ return execa("git", ["clone", this.getRepoUrl(fullName)]);
+ }
- 修改
packages/utlis/git/Github.js
+ getRepoUrl(fullName) {
+ return `https://github.com/${fullName}.git`;
+ }
- 修改
packages/utlis/git/Gitee.js
+ getRepoUrl(fullName) {
+ return `https://gitee.com/${fullName}.git`;
+ }
- 安装依赖
lerna add ora packages/install
3.6 自动安装依赖 + 启动项目
- 修改
packages/install/lib/index.js
async downloadRepo() {
const spinner = ora(
`正在下载:${this.selectedProject}` +
(this.selectedTag ? `(${this.selectedTag})` : "")
).start();
try {
await this.gitAPI.cloneRepo(this.selectedProject, this.selectedTag);
spinner.stop();
log.success(
`下载模板成功:${this.selectedProject}` +
(this.selectedTag ? `(${this.selectedTag})` : "")
);
+ await this.installDependencies();
+ await this.runRepo();
} catch (err) {
spinner.stop();
printErrorLog(err);
}
}
+ async installDependencies() {
+ const spinner = ora(
+ `正在安装依赖:${this.selectedProject}` +
+ (this.selectedTag ? `(${this.selectedTag})` : "")
+ ).start();
+ try {
+ const ret = await this.gitAPI.installDependencies(
+ process.cwd(),
+ this.selectedProject,
+ this.selectedTag
+ );
+ spinner.stop();
+ if (ret) {
+ log.success(
+ `依赖安装安装成功:${this.selectedProject}` +
+ (this.selectedTag ? `(${this.selectedTag})` : "")
+ );
+ } else {
+ log.error("依赖安装失败");
+ }
+ } catch (err) {
+ spinner.stop();
+ printErrorLog(err);
+ }
+ }
async runRepo() {
await this.gitAPI.runRepo(process.cwd(), this.selectedProject);
}
- 修改
packages/utils/git/GitServer.js
function getProjectPath(cwd, fullName) {
const projectName = fullName.split("/")[1]; // vuejs/vue => vue
const projectPath = path.resolve(cwd, projectName);
return projectPath;
}
function getPackageJson(cwd, fullName) {
const projectPath = getProjectPath(cwd, fullName);
const pkgPath = path.resolve(projectPath, "package.json");
if (pathExistsSync(pkgPath)) {
return fse.readJsonSync(pkgPath);
} else {
return null;
}
}
...
export default class GitServer {
...
installDependencies(cwd, fullName, tag) {
const projectPath = getProjectPath(cwd, fullName);
if (pathExistsSync(projectPath)) {
return execa("npm", ["install", "--registry=https://registry.npmmirror.com"], { cwd: projectPath });
}
return null;
}
runRepo(cwd, fullName) {
const projectPath = getProjectPath(cwd, fullName);
const pkg = getPackageJson(cwd, fullName);
if (pkg) {
const { scripts, bin } = pkg;
if (bin) {
execa("npm", ["run", "-g", name, "--registry=https://registry.npmmirror.com"], {
cwd: projectPath,
stdout: "inherit",
});
}
if (scripts && scripts.dev) {
return execa("npm", ["run", "dev"], {
cwd: projectPath,
stdout: "inherit",
});
} else if (scripts && scripts.serve) {
return execa("npm", ["run", "serve"], {
cwd: projectPath,
stdout: "inherit",
});
} else if (scripts && scripts.start) {
return execa("npm", ["run", "start"], {
cwd: projectPath,
stdout: "inherit",
});
} else {
log.warn("未找到启动命令");
}
} else {
}
}
那么至此我们的源码下载脚手架就已经开发完成了,最后我们用一张图总结所有流程