NestJS 12升级踩坑:从Webpack到Rspack,我折腾了一整个周末

21 阅读6分钟

说实话,看到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"。 翻译成人话就是:OnModuleInitOnApplicationBootstrap这些钩子的调用顺序变了。以前是按模块注册顺序来的,现在按组件层级(父模块→子模块)。 我的项目有个数据库连接初始化逻辑放在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是真的快,这一点没骗我。