为团队提供支持ts、代码规范以及提交规范的脚手架

563 阅读13分钟

前言

假设我现在为团队写了一个项目模板,但是要怎么使用这个项目模板呢,一种方式是,将它放在公司 gitlab 上,使用的时候拉取下来,不过这样子有点麻烦,每次拉取项目的时候,可能需要去查看项目的仓库地址,同时还需要修改项目名称。有更好的方式吗?我在思考。

突然想到我们平时一直使用的脚手架,就是干了这么一件事,借助 cli, 去拉取框架的项目模板,比如 vue-cli, 通过 yarn global add @vue/cli 安装 @vue/cli, 执行 vue create my-project 命令创建项目模板。

所以需要去实现一个可以拉取自己项目模板的 cli

项目模板的搭建过程以及源码,可以查看我的上篇文章,为团队提供提供支持 ts、代码规范以及提交规范的项目模板

从这篇文章中你能学到什么?

  • 如何为团队开发自己的脚手架工具
  • 怎么本地调试脚手架
  • 怎么将项目发布到 npm

搭建项目

项目初始化

上篇文章已经实现了一个 cli 的项目模板。cli 创建的方式,这篇文章正在实现,所以我先直接 clone 下来,修改项目名称。

git clone https://github.com/LBINGXIN/ts-cli-template.git

这是当前的目录结构

image.png

本地调试

在根目录下创建 bin 文件夹和 bin/ts-cli.js 文件,并设置如下内容:

#! /usr/bin/env node

// #! 符号的名称叫 Shebang,用于指定脚本的解释程序
// 这句脚本的作用是指定用node执行当前脚本文件
// 如果是Linux 或者 macOS 系统下还需要修改此文件的读写权限为 755
// 具体就是通过 chmod 755 ts-cli.js 实现修改

// 用于检查入口文件是否正常执行
console.log('ts-cli running!');

image.png

在项目根目录下的 pacakge.json 中增加如下内容:

{ 
    "bin": { 
        "ts-cli": "./bin/ts-cli.js" 
    } 
}

bin 用来指定命令(ts-cli)的可执行文件的位置,接下来在项目根目录执行 npm linkyarn link,将 package.json 中的属性 bin 的值路径添加全局链接,在命令行中执行 ts-cli 就会执行 ./bin/ts-cli.js 文件

npm link

执行 npm link,根据命令行输出结果,可以发现已经 ts-cli 包链接到全局的 node_modules

image.png

执行 ts-cli, 就会执行我们在 package.json 配置的可执行文件 ./bin/ts-cli.js,从而输出 ts-cli running!,现在我们实现了 ts-cli 的测试运行了。

image.png

有包的安装,同样也有包的卸载,当开发完成后,可以执行 npm unlink 将对应的包卸载, 执行完 npm unlink 后,在执行 ts-cli,提示 ts-cli 不存在该命令,说明我们卸载成功。

npm unlink

image.png

这是正常操作的过程,假如你在开发的过程中,修改了命令的名称,或者项目名称,以及其他不可预测的操作后,发现 npm unlink 删除不掉包,这时候可以使用 where ts-cli,查看当前包的位置,然后手动删除对应文件就可以了。

where ts-cli

image.png

开发

常用的脚手架工具库

库名称作用
commander命令行自定义指令
chalk控制台输出内容样式美化
ora控制台 loading 样式
download-git-repo下载远程模版
fs-extra系统fs模块的扩展,提供了更多便利的 API,并继承了fs模块的 API
shelljs支持跨平台调用系统上的命令

创建源码目录

在根目录下创建 src 文件夹和 src/index.ts 文件

image.png

因为我们使用 ts,需要进行编译,并且编译的结果会输出到 lib 目录中,所以需要修改一下 bin/ts-cli.js 内容如下:

#! /usr/bin/env node

// #! 符号的名称叫 Shebang,用于指定脚本的解释程序
// 这句脚本的作用是指定用node执行当前脚本文件
// 如果是Linux 或者 macOS 系统下还需要修改此文件的读写权限为 755
// 具体就是通过 chmod 755 ts-cli.js 实现修改

// 将构建目录(lib)下的 index.js 作为脚手架的入口
require('../lib/index');

image.png

自定义创建项目命令

安装依赖 commander

commander 是一个可以实现自定义命令行指令,并且可以解析命令行参数的 node 插件

npm i commander

实现创建项目的命令

使用 commander 实现创建项目的命令 ts-cli create <app-name>,在 src/index.ts 添加如下内容:

import { program } from 'commander';

program
  .command('create <app-name>')
  .description('create a new project')
  .action(async (name: string) => {
    console.log(name)
  });

program.parse(process.argv);

先执行 npm run build,编译文件,结果输出到 lib 目录中

npm run build

image.png

在命令行执行 ts-cli,可以发现新增了一条 create <app-name> 的命令

image.png

为了方便测试,我创建一个 ts-cli-test文件夹,并在 vs code 打开

ts-cli-test 目录下执行 ts-cli create <app-name><app-name> 为自定义参数,我设置app-name 为 my-app,输出的值也为 my-app,说明我们拿到了用户创建项目的项目名称

image.png

实现查看版本号

vue-cli 提供了 vue -V 查看版本号,我们也提供这样的一个功能,在 src/index.ts 添加查看版本号命令,版本号直接获取 package.json 对应的版本号

/* eslint-disable @typescript-eslint/no-var-requires */
program
  .version(`${require('../package.json').version}`, '-v --version')
  .usage('<command> [options]');

先执行 npm run build 进行编译,然后执行 ts-cli,可以发现多了一条 version 的命令

image.png

执行 ts-cli -v 或者 ts-cli --version 查看版本号

image.png

为了代码可读性,在 src 目录下创建 src/create.ts文件,用来实现脚手架拉取模板的具体逻辑

image.png

/src/index.ts 最终代码

src/index.ts的作用主要是用来创建所有命令,比如我们的 ts-cli create <app-name>ts-cli -v,命令的具体实现逻辑时抽离出去的,比如我们的 src/create.ts,因为我们这个项目暂时只有这两个命令,所以以下就是 src/index.ts的最终代码

import { program } from 'commander';
import create from './create';

// ts-cli -v 或 ts-cli --version 读取 package.json 的version
program
  // eslint-disable-next-line @typescript-eslint/no-var-requires
  .version(`${require('../package.json').version}`, '-v --version')
  .usage('<command> [options]');

// 创建项目命令 ts-cli create <app-name>
program
  .command('create <app-name>')
  .description('create a new project')
  .action(async (name: string) => {
    // 创建逻辑
    await create(name);
  });

program.parse(process.argv);

下载项目模板

安装依赖 download-git-repo

download-git-repo 是一个可以实现 从 git 仓库(GitHub、GitLab、Bitbucket)拉取文件的 node 插件。

npm i download-git-repo

download-git-repo 不支持 promise,我们可以使用 nodeutil.promisify 包装 download-git-repo,使它支持 promise

import { promisify } from 'util';
import { resolve } from 'path';
import * as downloadGitRepo from 'download-git-repo';
const download = promisify(downloadGitRepo);

实现项目拉取

// create 命令
export default async function create(projecrName: string): Promise<void> {
  // 模板地址 用户名/项目名 只需要这样就可以,内部会自动添加 github 地址
  const templateUrl = `LBINGXIN/ts-cli-template`;
  
  // 如果是公司内部私服gitlab,需要使用全地址,并加上 direct 前缀
  // const templateUrl = `direct:http://xxx.com/ts-cli-template.git`;

  // 拉取项目的位置 process.cwd() 是当前Node.js进程执行时的文件夹地址——工作目录
  const target = resolve(process.cwd(), projecrName);
  try {
    await download(templateUrl, target);
    console.log('download success...');
  } catch (err) {
    console.log('download failed...');
  }
}

/src/create.ts 当前代码

/**
 * create 命令的具体任务
 */

import { promisify } from 'util';
import { resolve } from 'path';
import * as downloadGitRepo from 'download-git-repo';
const download = promisify(downloadGitRepo);

// create 命令
export default async function create(projecrName: string): Promise<void> {
  // 模板地址 用户名/项目名 只需要这样就可以,内部会自动添加 github 地址
  const templateUrl = `LBINGXIN/ts-cli-template`;

  // 如果是公司内部私服gitlab,需要使用全地址,并加上 direct 前缀
  // const templateUrl = `direct:http://xxx.com/ts-cli-template.git`;

  // 拉取项目的位置 process.cwd() 是当前Node.js进程执行时的文件夹地址——工作目录
  const target = resolve(process.cwd(), projecrName);
  try {
    await download(templateUrl, target);
    console.log('download success...');
  } catch (err) {
    console.log('download failed...');
  }
}

老样子,先执行 npm run build 进行编译,为了方便测试,我们在 ts-cli-test目录进行测试, 执行 ts-cli create my-app,可以发现输出了 download sucess...,同时也在当前目录下创建了 my-app 项目

ts-cli create my-app

image.png

image.png

现在我们已经实现了通过 ts-cli,创建一个项目了,不过还有一些点需要处理和优化

  • 如果当前项目名称已经存在,需要添加一个退出操作,不需要执行项目创建
  • 点开 package.json,会发现项目名称不对,所以需要去做 name 修改
  • 如果网络比较慢的时候,我们执行 ts-cli create my-app,会发现命令行很久都没有反应,也不知道执行成功,所以需要添加一个 loading,给用户正在加载的反馈
  • 命令行的输出内容,是不是可以美化一下

接下来根据上面这几点,做具体分析,然后在给出 src/create.ts 的完整代码,着急的小伙伴,可以直接在后面一点,查看完整代码。

判断文件是否存在

添加 isFileExist 方法,用于验证当前目录下是否存在要创建的文件

/**
 * 验证当前目录下是否已经存在指定文件,如果存在则退出进行
 * @param filename 文件名
 */
export function isFileExist(filename: string): void {
  // 文件路径
  const file = resolve(process.cwd(), filename);
  // 验证文件是否已经存在,存在则推出进程
  if (existsSync(file)) {
    console.log(`${file} 已经存在`);
    process.exit(1);
  }
}

添加loading

安装依赖 ora

ora 是一个在命令行显示 loading 的 node 插件

需要安装5.4.1版本,最新版可能会有问题

npm i ora@5.4.1

封装一个 loading 公用方法

// 添加加载动画
export const wrapLoading = async (fn, message, ...args) => {
  // 使用 ora 初始化,传入提示信息 message
  const spinner = ora(message);
  // 开始加载动画
  spinner.start();

  try {
    // 执行传入方法 fn
    const result = await fn(...args);
    // 状态为修改为成功
    spinner.succeed();
    return result;
  } catch (error) {
    // 状态为修改为失败
    spinner.fail('failed ...');
  }
};

修改 package.json 项目名

/**
 * 读取指定路径下 json 文件
 * @param filename json 文件的路径
 */
export function readJsonFile<T>(filename: string): T {
  return JSON.parse(readFileSync(filename, { encoding: 'utf-8', flag: 'r' }));
}

/**
 * 覆写指定路径下的 json 文件
 * @param filename json 文件的路径
 * @param content  json 内容
 */
export function writeJsonFile<T>(filename: string, content: T): void {
  writeFileSync(filename, JSON.stringify(content, null, 2));
}

/**
 * 改写项目中 package.json 的 name、description
 */
export function changePackageInfo(projectName: string): void {
  const packagePath = resolve(getProjectPath(projectName), './package.json');
  const packageJSON: PackageJSON = readJsonFile<PackageJSON>(packagePath);
  packageJSON.name = packageJSON.description = projectName;
  writeJsonFile<PackageJSON>(packagePath, packageJSON);
}

美化命令行输出内容

安装依赖 chalk

npm i chalk@4.1.2

需要安装4.1.2版本,最新版可能会有问题

使用方式

import { cyan, gray, red, yellow } from 'chalk';

console.log(cyan('hello world!');
console.log(gray('hello world!');
console.log(red('hello world!');
console.log(yellow('hello world!');

src/create.ts 完整代码

/**
 * create 命令的具体任务
 */

import { promisify } from 'util';
import { resolve } from 'path';
import * as downloadGitRepo from 'download-git-repo';
import { existsSync, readFileSync, writeFileSync } from 'fs';
const download = promisify(downloadGitRepo);
import * as ora from 'ora';
import { red, yellow, gray, cyan } from 'chalk';

/**
 * 验证当前目录下是否已经存在指定文件,如果存在则退出进行
 * @param filename 文件名
 */
export function isFileExist(filename: string): void {
  // 文件路径
  const file = resolve(process.cwd(), filename);
  // 验证文件是否已经存在,存在则推出进程
  if (existsSync(file)) {
    console.log(red(`${file} 已经存在`));
    process.exit(1);
  }
}

// 添加加载动画
export const wrapLoading = async (fn, message, ...args) => {
  // 使用 ora 初始化,传入提示信息 message
  const spinner = ora(message);
  // 开始加载动画
  spinner.start();

  try {
    // 执行传入方法 fn
    const result = await fn(...args);
    // 状态为修改为成功
    spinner.succeed();
    return result;
  } catch (error) {
    // 状态为修改为失败
    spinner.fail('failed ...');
  }
};

/**
 * 读取指定路径下 json 文件
 * @param filename json 文件的路径
 */
export function readJsonFile<T>(filename: string): T {
  return JSON.parse(readFileSync(filename, { encoding: 'utf-8', flag: 'r' }));
}

/**
 * 覆写指定路径下的 json 文件
 * @param filename json 文件的路径
 * @param content  json 内容
 */
export function writeJsonFile<T>(filename: string, content: T): void {
  writeFileSync(filename, JSON.stringify(content, null, 2));
}

/**
 * 获取项目绝对路径
 * @param projectName 项目名
 */
export function getProjectPath(projectName: string): string {
  return resolve(process.cwd(), projectName);
}

export interface PackageJSON {
  name: string;
  version: string;
  description: string;
  scripts: {
    [key: string]: string;
  };
}

/**
 * 改写项目中 package.json 的 name、description
 */
export function changePackageInfo(projectName: string): void {
  const packagePath = resolve(getProjectPath(projectName), './package.json');
  const packageJSON: PackageJSON = readJsonFile<PackageJSON>(packagePath);
  packageJSON.name = packageJSON.description = projectName;
  writeJsonFile<PackageJSON>(packagePath, packageJSON);
}

/**
 *
 * @param projecrName
 */

export async function getRepo(projectName: string): Promise<void> {
  // 模板地址 用户名/项目名 只需要这样就可以,内部会自动添加 github 地址
  const templateUrl = `LBINGXIN/ts-cli-template`;

  // 如果是公司内部私服gitlab,需要使用全地址,并加上 direct 前缀
  // const templateUrl = `direct:http://xxx.com/ts-cli-template.git`;

  // 拉取项目的位置 process.cwd() 是当前Node.js进程执行时的文件夹地址——工作目录
  const target = resolve(process.cwd(), projectName);

  await wrapLoading(
    download, // 远程下载方法
    'waiting download template', // 加载提示信息
    templateUrl, // 参数1: 下载地址
    target,
  );
}

// create 命令
export default async function create(projectName: string): Promise<void> {
  // 判断文件是否已经存在
  isFileExist(projectName);
  await getRepo(projectName);
  // 改写项目的 package.json 基本信息,比如 name、description
  changePackageInfo(projectName);
  // 输出结束提示
  end(projectName);
}

/**
 * 整个项目安装结束,给用户提示信息
 */
export function end(projectName: string): void {
  console.log(`Successfully created project ${yellow(projectName)}`);
  console.log('Get started with the following commands:');
  console.log('');
  console.log(`${gray('$')} ${cyan('cd ' + projectName)}`);
  console.log(`${gray('$')} ${cyan('npm run dev')}`);
  console.log('');
}

老样子,先执行 npm run build 进行编译,然后在 ts-cli-test 文件夹执行 ts-cli create my-app 进行测试

可以看出在下载文件时,会显示一个蓝色的loading

image.png

下来成功时,会有绿色成功标志,并输出提示内容

image.png

现在虽然已经完成我们的目标,不过代码还是有点乱,扩展性也不好,下面时重构后的代码,具有较好的扩展性

完整代码

这是源码的一个目录结构

image.png

/src/index.ts

import { program } from 'commander';
import create from './order/create';

// 查看版本 ts-cli -v 或 ts-cli --version
/* eslint-disable @typescript-eslint/no-var-requires */
program
  .version(`${require('../package.json').version}`, '-v --version')
  .usage('<command> [options]');

// 创建项目命令 ts-cli create app-name
program
  .command('create <app-name>')
  .description('create a new project')
  .action(async (name: string) => {
    // 创建逻辑
    await create(name);
  });

program.parse(process.argv);

/src/order/create.ts

/**
 * create 命令的具体任务
 */
import { changePackageInfo, end, isFileExist, getRepo } from '../utils/create';

// create 命令
export default async function create(projecrName: string): Promise<void> {
  // 判断文件是否已经存在
  isFileExist(projecrName);
  await getRepo(projecrName);
  // 改写项目的 package.json 基本信息,比如 name、description
  changePackageInfo(projecrName);
  // 结束
  end(projecrName);
}

/src/utils/create.ts

/**
 * create 命令需要用到的所有方法
 */
import {
  getProjectPath,
  PackageJSON,
  printMsg,
  readJsonFile,
  writeJsonFile,
  wrapLoading,
} from '../utils/common';
import { existsSync } from 'fs';
import { resolve } from 'path';
import { cyan, gray, red, yellow } from 'chalk';
import { promisify } from 'util';
import * as downloadGitRepo from 'download-git-repo';
const download = promisify(downloadGitRepo);

/**
 * 验证当前目录下是否已经存在指定文件,如果存在则退出进行
 * @param filename 文件名
 */
export function isFileExist(filename: string): void {
  // 文件路径
  const file = getProjectPath(filename);
  // 验证文件是否已经存在,存在则推出进程
  if (existsSync(file)) {
    printMsg(red(`${file} 已经存在`));
    process.exit(1);
  }
}

export async function getRepo(projectName: string): Promise<void> {
  const requestUrl = `LBINGXIN/ts-cli-template`;
  await wrapLoading(
    download, // 远程下载方法
    'waiting download template', // 加载提示信息
    requestUrl, // 参数1: 下载地址
    resolve(process.cwd(), projectName),
  );
}

/**
 * 改写项目中 package.json 的 name、description
 */
export function changePackageInfo(projectName: string): void {
  const packagePath = resolve(getProjectPath(projectName), './package.json');
  const packageJSON: PackageJSON = readJsonFile<PackageJSON>(packagePath);
  packageJSON.name = packageJSON.description = projectName;
  writeJsonFile<PackageJSON>(packagePath, packageJSON);
}

/**
 * 整个项目安装结束,给用户提示信息
 */
export function end(projectName: string): void {
  printMsg(`Successfully created project ${yellow(projectName)}`);
  printMsg('Get started with the following commands:');
  printMsg('');
  printMsg(`${gray('$')} ${cyan('cd ' + projectName)}`);
  printMsg(`${gray('$')} ${cyan('npm run dev')}`);
  printMsg('');
}

/src/utils/common.ts

/**
 * 放一些通用的工具方法
 */
import { readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';
import * as clear from 'clear-console';
import * as ora from 'ora';

// 添加加载动画
export const wrapLoading = async (fn, message, ...args) => {
  // 使用 ora 初始化,传入提示信息 message
  const spinner = ora(message);
  // 开始加载动画
  spinner.start();

  try {
    // 执行传入方法 fn
    const result = await fn(...args);
    // 状态为修改为成功
    spinner.succeed();
    return result;
  } catch (error) {
    // 状态为修改为失败
    spinner.fail('failed ...');
  }
};

export interface PackageJSON {
  name: string;
  version: string;
  description: string;
  scripts: {
    [key: string]: string;
  };
}

export interface JSON {
  [key: string]: unknown;
}

/**
 * 读取指定路径下 json 文件
 * @param filename json 文件的路径
 */
export function readJsonFile<T>(filename: string): T {
  return JSON.parse(readFileSync(filename, { encoding: 'utf-8', flag: 'r' }));
}

/**
 * 覆写指定路径下的 json 文件
 * @param filename json 文件的路径
 * @param content  json 内容
 */
export function writeJsonFile<T>(filename: string, content: T): void {
  writeFileSync(filename, JSON.stringify(content, null, 2));
}

/**
 * 获取项目绝对路径
 * @param projectName 项目名
 */
export function getProjectPath(projectName: string): string {
  return resolve(process.cwd(), projectName);
}

/**
 * 打印信息
 * @param msg 信息
 */
export function printMsg(msg: string): void {
  console.log(msg);
}

/**
 * 清空命令行
 */
export function clearConsole(): void {
  clear();
}

npm 包发布

准备

如果是发布到 npm,需要创建一个账号

修改 package.json 的如下内容

{ 
    "name": "@lbingxin/ts-cli",
    "version": "1.0.0",
    "main": "./lib/index.js",
    "keywords": [
        "typescript",
        "cli",
        "typescript 脚手架",
        "ts 脚手架",
        "ts-cli",
        "脚手架"
    ],
    "author": "LBINGXIN", 
    "files": ["package.json", "README.md", "lib"], 
    "repository": "https://github.com/LBINGXIN/ts-cli.git",
 }
  • name: 包名,在包名称前加自己的 npm 账户名,采用 npm scope 的方式,scope 对于公司项目的话,便于分类管理,对于个人包也是一样,同时也是避免跟别人包名重复问题。
  • version:版本后,后续包的迭代,需要修改版本号
  • main:表示包的入口位置
  • keywords:关键字,方便别人搜索到你的包
  • files:告诉 npm,publish 时发布哪些包到 npm 仓库
  • repository:项目仓库

一个好的文档也是必不可少的,所以需要完善README.md

# ts-cli

## 简介

创建一个支持 ts, 代码规范和 git 提交规范的 cli 基础项目

## 安装依赖

### npm 方式

npm i -g @lbingxin/ts-cli

### yarn 方式

yarn global add @lbingxin/ts-cli

## 使用方式

### 查看版本

ts-cli -v

### 创建项目

`<app-name>` 为自定义属性,根据自己的项目名称而定,比如 `my-project`

ts-cli create <app-name>

设置npm镜像源

要发布包到npm上,需要保证当前的镜像源是https://registry.npmjs.org,如果不是的话,会报403错误,通过以下方式查看和设置npm镜像源

查看npm镜像源

npm config get registry

设置npm镜像源

npm config set registry https://registry.npmjs.org

image.png

发布

发布时,需要先执行 npm login 登录,根据提示输入账号,密码,邮箱,以及发送到邮箱的验证码,即可完成登录

npm login

执行npm publish --access public,发布包,我们加上属性--access public的原因是因为我们是npm scope私有作用域包,所以需要添加上这个属性,发布成功,就能在npm上查看到自己的包

npm publish --access public

image.png

image.png

取消发布

如果发布失败,由于npm不支持相同版本号发布,所以有两种选择,1.修改当前项目的版本号,2.撤销发布

根据提示执行取消发布,但还是每次提交前,保证包的正确性,减少bug是切图仔的传统美德

注意:npm规定 上传一个包后可以在24h内删除,删除一个包后在24h内不能再次上传相同包,即版本号不能相同

npm unpublish [<@scope>/]<pkg>@<version>

image.png

git打tag

为什么需要打tag呢,可能对于一些没有维护过npm包的同学会有点困惑,但是保持git的tag和npm包版本号一致,是很重要的一件事,对于后续的版本维护有很大的帮助。

运行 git tag -a v1.0.0 -m 备注

git tag -a v1.0.0 -m "ts-cli基础功能"

image.png

VS Code左侧的TAGS可以看到刚才创建的tag

image.png

运行git push --tags,推送当前taggit远程

git push --tags

image.png

可以在Github发现,tag已经上传成功了

image.png

小结

到这里相信大家都能实现一个自己的 cli 了,对应的代码也发布到 Github,cli 也发布到npm

如果觉得这篇文章对您有帮助,欢迎点赞收藏,有问题也欢迎指出。