yyds,这可能是你第一个自定义的脚手架

·  阅读 1353
yyds,这可能是你第一个自定义的脚手架

本文为原创文章,引用请注明出处,欢迎大家收藏和分享💐💐

开场

哈喽大咖好,我是Johnny,这次给大家重新缕一缕如何用typescript配合周边插件做一个易用的脚手架管理工具。

想到写这篇文章的原因有俩:一是最近业务上有类似需求,作为这领域的探索和整理,给有需要的小伙伴做个参考;二是从前端个人或团队的技术储备角度出发,更抽象和统一的开发者工具,能使开发效率有效提升,省去大量的代码copy和update的冗余工作。

场景演示

为了直观给大家展示关键流程,本文实现的脚手架创建步骤为:

命令输入 → 检查目录合法 → 选择github工程模板 → 选择版本 → 填入必要信息 → 模板下载

效果图

2022-06-01 17.15.20.gif

必备插件

欲善其功必先利其器,讲实现前,我们先了解下各插件的功能吧🐶🐶。。。

插件一览

  • chalk:命令行彩色文字
  • commander:完整的 node.js 命令行解决方案
  • figlet:花里胡哨的命令行艺术字
  • fs:nodejs的文件系统,多文件curd
  • fs-extra:fs升级版,提供更便利的API和编码方式
  • inquirer:命令行输入交互,提供多种问答方式
  • module-alias:nodejs别名路径转换器
  • ora:loading效果
  • shelljs:支持nodejs中执行shell命令神器

chalk

chalk是一个文字变色器,它可以在命令行实现以下文字效果:

image.png

在代码执行过程中往往需要把一些重要信息高亮输出,这个插件便恰到好处。举个例子:

import { cyan, bgYellow } from 'chalk';

console.log('这是正常的文字...');
console.log(cyan('这是用了cyan的文字...'));
console.log(bgYellow('这是用了bgYellow的文字...'));
复制代码

效果:

image.png

commander

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个参数后,命令面板就会打印用户的消息。

  • commandoption分别代表执行的命令和命令后面可选参数,<>,[]包裹的参数被认为是强制、可选输入项,强制项缺失系统会直接报错。
  • action便是用户按回车后要执行的操作,(message, cmd)分别代表commandoption紧跟的参数内容。

上面代码执行效果: image.png

figlet

figlet能把你输入的文字通过字符组合变化出各种效果,这里就不细述了,大家可以看官方样例

fs 和 fs-extra

这2个库主要用于nodejs环境下对文件的操作,fs-extra是fs的拓展,让更少代码可以实现同样的操作。

inquirer

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();
复制代码

效果 2022-06-02 10.33.10.gif

module-alias

module-alias主要兼容tsc编译的引用路径问题,下面会细述。

ora

命令行的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));
复制代码

效果 2022-06-02 10.55.45.gif

shelljs

shelljsnodejs下的脚本语言解析器,具有丰富且强大的底层操作(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后随便找个目录执行上面一系列命令后,会得到这样项目结构 image.png

接下来打开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里处理;另外每个步骤的处理就是用到了我们上面介绍的插件能力。

解决typescript compile的路径问题

由于这个项目是nestjs拆出来的简单版,没有用框架的构建能力,假如在项目中用了路径别名「path alias」,并且直接用tsc编译,那么输出的js包会有路径引用不到的问题,举个简单例子:

tsconfig.json image.png

在某个文件(src/tools/cliCreator/lib/creator.ts)调用,本地开发是没问题的:

import { loading } from '@root/src/utils/global';
复制代码

但是在tsc编译后再运行就会出错,原因是无法识别@rootimage.png

再追查下原因,我们去到编译后文件已排查,发现路径根本没转换,这不是芭比Q了嘛。。。 image.png

为了解决这个问题,要么就使用webpack、nest这些打包工具,要么就找些三方插件支持。对比下前者肯定不是最优选,只会使得项目越来越重,在后者这里推荐module-alias插件,使用起来方便,只需要在package.json注册,然后在总入口引入就可以了。

落幕

到这里,一个简单易用的脚手架就做好了,逻辑不复杂,小伙伴们可以尝试下。

感谢大家阅览并欢迎纠错,欢迎大家关注本人公众号「是马非马」,一起玩耍起来!🌹🌹

GitHub项目传送门

我正在参加「创意开发 投稿大赛」详情请看:掘金创意开发大赛来了!

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改