说实话,看到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
升级命令跑得很顺利:
1npm i -g @nestjs/cli@latest 2nest upgrade 3
CLI自动帮我把nest-cli.json里的webpack配置改了,@nestjs/*包全升到v12兼容版本。本地nest build一跑——过了。 我心想这事也没网上说的那么麻烦嘛。 然后nest start。 报错。
1Error: Cannot find module '@nestjs/core' 2Require stack: 3- /path/to/project/dist/main.js 4
我盯着这个报错看了五分钟。@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里显式指定输出格式:
1// rspack.config.js 2module.exports = { 3 output: { 4 library: { type: 'commonjs2' }, 5 }, 6 // 关键:告诉Rspack你的目标是Node.js 7 target: 'node', 8}; 9
加上这两行,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的——
1// v11 能跑的代码 2@ClientNATS('SERVICE_A') 3private client: ClientProxy; 4 5// 消息处理 6this.client.send('topic', payload).pipe( 7 // 之前这里拿到的是Buffer 8 map((response) => response.toString('utf-8')), 9); 10
升级后这里拿到的已经是解析好的对象了,你再[.toString()](function toString() { [native code] })会直接报错。 而且自定义deserializer的签名也变了。老版本接收的是Buffer,新版接收的是完整的NATS Message对象:
1// v12 新版deserializer 2deserializer(message: NatsMsg): any { 3 // message是完整的NatsMsg,不是payload 4 return JSON.parse(message.data.toString()); 5} 6
我项目里有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:
1import { z } from 'zod'; 2 3const CreateUserSchema = z.object({ 4 name: z.string().min(2), 5 email: z.string().email(), 6 age: z.number().int().positive().optional(), 7}); 8 9@Post() 10create(@Body({ schema: CreateUserSchema }) body: z.infer<typeof CreateUserSchema>) { 11 return this.usersService.create(body); 12} 13
配合StandardSchemaValidationPipe,连DTO类都省了。而且schema能直接喂给@nestjs/swagger生成OpenAPI文档——这点很关键,之前class-validator的装饰器和swagger的装饰器要写两遍,现在一份schema搞定。 **@nestjs/observe**试了一下,挺惊喜。不用接Jaeger、不用配OpenTelemetry collector,直接:
1import { createObserveModule, ObserveInstrument } from '@nestjs/observe'; 2 3const { ObserveModule } = createObserveModule(); 4 5const app = await NestFactory.create(AppModule, { 6 instrument: ObserveInstrument, 7}); 8
启动后自动采集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是真的快,这一点没骗我。
