持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情
本文为原创文章,引用请注明出处,欢迎大家收藏和分享💐💐
哈喽大咖好,我是Johnny,这次给大家重新缕一缕如何用typescript
配合周边插件做一个易用的脚手架管理工具。
想到写这篇文章的原因有俩:一是最近业务上有类似需求,作为这领域的探索和整理,给有需要的小伙伴做个参考;二是从前端个人或团队的技术储备角度出发,更抽象和统一的开发者工具,能使开发效率有效提升,省去大量的代码copy和update的冗余工作。
为了直观给大家展示关键流程,本文实现的脚手架创建步骤为:
命令输入 → 检查目录合法 → 选择github工程模板 → 选择版本 → 填入必要信息 → 模板下载
https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c36c726e85934991966cb0c15b26f74c~tplv-k3u1fbpfcp-zoom-in-crop-mark:1304:0:0:0.awebp?
欲善其功必先利其器,讲实现前,我们先了解下各插件的功能吧🐶🐶。。。
chalk是一个文字变色器,它可以在命令行实现以下文字效果:
在代码执行过程中往往需要把一些重要信息高亮输出,这个插件便恰到好处。举个例子:
import { cyan, bgYellow } from 'chalk';
console.log('这是正常的文字...');
console.log(cyan('这是用了cyan的文字...'));
console.log(bgYellow('这是用了bgYellow的文字...'));
复制代码
效果:
commander node.js命令行界面的完整解决方案,受 Ruby Commander启发。简单演示下commander的用法:
import { program } from 'commander';
program
.command('show <message>')
.description('展示你输入的消息')
.option('-t, --tip <tips>', '消息提示', '默认值')
.action((message, cmd) => {
console.log(message, cmd);
});
复制代码
上面定义了一条交互命令,功能就是让用户执行show
命令,并输入“展示的消息”
和“消息提示”
2个参数后,命令面板就会打印用户的消息。
command
和option
分别代表执行的命令和命令后面可选参数,<>
,[]
包裹的参数被认为是强制、可选输入项,强制项缺失系统会直接报错。action
便是用户按回车后要执行的操作,(message, cmd)
分别代表command
和option
紧跟的参数内容。上面代码执行效果:
figlet能把你输入的文字通过字符组合变化出各种效果,这里就不细述了,大家可以看官方样例。
这2个库主要用于nodejs环境下对文件的操作,fs-extra是fs的拓展,让更少代码可以实现同样的操作。
inquirer
能满足你在命令行的各种输入交互,大概的使用规则就是通过async/await函数包裹交互式命令,等待用户输入后再获取结果执行后续逻辑,例如:
import { green } from 'chalk';
import { prompt } from 'inquirer';
const choseQuestion = async () => {
const { question } = await prompt([
{
name: 'question',
type: 'list',
message: '请选择您的问题',
choices: ['如何上热搜', '如何财富自由', '我要回家躺平'],
},
]);
console.log(`您选的问题是:${green(question)}`);
};
choseQuestion().then();
复制代码
效果
https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/44297641603d4b6eb0e45caec0c4f219~tplv-k3u1fbpfcp-zoom-in-crop-mark:1304:0:0:0.awebp?
module-alias
主要兼容tsc编译的引用路径问题,下面会细述。
命令行的loading效果,举个🌰:
import ora from 'ora';
export const loadingDemo = async <T>(message: T): Promise<T> => {
const spinner = ora(message);
spinner.start(); // 开启加载
try {
const result: T = await new Promise(resolve => {
setTimeout(() => {
resolve(message);
}, 1000);
});
spinner.succeed();
return Promise.resolve(result);
} catch (err: any) {
spinner.fail(err);
return Promise.reject(err);
}
};
loadingDemo<string>('我要loading 1秒').then(res => console.log(res));
复制代码
效果
https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/37d2516fef5741d2b24a0fe4c1415e44~tplv-k3u1fbpfcp-zoom-in-crop-mark:1304:0:0:0.awebp?
shelljs
是nodejs
下的脚本语言解析器,具有丰富且强大的底层操作(Windows/Linux/OS X)权限。
本项目中shelljs主要作用是clone git仓库等。可能大家会有疑问,为什么对仓库的操作不用rest api?例如github有相对完善的rest api库,gitlab也有自己的api,而且网上也有很多插件封装了这些api。
关于这个灵魂拷问,笔者的想法是:api一般配套系列的鉴权流程,假如是一个public的仓库其实没必要做那么多额外的安全操作;其次项目也是想尽量减少三方制约的规则,方便以后作为一个纯净版项目移植到其他地方,可以到shelljs满足不了的情景再考虑加入api模块。
尽管如此,项目也保留了api目录方便以后拓展。
前储备知识介绍完了,可以开始逐步实现我们的逻辑。这部分会讲关键步骤和思路,源码有兴趣的同学可以去github上看。
.
├── .eslintrc.js # eslint配置
├── bin # tsc转换后的js源码
├── config # 环境配置
├── .gitignore
├── .prettierrc # prettier配置
├── README.md
├── package-lock.json # 依赖锁
├── package.json # 项目配置
├── src
│ ├── tools # 工具包源码
│ │ ├── cliCreator # 脚手架创建
│ │ ├── cliUpdater # 脚手架更新「待建」
│ │ └── proxy # 开发服务代理小工具「待建」
│ └── utils # 基础方法
└── tsconfig.json # ts配置
复制代码
上面是轻量化版本,原项目是基于nestjs打造,因为在满足脚手架下载功能之外,还要启动本地服务来做其他开发提效工作。但是本文只叙述创建脚手架这一部分,方便大家理解就把项目简化了。
其中src/tools
包含脚手架创建和更新功能,src/utils
保存全局方法,eslintrc.js,prettierrc,tsconfig.json
分别是代码规整文件,bin
则是tsc编译后的js文件目录。
众所周知要直接在命令行使用自定义的命令,必须要先安装好Nodejs环境,然后再把命令注册到全局中去。下面举个例子:
mkdir hello
cd hello
npm init -y
touch helloWorld.js
echo '#!/usr/bin/env node \n console.log("hello world")' > helloWorld.js
复制代码
假如你用的是mac电脑,安装好nodejs后随便找个目录执行上面一系列命令后,会得到这样项目结构
接下来打开package.json
,给项目加一条执行命令,例如:
{
"name": "hello",
"version": "1.0.0",
"description": "",
"main": "index.js",
"bin": {
"hello": "./helloWorld.js"
},
"scripts": {
"test": "echo "Error: no test specified" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
复制代码
"hello": "./helloWorld.js"
的意思是假如执行hello命令,nodejs就会选择同文件夹下helloWorld.js执行。
到这里我们缺最后一步就是把hello命令挂到全局中去,要实现这个很简单,本地挂载直接在package.json
所在目录执行npm link
。
注册完后随便在电脑找个目录执行hello
,控制台就会输出hello world
了;而远程npm只需要在安装时加-g
参数即可,这就是全局命令注册方法。
另附:npm软链常用命令
我们确定src/tools/cliCreator/bin/demo.ts
作为创建脚手架项目的入口文件,内容如下:
#!/usr/bin/env node
import { cyan } from 'chalk';
import { program } from 'commander';
import figlet from 'figlet';
import { create } from '../lib';
import pkg from '@root/package.json';
program
.command('create <project-name>')
.description('创建项目')
.option('-f, --force', '是否强制覆盖')
.action((projectName, cmd) => {
create(projectName, cmd).then();
});
program.on('--help', () => {
console.log(
cyan(
figlet.textSync('dc', {
font: '3D-ASCII',
horizontalLayout: 'default',
verticalLayout: 'default',
width: 80,
whitespaceBreak: true,
}),
),
);
});
program.name('dc').usage(`<command> [option]`).version(`dc ${pkg.version}`);
program.parse(process.argv);
复制代码
代码简单定义了一个create命令,并且强制带上项目名作为参数。另提供-f
可选参数,是否在存在路径情况下强制覆盖。
良好的编程习惯下,在到达核心创建脚手架逻辑前,应该在外面还有一层封装,对每个输入参数做容错处理。
在create
中,我们有-f --force
参数要处理,所以选项流程函数src/tools/cliCreator/lib/index.ts
可以这样写:
import path from 'path';
import fse from 'fs-extra';
import { clearDirectory } from './filesHandler';
import { mainLine } from './creator';
import { red } from 'chalk';
export const create = async (projectName: string, options: Record<string, unknown>) => {
try {
// 获取当前工作目录
const targetDirectory = path.resolve(process.cwd(), projectName);
let result = '';
// 检查是否有文件覆盖
if (fse.existsSync(targetDirectory)) {
result = await clearDirectory(targetDirectory, options);
}
if (result === 'Cancel') return;
// 创建项目总线
await mainLine(projectName);
} catch (err) {
console.log(red('❌ Error: ' + err));
}
};
复制代码
在create方法中,所有步骤的错误都会被catch捕获,在catch我们可以设计统一的出错处理,例如可以上报logger。
通过上述流程后,我们基本可以确保所有输入选项都处理好了,接下来就可以到核心的创建流程了。创建流程在src/tools/cliCreator/lib/creator.ts
路径里,完整代码:
import { prompt } from 'inquirer';
import shell from 'shelljs';
import fse from 'fs-extra';
import fs from 'fs';
import { cyan, red, green } from 'chalk';
import { loading } from '@root/src/utils/global';
import { template, TTemplate } from '../constants/repo';
type TInfo = {
repo: string;
name: string;
version: string;
author: string;
description: string;
};
/**
* 创建项目主线程
*/
export const mainLine = async (projectName: string) => {
try {
const info: TInfo = {
repo: '',
name: projectName,
version: '',
author: '',
description: '',
};
// 仓库信息 —— 模板信息
info.repo = await getRepoInfo();
// 标签信息 —— 版本信息
info.version = await getTagInfo(info.repo);
// 作者
info.author = await getAuthor();
// 描述
info.description = await getDescription();
// 下载模板
await loading(`下载模板 ${info.repo}`, download, info);
console.log(green(`成功创建 ${cyan(info.name)}`));
} catch (err) {
console.log(red('❌ Error: ' + err));
}
};
/**
* 选取模板
*/
const getRepoInfo = async () => {
// ...
};
/**
* 选取版本
* @param repo
*/
const getTagInfo = async (repo: string): Promise<string> => {
// ...
};
/**
* 输入作者
*/
const getAuthor = async () => {
// ...
};
/**
* 输入项目描述
*/
const getDescription = async () => {
// ...
};
/**
* 获取模板版本
* @param repo
*/
const getTagInfoList = (repo: string): Promise<string[]> => {
// ...
};
/**
* 下载模板
* @param info
*/
const download = async (info: TInfo) => {
// ...
};
复制代码
套路一样的,mainLine
负责把控创建步骤每个环节,任意一步出错会走catch里处理;另外每个步骤的处理就是用到了我们上面介绍的插件能力。
由于这个项目是nestjs拆出来的简单版,没有用框架的构建能力,假如在项目中用了路径别名「path alias」,并且直接用tsc编译,那么输出的js包会有路径引用不到的问题,举个简单例子:
tsconfig.json
在某个文件(src/tools/cliCreator/lib/creator.ts
)调用,本地开发是没问题的:
import { loading } from '@root/src/utils/global';
复制代码
但是在tsc编译后再运行就会出错,原因是无法识别@root
。
再追查下原因,我们去到编译后文件已排查,发现路径根本没转换,这不是芭比Q了嘛。。。
为了解决这个问题,要么就使用webpack、nest这些打包工具,要么就找些三方插件支持。对比下前者肯定不是最优选,只会使得项目越来越重,在后者这里推荐module-alias插件,使用起来方便,只需要在package.json
注册,然后在总入口引入就可以了。
到这里,一个简单易用的脚手架就做好了,逻辑不复杂,小伙伴们可以尝试下。