学习目标

  • 使用yargs构建命令行工具
  • 父子进程间通信
  • 使使用esbuild极速执行ts文件的node.js应用
  • nodemon监控文件变化重启

预装依赖

为了防止依赖的混乱(比如在应用中无需依赖esbuild等),后续教程会使用monorepo模式把CLI工具包作为一个单独的包来管理自身的依赖

~ pnpm add chalk execa ora yargs
~ pnpm add esbuild nodemon @types/nodemon @anatine/esbuild-decorators source-map-support -D

文件结构

本次编码集中于新建的scripts目录,文件结构如下

scripts
├── cli.js
├── commands
│   ├── index.ts
│   └── start.command.ts
├── esbuild
│   ├── runner.js
│   ├── tansfomer.d.ts
│   └── tansfomer.js
├── handlers
│   ├── index.ts
│   └── start.handler.ts
├── helpers.ts
└── types.ts

CLI编码

这节教程以构建一个自定义的启动命令为例

代码转义

本节教程使用esbuild来作为编译器执行我们的应用,当然你也可以使用其它的编译器,举例几个目前我自己测试过确实可用的编译器,并简短说明一下他们各自的优缺点

以下应用重启耗时测试环境为在笔者本机(16G,i7的MBP)下结合nodemon(ts-node-dev除外)在每次修改代码后重启本教程示例应用的耗时

ts-node

耗时: 4.5-5s左右

优点: 与原生tsc效果一样,是类标准的typescriptnode运行时,基本无bug并且支持编程.因为由ts编写,所以即使遇到问题也可以自己处理.

缺点: 效率低下,重启后需要等待一会儿才能启动应用

如果使用ts-node编译的话,把runner.js改成如下代码即可

require('ts-node').register({
    files: true,     
    transpileOnly: true,
    project: tsconfig,
});
require('tsconfig-paths/register');

swc.js

耗时: 2-3s左右

优点: 使用[rust][]编写,运行效率非常快,同样支持编程,并且无需插件等,原生支持metadata

缺点: 部分代码闭源私有,另一个问题是[rust][]这语言比较偏,对于不熟悉的同学遇到问题就显得非常黑盒(比如不支持class-validator的问题),只能等待官方解决

ts-node-dev

耗时: 0.5-1s

优点: 基于ts-node实现,基本无bug,效率非常高,几乎实时重启

缺点: 不支持编程,重启为热重启,会造成程序内部一些比较大的问题

esbuild

耗时: 2.2-3.5s左右

优点: 使用[golang][]编写,对于跟笔者一样对[golang][]比较熟悉的同学非常友好,而且完全开源,这也是本教程采用它的一个重要原因.运行效率也适中,默认即为编程实现.官方原则上是用来作为打包工具的,在本教程里我们通过简短的几个函数就可以让他变为支持tsnode运行时.还有一个优点就是前端打包工具[vite][]目前比较流行,如果做[react][]或者[vue][]的全栈应用可以避免重复学习

缺点: 原生不支持装饰器的metadata,需要安装esbuild-decorators插件,并且有可能会有意想不到的bug,一些问题需要自己爬坑

对于esbuild只需要使用两个函数即可实现对nestjs应用的转义

// scripts/esbuild/runner.js

// 据配置使用esbuild转义指定文件,并返回转义后的代码
async function transpile(code, filename, options = {})
// 循环转义指定后缀的文件
function runner(options = {}) 

此处为了有类型提示需要定义一个声明文件

// scripts/esbuild/tansfomer.d.ts
import { BuildOptions } from 'esbuild';

export declare function transpile(
    code: string,
    filename: string,
    options: Partial = {},
): Promise;

export declare function runner(
    options: Partial = {},
): Promise;

参数类型

由控制台传给命令一些参数可以实现不同的功能,参数的类型如下

// scripts/types.ts
export type StartCommandArgs = {
    watch: boolean; // 是否监控文件变化重启服务器
    lint: boolean;  // 是否在启动前预先使用eslint进行格式化
    debug: boolean; // 是否在debug中启动,以便使用vscode或chrome调试
    debug_port?: number; // 调试服务的端口
};

编译器

因为nodemon目前无法直接使用ts-node或者esbuildts编译器(甚至不能使用node参数-r)直接在fork模式下执行子进程,其spawn选项只对纯node脚本有效,所以即使把spawn设置为false,任然会以普通shell来执行子进程,具体情况查阅[此处][https://github.com/remy/nodemon/issues/1871],为了可以使用fork,新建一个runner.js文件,并在其中加载esbuild编译器和包含main.ts即可

// scripts/esbuild/runner.js
const path = require('path');
const { runner } = require('./tansfomer');

const tsconfig = path.resolve(__dirname, '../../tsconfig.build.json');

runner({
    tsconfig,
    platform: 'node',
    target: 'esnext',
    sourcemap: false,
});

require(path.resolve(__dirname, '../../src/main.ts'));

常规启动

常规启动直接使用fork子进程启动,在这里分类方便使用异步和std数据,不采用node自带的proccess API,而使用更为方便的execa这个库来实现

// scripts/handlers/start.handler.ts
export async function StartHandler(args: yargs.Arguments) {
   const script = path.resolve(__dirname, '../esbuild/runner.js');
  ...
  if (args.watch && !args.debug) {...}
  else{
    const subprocess = execa.node(script, undefined, {
        ...commonOptions,
         stdio: 'pipe',
         nodeOptions: execArgs,
     });
     startLog(subprocess, spinner, args.debug);
  }
}

监控与重启

使用nodemon对文件进行监控,一旦文件发生改变则自动重启

启动后可以看到三个node进程,分别为cli.js执行yargs的主进程,nodemon的进程,和以nodemon启动的server子进程

其关系为,nodemonyargs的子进程,servernodemon的子进程(fork进程)

在退出nodemon进程(ctrl+c)的时候server子进程会自动退出,而yargs父进程需要主动退出

具体实现如下

// scripts/handlers/start.handler.ts
    if (args.watch && !args.debug) {
        const runner = nodemon({
            ...commonOptions,
            script,
            exec: 'node',
            args: execArgs,
            ext: 'js,json,ts',
            watch: ['src'],
            ignore: ['.git', 'node_modules', 'dist', 'scripts'],
            nodeArgs: execArgs,
            spawn: false, // 使用fork
            verbose: true,
            stdout: false, // 把server子进程的stdout消息发送到nodemon父进程的管道,由父进程控制输出
        });
        // 重启时输出消息
        runner.on('restart', async () => {
            console.log();
            console.log(chalk.yellow(startMsg.restarting));
        });
        // 在退出nodemon进程时关闭yargs父进程
        runner.on('quit', async (code: number) => process.exit(code));
        // eslint-disable-next-line func-names
        runner.on('readable', function (this: ChildProcess) {
            startLog(this, spinner, args.debug);
        });
    }

子进程消息

启动器和nodemonstartLog函数用于在启动应用时输出应用内的消息,如果是debug模式则直接输出stdout消息,如果是普通模式则托管给printFork处理

printFork函数可通过fork通信与message钩子获取子进程主动发送的内容,为了让应用在启动后停止雪碧图,可以在main.ts中发送一个started消息

// scripts/helpers.ts
export function printFork(
    subprocess: ChildProcess, // server子进程
    spinner: ora.Ora, // 雪碧图对象
    msg: { successed: string; failed: string }, // 启动成功与错误的消息,为应用内部的日志
    success: string, // 应用主动发出的fork消息
) {
    if (subprocess.stdout) subprocess.stdout.pipe(process.stdout);
    if (subprocess.stderr) {
        subprocess.stderr.on('data', (data) => {
            console.error(data.toString());
            spinner.fail(chalk.red(msg.failed));
            spinner.clear();
        });
    }
    subprocess.once('message', (m) => {
        if (m === success) {
            spinner.succeed(chalk.greenBright.underline(msg.successed));
            spinner.clear();
        }
    });
}

在应用内部发送已启动消息

// src/main.ts
...
if (process.send) process.send('started');
await app.listen(appConfig.port, appConfig.host, () => {

感兴趣的话还可以添加一个时间计算的功能,具体可以看代码

处理Eslint

可以在启动应用的时候对代码做一次eslint规则检测

后续我们为cli添加自定义的配置功能后此函数可以独立为一个命令

// scripts/helpers.ts
export async function lintCode()

StartHandler函数中添加

// scripts/handlers/start.handler.ts
export async function StartHandler(args: yargs.Arguments) {
    if (args.lint) await lintCode();
    ...
}

构建命令

为了执行ts文件,需要在cli.js中引用前面编写的runner方法,并包含我们的命令行包

// scripts/cli.js
const { runner } = require('./esbuild/tansfomer');

runner({
    platform: 'node',
    target: 'esnext',
    sourcemap: false,
});
require('./commands');

构建start命令

// scripts/commands/start.command.ts
export const StartCommand: CommandModule = {
    command: ['app:start', 'as'], // 命令别名为 'as'
    describe: 'Start app.',
    builder: {
        // 是否启用监控热重启,默认启用
        watch
       // 是否在启动时eslint一次,默认启用
        lint
      // 是否启用debug模式.默认不启用
        debug
       // 给编辑器和IDE用于debug的端口
        debug_portdefault: 9999,
        },
    } as const,
    handler: async (args: yargs.Arguments) =>
        StartHandler(args),
};

然后直接在index.ts中构建yargs命令即可

// scripts/commands/index.tss
commands.forEach((command) => yargs.command(command));
yargs
    .usage('Usage: $0  [options]')
    .demandCommand(1)
    .strict()
    .scriptName('cli')
    .fail((msg, err, y) => {
        // 遇到错误命令时,如果无参数则直接显示帮助信息
        if ((!msg && !err) || args.length === 0) {
            yargs.showHelp();
            process.exit();
        }
        if (msg) console.error(chalk.red(msg));
        if (err) console.error(chalk.red(err.message));
        process.exit();
    })
    .alias('v', 'version')
    .help('h')
    .alias('h', 'help').argv;

Debug模式

原来我们直接使用ts-node进行debug,现在尝试使用自己构建的cli进行debug,在debug模式时可以关闭spinner功能,然后作为node参数传入子进程即可

因为debug时,vscode等ide会自动监控文件与重启,所以如果是watch模式启动,则直接跳到非watch的普通模式启动

// scripts/handlers/start.handler.ts
const execArgs: string[] = [];
if (!args.debug) spinner.start();
if (args.watch && !args.debug) {
  ...
}else {
   if (args.debug) execArgs.push(`--inspect=${args.debug_port}`);
   const subprocess = execa.node(script, undefined, {
            ...
            nodeOptions: execArgs,
   });
}

修改vscode的launch.json

// .vscode/launch.json
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "NESTPLUS",
            "type": "pwa-node",
            "request": "attach",
            "restart": true,
            "cwd": "{workspaceRoot}",
            "port": 9999,
            "sourceMaps": true,
            "resolveSourceMapLocations": [
                "{workspaceFolder}/**",
                "!**/node_modules/**"
            ]
        }
    ]
}

更改命令

修改package.json中的启动命令

// package.json
    "cli": "node ./scripts/cli.js",
    "cli:prod": "cross-env NODE_ENV=production node ./scripts/cli.js",
    "prebuild": "cross-env rimraf dist",
    "build": "nest build",
    "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
    "start": "node ./scripts/cli.js as --no-lint",
    "start:lint": "node ./scripts/cli.js as",
    "start:nw": "node ./scripts/cli.js as --no-w --no-lint",
    "start:debug": "node ./scripts/cli.js as --no-lint --debug",
    ...