使用esbuild快速热启动
学习目标
预装依赖
为了防止依赖的混乱(比如在应用中无需依赖esbuild等),后续教程会使用monorepo模式把CLI工具包作为一个单独的包来管理自身的依赖
- yargs:构建CLI工具的框架
- nodemon: 监控文件改变并重新执行命令
- execa:异步获取子进程消息替代
process
的回调函数方式 - ora:在命令行生成雪碧图
- esbuild:编译
ts
文件 - esbuild-decorators:使esbuild支持metadata
- source-map-support:为转义的
ts
代码提供源映射支持
~ 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
效果一样,是类标准的typescript
的node
运行时,基本无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][]比较熟悉的同学非常友好,而且完全开源,这也是本教程采用它的一个重要原因.运行效率也适中,默认即为编程实现.官方原则上是用来作为打包工具的,在本教程里我们通过简短的几个函数就可以让他变为支持ts
的node
运行时.还有一个优点就是前端打包工具[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<BuildOptions> = {},
): Promise<string>;
export declare function runner(
options: Partial<BuildOptions> = {},
): Promise<void>;
参数类型
由控制台传给命令一些参数可以实现不同的功能,参数的类型如下
// scripts/types.ts
export type StartCommandArgs = {
watch: boolean; // 是否监控文件变化重启服务器
lint: boolean; // 是否在启动前预先使用eslint进行格式化
debug: boolean; // 是否在debug中启动,以便使用vscode或chrome调试
debug_port?: number; // 调试服务的端口
};
编译器
因为nodemon目前无法直接使用ts-node或者esbuild等ts
编译器(甚至不能使用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<StartCommandArgs>) {
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
子进程
其关系为,nodemon
为yargs
的子进程,server
为nodemon
的子进程(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);
});
}
子进程消息
启动器和nodemon的startLog
函数用于在启动应用时输出应用内的消息,如果是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<StartCommandArgs>) {
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<any, StartCommandArgs> = {
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<StartCommandArgs>) =>
StartHandler(args),
};
然后直接在index.ts
中构建yargs命令即可
// scripts/commands/index.tss
commands.forEach((command) => yargs.command(command));
yargs
.usage('Usage: $0 <command> [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",
...