版权声明:本人文章仅在掘金平台发布,请勿抄袭搬运,转载请注明作者及原文链接 🦉
作者:北岛贰
https://juejin.cn/post/7256702654579310652
阅读提示:网页版带有主题和代码高亮,阅读体验更佳 🍉
大厂技术 高级前端 Node进阶
点击上方 程序员成长指北,关注公众号
回复1,加入高级Node交流群
如果你不想看文章,可以直接阅读源码:xwg-cli[1],如果有收获请点个 star。
开门见山,先看效果:
xwg \--help
:
xwg \--version
:
xwg create demo
:
xwg create \--help
:
再来看看模板的质量如何:
每份模板都是我精心准备的,具备较为完善的工程化能力,包括 eslint
,commitlint
,git cz
,CHANGELOG
等。但是也没有过多地干涉你的使用,并不是完全强制的。例如,虽然我配置了 eslint
,但是没有强制规则,你可以自己增减。
koa:
搭建了完整的 koa
开发结构,内置了一个 User
模块,开箱即用。像 sequelize
、log4js
等功能都是完备可用的,是不是非常用心,有木有!!
uniapp + vue2 + uview:
uniapp 这份 vue2 版本的模板,基于我自己的商业项目进行改造,像请求模块都是内置好的。另外,这个模板还加入了 uview-ui
,省去了你寻找 UI 添加组件库的步骤,关键是添加 UI 组件库真的有不少坑,用了这个模板直接快人一步,少踩起码 10 个坑。
使用简介
在介绍开发代码之前,想必你也想先尝试下效果。
首先,确保你的 node
版本是 14.18+
或者 16+
。
执行 yarn add xwg-cli \-g
,重新打开终端执行 xwg \--version
看看是否成功安装。目前支持的命令有:
xwg \--help
xwg \--version
xwg create <项目名>
其实 --help
,--version
是自带的(后面会详细讲),这两个命令更严谨的说法是参数,参数一般就是 --
开头,只有 create
才算是命令,目前 cli 仅完成了创建项目的能力,其他的能力正在思考建设中。
如果你不想下载,也可以执行 npx xwg-cli create demo
尝试创建项目。想必你又有疑问,为什么我的 cli 叫作 xwg-cli
,但是我执行的时候是 xwg xxx
,为什么不是 xwg-cli xxx
?这些问题都将在我们接下来的文章中解答。
接下来的内容会比较长,请坐好小板凳。
如何搭建项目的开发环境
cli 工具本质是一个 npm 库,那么我们就应该按照库的模式搭建项目开发环境。如果你不知道如何搭建,请参考我的文章:开发一个 npm 库应该做哪些工程配置?[2]。如果你不是很想从头搭建开发环境,那么,你可以直接 fork 我的仓库:xwg-cli[3]。
我的项目使用了 ts,但是不复杂,基本和 js 差不多,但是这仍然要求你具备比较好的 ts 知识,否则阅读文章会有一点难度。
需要用到的第三方库
库名 | 作用 |
---|---|
commander | 解析用户在终端输入的命令及参数,例如 create、--help 等 |
chalk | 为终端输出的文字增加各种各样的颜色 |
inquirer | 给用户提供各种交互,包括输入、选择等,例如提示用户选择项目的创建类型,其可以提供输入框、radio、checkbox 等强大的交互控件 |
ora | 增加 loading,例如下载模板时增加 loading |
fs-extra | fs 模块的增强,支持更强大的文件读写操作 |
download-git-repo | 下载模板 |
chalk
我下载的 chalk
的大版本是 4
,类型定义可能和其它版本不一样,具体请以你下载的版本为准。
import chalk from 'chalk';
console.log(chalk.red('这会输出红色的字'))
console.log(chalk.blue('这会输出蓝色的字'))
console.log(chalk.cyan('这会输出青色的字'))
console.log(chalk.cyan.bold('这会输出青色加粗的字'))
console.log(chalk.cyan.italic('这会输出青色斜体的字'))
chalk 改变字体颜色的所有方法定义:
declare type ForegroundColor =
| 'black'
| 'red'
| 'green'
| 'yellow'
| 'blue'
| 'magenta'
| 'cyan'
| 'white'
| 'gray'
| 'grey'
| 'blackBright'
| 'redBright'
| 'greenBright'
| 'yellowBright'
| 'blueBright'
| 'magentaBright'
| 'cyanBright'
| 'whiteBright';
chalk 改变字体背景色的所有方法定义:
declare type BackgroundColor =
| 'bgBlack'
| 'bgRed'
| 'bgGreen'
| 'bgYellow'
| 'bgBlue'
| 'bgMagenta'
| 'bgCyan'
| 'bgWhite'
| 'bgGray'
| 'bgGrey'
| 'bgBlackBright'
| 'bgRedBright'
| 'bgGreenBright'
| 'bgYellowBright'
| 'bgBlueBright'
| 'bgMagentaBright'
| 'bgCyanBright'
| 'bgWhiteBright';
chalk 改变字体属性所有方法的定义:
declare type Modifiers =
| 'reset'
| 'bold'
| 'dim'
| 'italic'
| 'underline'
| 'inverse'
| 'hidden'
| 'strikethrough'
| 'visible';
了解这些基本已经足够你开发使用了。
commander
import { program } from 'commander';
program.name
定义命令的名称。
program.name(chalk.cyan('xwg'))
program.name(chalk.cyan('demo'))
program.usage
定义命令的用法。
program.name(chalk.cyan('xwg')).usage(`${chalk.yellow('<command>')} [options]`);
program.version
定义 --version
。
program.version(
`\r\n ${chalk.cyan.bold(VERSION)}
${chalk.cyan.bold(BRAND_LOGO)}`
);
program.on
监听某个参数的执行,并执行回调。
program.on("--help", function () {
console.log(`\r\n终端执行 ${chalk.cyan.bold("xwg <command> --help")} 获取更多命令详情\r\n`);
});
当我们监听某个命令时,可以这么写:
program
.command('create <project-name>') // 这里不能使用 chalk
.description(chalk.cyan('创建新项目'))
.option('-f, --force', chalk.red('如果目录已存在将覆盖原目录,请谨慎使用,这会先删除你已存在的项目再进行创建,可能会存在意外情况'))
.action(这里是一个回调函数,执行这个命令的操作);
command 监听命令,description 设置命令的描述,option 是设置命令的可选参数,比如这里设置了 --force
,意思就是是否强制覆盖目录,这个参数是可选的。所以 --help
,--version
其实也是参数。action 就是这个命令真正的执行的方法,这里我们定义了一个 create
函数,该方法最后就会执行这个函数,在这个函数中我们去执行一系列的包括询问用户、添加 loading、下载模板等操作。
program.parse(process.argv);
是固定写法,在最后执行,就是将 process.argv 传递给 parse,parse 会解析用户在终端输入的一切命令还有参数,并进行执行。
来看完整版:
const runner = () => {
program.name(chalk.cyan('demo')).usage(`${chalk.yellow('<command>')} [options]`);
program.version(
`\r\n ${chalk.cyan.bold(VERSION)}
${chalk.cyan.bold(BRAND_LOGO)}`
);
program
.command('create <project-name>') // 这里不能使用 chalk
.description(chalk.cyan('创建新项目'))
.option('-f, --force', chalk.red('如果目录已存在将覆盖原目录,请谨慎使用,这会先删除你已存在的项目再进行创建,可能会存在意外情况'))
.action(() => {
// 执行操作
});
program.on("--help", function () {
console.log(`\r\n终端执行 ${chalk.cyan.bold("xwg <command> --help")} 获取更多命令详情\r\n`);
});
program.parse(process.argv);
};
inquirer
具体配置参见官方文档:www.npmjs.com/package/inq…[4]
贴太多代码影响阅读体验,想看具体实现细节和类型定义的可以去看源码。
import Inquirer from 'inquirer';
export const prompt = async (prompts: any[]) => {
return await new Inquirer.prompt(prompts);
};
/** 询问要创建的项目类型 */
export const askCreateType = async () => {
const { projectType } = await prompt([
// 返回值为 Promise
// 具体配置参见:https://www.npmjs.com/package/inquirer#questions
{
type: "list",
name: "projectType",
message: "请选择你要创建的项目类型",
choices: [
{ name: "vue", value: 'vue' },
{ name: "react", value: 'react' },
{ name: "uniapp", value: 'uniapp' },
{ name: "koa", value: 'koa' },
// { name: "nest", value: 'nest' },
{ name: "library", value: 'library' },
],
},
]);
return projectType;
};
ora
import ora from "ora";
import chalk from "chalk";
const spinner = ora('loading');
spinner.start(); // 开启加载
spinner.succeed('成功');
spinner.fail("请求失败,正在重试...");
download-git-repo
在网上看到其他文章说该库不支持 Promise,需要 util 模块 promisify 一下:
import util from 'node:util';
import download from 'download-git-repo';
export const downloadGitRepo = util.promisify(download);
// 下面是伪代码
downloadGitRepo(`direct: 仓库地址`, 存放的目标地址, { clone: true })
一些前置知识
node_modules 的 bin 目录
我们下载的一些 cli 工具,会放在 node_modules
下一个 .bin
的目录中。虽然其文件没有后缀,但其实就是 js 文件,它会去找对应的代码执行,下面是 bin 目录下 nodemon 命令的文件内容:
当我们执行命令时 node 会从这个目录中找到我们要执行的命令。那为什么这些包会在这里生成一个命令文件呢?主要是因为可以在 package.json
中配置字段 bin
:
我们这里配置一个命令 xwg
,它在执行时会去寻找 bin
文件夹下的 cli.js
文件进行执行。这也就是为什么我的包名是 xwg-cli
,但是我却可以通过 xwg
来执行命令。我们还可以创建多个,不同的命令对应不同的执行文件:
"bin": {
"xwg": "./bin/cli.js",
"ikun": "./bin/ikun.js"
},
必要的注释
接下来也是一个非常重要的点:
可以看到,在 cli.js
的首行,有一行注释:#! /usr/bin/env node
。这个注释可不是多余,所有的 node cli 必须在开头包含此注释,否则命令就没法正常运行,这句注释就是指该命令在 node 环境下运行,所以请务必不要省略此注释!
本地测试
我们可以现在 bin/cli.js
下随便写点什么:
#! /usr/bin/env node
console.log('跑起来了')
基于当前项目打开你的终端,运行 npm link,链接你的本地包,此时我们终端运行 xwg
试试:
如果没有权限请记得给你的项目赋予管理员权限。
工程化配置
tsconfig.json
noEmit
必须是 false,否则 tsc 编译后不输出文件。
include
必须包含 types/**/*
,否则在 types 下的声明不起效果。
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM"],
"moduleResolution": "Node",
"strict": true,
"sourceMap": false,
"resolveJsonModule": true,
"isolatedModules": false,
"esModuleInterop": true,
"noEmit": false,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true
},
"include": ["src", "types/**/*"]
}
nodemon
由于每次更改都要重新执行一遍 node ./lib/index.js
过于麻烦,所以需要借助于 nodemon。
下载 nodemon:yarn add nodemon \-D
。
新建 nodemon.json
:
{
"watch": ["src"], // 监听 src 目录
"ext": "ts", // 匹配扩展名为 ts 的文件
"exec": "rimraf lib && tsc --outDir lib --module CommonJS"
}
watch
:监听 src 目录。
ext
:匹配扩展名为 ts 的文件。
exec
:rimraf 是一个 npm 包,作用是删除目录文件,优点是跨平台兼容性好。该命令的意义是:tsc 编译 src 下的 ts 文件并制定输出目录为 lib (--outDir lib
),并指定编译目标的模块化规范是 commonjs(--module CommonJS
)。
因为有 nodemon.json
的配置文件,我们在 package.json
中可以简写:
"scripts": {
"dev": "nodemon",
},
每次我们执行 npm run dev
实际上就是执行 nodemon,nodemon 会寻找 nodemon.json
并执行里面的配置,每次当我们修改 src 的 ts 文件它就会删除 lib 并帮助我们重新 tsc 编译一遍。
文件目录结构
bin 文件我们存放命令的执行入口
lib 的代码是 src 经过 tsc 编译为 commonjs 的代码
src 是我们编写核心代码的地方
test 用于存放单元测试
types 用于存放 ts 类型声明
.editorconfig 编辑器配置
.eslintignore eslint 忽略文件
.eslintrc eslint 配置文件
commitlin.config.js commit 提交信息校验的配置文件,commit message 不对会报错
nodemon.json 配置 nodemon,帮助我们保存代码后热更新,不用再执行
node lib/index.js
tsconfig.json ts 配置文件
bin/cli.js
#! /usr/bin/env node
/**
* xwg cli
* @author xiwenge <1825744594@qq.com>
* @create 2023/06/24
*/
'use strict';
const runner = require('../lib/index').default;
runner();
不建议把大量的代码堆在 bin 目录下,可能很多教程会这么做,但是这样拆分代码不太简洁美观。我们将所有核心代码放入 lib 目录下,从 lib 引入核心代码来进行运行。
在这里我们引入 runner 函数进行执行。
src/index.ts
import runner from './runner';
export default runner;
src/runner.ts
这里,我们把 command 相关的代码拆出去。
import { program } from 'commander';
import chalk from 'chalk';
import {
create
} from './commands';
import { BRAND_LOGO, VERSION } from './const';
const runner = () => {
program.name(chalk.cyan('xwg')).usage(`${chalk.yellow('<command>')} [options]`);
program.version(
`\r\n ${chalk.cyan.bold(VERSION)}
${chalk.cyan.bold(BRAND_LOGO)}`
);
create();
program.on("--help", function () {
console.log(`\r\n终端执行 ${chalk.cyan.bold("xwg <command> --help")} 获取更多命令详情\r\n`);
});
program.parse(process.argv);
};
export default runner;
src/const.ts
此目录用于存放我们需要使用到的静态变量。这种艺术字可以访问 ascii 艺术字[5],我们这里使用的字体是 ANSI Shadow
。
/**
* 静态变量
* @author xiwenge <1825744594@qq.com>
* @create 2023/06/25
*/
import fs from 'fs-extra';
import path from 'node:path';
import util from 'node:util';
import download from 'download-git-repo';
/** 当前根目录 */
export const ROOT_DIR = path.resolve(__dirname, '../');
const { version } = fs.readJSONSync(path.resolve(ROOT_DIR, 'package.json'));
/** https://tooltt.com/art-ascii/ font: ANSI Shadow */
export const BRAND_LOGO = `
██╗ ██╗██╗ ██╗ ██████╗ ██████╗██╗ ██╗
╚██╗██╔╝██║ ██║██╔════╝ ██╔════╝██║ ██║
╚███╔╝ ██║ █╗ ██║██║ ███╗ ██║ ██║ ██║
██╔██╗ ██║███╗██║██║ ██║ ██║ ██║ ██║
██╔╝ ██╗╚███╔███╔╝╚██████╔╝ ╚██████╗███████╗██║
╚═╝ ╚═╝ ╚══╝╚══╝ ╚═════╝ ╚═════╝╚══════╝╚═╝
`;
/** 当前版本号 */
export const VERSION = version;
export const getRepoURL = (tag: string) => {
return `https://gitee.com/redstone-1/${tag}.git`;
};
export const downloadGitRepo = util.promisify(download);
封装loading和prompt
loading
import ora from "ora";
import chalk from "chalk";
import { LoadingOther } from '../types';
/**
* 睡觉函数
* @param {Number} delay 睡眠时间
*/
const sleep = (delay: number) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(false);
}, delay);
});
};
/**
* 加载中方法
* @param message - 提示信息
* @param callback - 执行的回调
* @param projectName - 项目名
* @returns
*/
export const loading = async (message: string, callback: () => any, other: LoadingOther): Promise<any> => {
const spinner = ora(message);
spinner.start(); // 开启加载
try {
const res = await callback();
// 加载成功
spinner.succeed(
`${chalk.black.bold('下载成功!执行以下命令打开并运行项目:')}
\r\n ${chalk.gray.bold(`cd ${other?.projectName}`)}
\r\n ${chalk.gray.bold('npm install')}
\r\n ${chalk.gray.bold('npm run dev')}
\r\n ${chalk.gray.bold('问题、意见、建议请反馈至:https://github.com/Redstone-1/xwg-cli/issues')}
`
);
return res;
} catch (error) {
// 加载失败
spinner.fail("请求失败,正在重试...");
await sleep(1000);
// 重新拉取
return loading(message, callback, other);
}
};
prompt
import Inquirer from 'inquirer';
export default async (prompts: any[]) => {
return await new Inquirer.prompt(prompts);
};
askUser.ts
将所有询问用户的操作都封装在此,这里仅贴出示例:
import prompt from "../../utils/prompt";
// ========== library ==========
/** 询问要创建的项目类型 */
export const askCreateType = async () => {
const { projectType } = await prompt([
// 返回值为 Promise
// 具体配置参见:https://www.npmjs.com/package/inquirer#questions
{
type: "list",
name: "projectType",
message: "请选择你要创建的项目类型",
choices: [
{ name: "vue", value: 'vue' },
{ name: "react", value: 'react' },
{ name: "uniapp", value: 'uniapp' },
{ name: "koa", value: 'koa' },
// { name: "nest", value: 'nest' },
{ name: "library", value: 'library' },
],
},
]);
return projectType;
};
...
create 命令
新建 commands 目录存放各类命令
拆分 action
将 action 的回调函数 create 拆分出去:
create.ts
这里主要做了一件事,判断要创建的项目目录名是否存在,分别走入不同的分支逻辑:
import path from 'path';
import fs from 'fs-extra';
import dirExistCall from './dirExistCall';
import downloadRepo from './downloadRepo';
/**
* 创建新项目
* @param projectName - 项目名
* @param options - 命令参数
*/
export default async (projectName: string, options: any) => {
// 获取当前工作目录
const cwd = process.cwd();
// 拼接得到项目目录
const targetDirectory = path.join(cwd, projectName);
// 判断目录是否存在
if (fs.existsSync(targetDirectory)) {
await dirExistCall(options, projectName, targetDirectory);
} else {
await downloadRepo(projectName, targetDirectory);
}
};
dirExistCall.ts
当目录已经存在时,需要询问用户是否覆盖。
import fs from "fs-extra";
import downloadRepo from './downloadRepo';
import { askOverwrite } from './askUser';
/**
* 如果目录已经存在时调用
* @param options - 命令参数
* @param targetDirectory - 目标路径
*/
export default async (options: any, projectName: string, targetDirectory: string) => {
// 判断是否使用 --force 参数
if (options.force) {
// 删除重名目录
await fs.remove(targetDirectory);
await downloadRepo(projectName, targetDirectory);
} else {
const { isOverwrite } = await askOverwrite();
// 选择 Overwirte
if (isOverwrite) {
// 先删除掉原有重名目录
await fs.remove(targetDirectory);
await downloadRepo(projectName, targetDirectory);
}
}
};
downloadRepo.ts
当用户将所有的选择都确认后执行下载模板的操作。下面给出部分代码示例,请从下往上阅读:
import {
askCreateType,
askNeedTypeScript,
askIsAgreeCli,
askVueVersion,
askNeedUviewUI,
} from "./askUser";
import { loading } from "../../utils/loading";
import { getRepoURL, downloadGitRepo } from "../../const";
import { TProjectType } from '../../types';
import chalk from "chalk";
/**
* 下载 vue 模板
* @param projectName - 项目名称
* @param targetDirectory - 目标存储路径
*/
const downloadVueTemplate = async (projectName: string, targetDirectory: string) => {
let repoURL = '';
const needTypeScript = await askNeedTypeScript();
if (needTypeScript) {
repoURL = getRepoURL('vue-template-typescript');
}
if (!needTypeScript) {
repoURL = getRepoURL('vue-template');
}
await loading('正在下载模板,请稍后...', () => downloadGitRepo(`direct:${repoURL}`, targetDirectory, { clone: true }), { projectName });
};
/**
* 执行创建命令
* @param projectType - 项目类型 "library" | "react" | "vue" | "uniapp" | "koa" | nest""
* @param projectName - 项目名称
* @param targetDirectory - 目标存储路径
*/
const execCreate = async (projectType: TProjectType, projectName: string, targetDirectory: string) => {
switch (projectType) {
case 'library':
await downloadLibraryTemplate(projectName, targetDirectory);
break;
case 'vue':
await downloadVueTemplate(projectName, targetDirectory);
break;
case 'react':
await downloadReactTemplate(projectName, targetDirectory);
break;
case 'uniapp':
await downloadUniappTemplate(projectName, targetDirectory);
break;
case 'koa':
await downloadKoaTemplate(projectName, targetDirectory);
break;
case 'nest':
console.log(chalk.gray.bold(`\r\n 开发中,敬请期待...\r\n`));
break;
}
};
/**
* 创建项目
* @param projectName - 项目名称
* @param targetDirectory - 目标存储路径
*/
export default async (projectName: string, targetDirectory: string) => {
console.log(chalk.red.bold(`\r\n 请注意:本 cli 下大部分模板采用 vite 构建,node 版本需要 14.18+ 或 16+ 或更高\r\n`));
const projectType = await askCreateType();
await execCreate(projectType, projectName, targetDirectory);
};
到这里我们就完成了 create 命令,现在执行 xwg create xxx
就可以创建项目了。
写在最后
贴了太多代码看起来可能不是很顺畅,建议直接去看源码,更能快速上手学习。
在其他的一些高级 cli 教程里,使用 babel 进行各种配置文件的生成和写入,这是更高级的内容,暂时做不了。目前比较呆的处理方式是写一堆模板,不同的配置下载不同的模板。引入 babel 可以减少模板仓库数量,根据用户的选择动态的删除、插入各种文件,具备更强大更灵活的处理能力,但是目前不具备 babel 的运用能力,后续再考虑进行升级。若有愿意共建的朋友,欢迎 Pr。
Node 社群
我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。
“分享、点赞、在看” 支持一下