说实话,看到NestJS 12的changelog时我是有点兴奋的。
8月27号发布,ESM-first、Rspack替代Webpack、Standard Schema验证、@nestjs/observe原生可观测性——每一条都戳在痛点上。特别是Rspack替代Webpack那条,我心想:终于不用等turbopack慢吞吞地编译了。
然后我花了整个周末才把项目跑起来。
先说下项目情况:一个中等体量的B端后台系统,NestJS 11.2.0 + Webpack monorepo架构,用了NATS消息队列、GraphQL订阅、大约40个module。升级前我特意跑了nest upgrade --dry-run看了看报告——嗯,报告写得很漂亮,告诉我"大部分迁移会自动完成"。
大部分。注意这个词。
周六上午:Webpack到Rspack
升级命令跑得很顺利:
npm i -g @nestjs/cli@latest
nest upgrade
CLI自动帮我把nest-cli.json里的webpack配置改了,@nestjs/*包全升到v12兼容版本。本地nest build一跑——过了。
我心想这事也没网上说的那么麻烦嘛。
然后nest start。
报错。
Error: Cannot find module '@nestjs/core'
Require stack:
- /path/to/project/dist/main.js
我盯着这个报错看了五分钟。@nestjs/core?刚才build不是过了吗?
周六下午三点,我第三次跑nest start,还是同样的报错。我甚至怀疑是不是npm缓存出了问题,清了缓存重装依赖,没用。翻了半天issue才发现——NestJS 12所有核心包现在是ESM,但我的项目还是CommonJS。Node.js 20.19+的require(esm)确实能兼容,但有个前提:你的build输出格式得配对。
Webpack时代我的tsconfig.json是"module": "commonjs",Rspack默认行为不一样。翻了Rspack的NestJS集成文档,发现需要在rspack.config.js里显式指定输出格式:
// rspack.config.js
module.exports = {
output: {
library: { type: 'commonjs2' },
},
// 关键:告诉Rspack你的目标是Node.js
target: 'node',
};
加上这两行,build过了,start也过了。
但别高兴太早——monorepo里的shared library又有问题。之前Webpack用的是ts-loader处理跨包引用,Rspack用的是SWC。我的shared包里有个循环依赖(Module A import Module B import Module A),Webpack时代居然能跑,Rspack直接炸了。
后来我查了下,循环依赖这问题其实madge --circular src一跑就能查出来,但我当时没跑。排查了两个小时,最后老老实实把循环依赖拆了。这不能怪Rspack,Webpack本来就"不应该"让循环依赖跑起来,只是它默默帮你兜住了。Rspack不惯着这毛病。
周六晚上:NATS换包的坑
搞完Rspack已经晚上八点了。我想着顺手把NATS也换了,毕竟官方说就换个包名的事。
NestJS 12把NATS从nats包换成了@nats-io/transport-node。官方migration guide只说了一句"run npm uninstall nats && npm install @nats-io/transport-node"。
问题在于:不只是换个包名。
原来的NATS客户端序列化是Buffer,新版是JSON字符串。如果你的自定义deserializer之前是直接读Buffer的——
// v11 能跑的代码
@ClientNATS('SERVICE_A')
private client: ClientProxy;
// 消息处理
this.client.send('topic', payload).pipe(
// 之前这里拿到的是Buffer
map((response) => response.toString('utf-8')),
);
升级后这里拿到的已经是解析好的对象了,你再.toString()会直接报错。
而且自定义deserializer的签名也变了。老版本接收的是Buffer,新版接收的是完整的NATS Message对象:
// v12 新版deserializer
deserializer(message: NatsMsg): any {
// message是完整的NatsMsg,不是payload
return JSON.parse(message.data.toString());
}
我项目里有3个自定义deserializer,全得改。改完跑测试,又有两个mock数据的格式对不上——因为mock数据是按老版本的Buffer格式写的。
这块折腾到晚上十点多。后来我学乖了,先全局搜了一遍nats的import和所有deserializer实现,统一改完再跑测试。
周日:一个隐蔽的hooks顺序问题
周六搞到半夜,周日早上起来继续。这次是Lifecycle Hooks顺序变了。
这个breaking change在changelog里只占一行:"Lifecycle hooks are now invoked by component hierarchy level"。
翻译成人话就是:OnModuleInit、OnApplicationBootstrap这些钩子的调用顺序变了。以前是按模块注册顺序来的,现在按组件层级(父模块→子模块)。
我的项目有个数据库连接初始化逻辑放在OnModuleInit里,另一个缓存预热放在OnApplicationBootstrap里。升级后这俩的执行顺序反了——缓存预热先跑了,但数据库还没连上。
启动直接报错。
我一开始根本没注意到这个变更。排查了半天看log才发现,OnApplicationBootstrap比OnModuleInit先执行了。解决方式是把缓存预热挪到一个独立的module里,用imports确保它在数据库module之后初始化。
如果你项目里依赖hooks执行顺序(比如"先连数据库再初始化缓存"这种隐式依赖),一定要提前检查。
新功能:Standard Schema确实香
周日晚上终于把坑都填完了,试了试新功能。
Standard Schema验证确实好用。以前@Body()参数验证得写个class-validator的DTO类,现在直接用Zod:
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().int().positive().optional(),
});
@Post()
create(@Body({ schema: CreateUserSchema }) body: z.infer<typeof CreateUserSchema>) {
return this.usersService.create(body);
}
配合StandardSchemaValidationPipe,连DTO类都省了。而且schema能直接喂给@nestjs/swagger生成OpenAPI文档——这点很关键,之前class-validator的装饰器和swagger的装饰器要写两遍,现在一份schema搞定。
**@nestjs/observe**试了一下,挺惊喜。不用接Jaeger、不用配OpenTelemetry collector,直接:
import { createObserveModule, ObserveInstrument } from '@nestjs/observe';
const { ObserveModule } = createObserveModule();
const app = await NestFactory.create(AppModule, {
instrument: ObserveInstrument,
});
启动后自动采集HTTP请求耗时、GraphQL解析时间、队列消费耗时。数据格式是OpenTelemetry标准的,后面要接Grafana或者Datadog都能直接用。 结果一跑,Fastify不支持,我人傻了。我们项目恰好用的是Fastify……所以暂时没上线,等官方支持了再说。
值不值得升?
你要问我值不值得升……我周末两天都搭进去了,你说呢? NestJS 12的ESM支持和Rspack确实能带来构建速度提升(我本地monorepo build时间从45秒降到12秒),Standard Schema验证也比class-validator优雅不少。但breaking change不少,特别是NATS用户和依赖hooks顺序的项目,升级成本不低。 我的建议是先在开发环境跑一遍。测试覆盖完整的可以放心升,覆盖不全的先补测试再说。
几个容易踩的坑提前说一下:
- Node.js必须v20.19+或v22.12+,21.x不支持
- 循环依赖先用
madge --circular src查,Rspack不惯着这个 - Rspack配置要加
target: 'node'和library.type - NATS不只是换包名,序列化格式变了
- Lifecycle hooks顺序变了,检查有没有隐式依赖执行顺序
- GraphQL订阅的
subscriptions-transport-ws已移除,必须换graphql-ws
Rspack是真的快,这一点没骗我。