一个 30 行的迷你 DI 容器,拆穿 NestJS 的「自动 new」魔法
写 NestJS 的 CRUD 时,我盯着这行代码卡了整整半小时:
typescript
// src/todos/Todos.controller.ts
@Controller('todos')
export class TodosController {
constructor(private readonly todosService: TodosService) {}
@Get()
findAll(): Todo[] {
return this.todosService.findAll(); // 🔍 这个 todosService 是哪儿来的?!
}
}
我翻遍了整个文件,没有 new TodosService(),没有 import 之外任何创建它的代码。 但它就是能用,findAll() 就是能正常返回数据。
如果你是从 Express / Koa 转过来的,你八成也在这懵过。写 Express 时,服务是你亲手 new 出来的:
javascript
// ❌ Express 时代的肌肉记忆
const todosService = new TodosService();
app.get('/todos', (req, res) => res.json(todosService.findAll()));
到了 NestJS,new 凭空消失了。这篇文章就解决这一个问题:那个没被 new 的 service,到底是谁、在什么时候、怎么造出来的?
一句话剧透:是「依赖注入容器」替你 new 的。 但「替你 new」这三个字背后,藏着一套你以后会反复遇到的设计思想。
先回答「为什么不让你自己 new」
你可能会想:不就是一个 new 吗,我补上去不就完了?
typescript
// ❌ 最直接的「解法」:自己在构造函数里 new
export class TodosController {
private todosService: TodosService;
constructor() {
this.todosService = new TodosService(); // ⚠️ 看起来解决了,其实埋了雷
}
}
这确实能跑,但它把两个本该分开的东西焊死在了一起:「做什么」和「怎么做」。
TodosController 的本职是「处理 HTTP 请求、校验参数」,它不该知道 TodosService 是怎么被造出来的。一旦 new 写死,三个坏处立刻冒出来:
| 坏处 | 具体表现 |
|---|---|
| 没法换 | 想换成 MockTodosService 做测试?改源码才能换 |
| 没法复用 | 三个 controller 各自 new 一遍,同一个 service 有三个副本 |
| 连锁爆炸 | TodosService 又依赖 Logger、DB,你写死 new TodosService(),就得连带知道 Logger、DB 怎么 new |
尤其是测试这一条,NestJS 官方给的 app.controller.spec.ts 就是证据:
typescript
// src/app.controller.spec.ts
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController], // 🔑 想测哪个,就把哪个装进去
providers: [AppService], // 🔑 service 也是「配」进去的,不是写死的
}).compile();
appController = app.get<AppController>(AppController);
如果当初
AppController内部自己new AppService(),这段测试就写不出来------因为你想换成假 service 都无从下手。
所以 NestJS 的选择是:你只声明「我需要一个 TodosService」,至于怎么造、造几个、什么时候造,全部交给框架。
拆穿魔法:手写一个 30 行的迷你 DI 容器
「交给框架」听上去像玄学。我不喜欢玄学,所以动手写了一个最小的容器,把 NestJS 背后那套机制扒干净。它短到你能照着抄进浏览器控制台跑:
javascript
// mini-di.mjs ------ 一个 30 行就能跑的迷你依赖注入容器
class Container {
constructor() {
this.instances = new Map(); // 🔑 类型 → 单例实例
}
// 登记一个 provider:标记它是「可以被注入的」
register(Target) {
this.instances.set(Target, null); // 先占位,等第一次用的时候再 new
return Target;
}
// 根据「类型」拿实例:没有就 new,有就直接返回(这就是单例)
get(Target) {
// 1. 缓存命中,直接返回同一个实例
if (this.instances.has(Target) && this.instances.get(Target) !== null) {
return this.instances.get(Target);
}
// 2. 拿到它声明的依赖(构造函数的参数类型),递归 new 出来
const deps = (Target.$$deps || []).map((Dep) => this.get(Dep));
// 3. 真正的 new 在这里发生,而且只发生一次
const instance = new Target(...deps);
this.instances.set(Target, instance);
return instance;
}
}
配套的业务代码,写法上刻意模仿 NestJS:
javascript
class TodosService {
findAll() {
return [{ id: 1, title: '学习依赖注入', complete: false }];
}
}
class TodosController {
constructor(todosService) {
this.todosService = todosService; // 🔑 这是「注入」进来的,不是 new 的
}
list() {
return this.todosService.findAll();
}
}
TodosController.$$deps = [TodosService]; // 🔑 手动声明「我要一个 TodosService」
装配并运行:
javascript
const container = new Container();
container.register(TodosService);
container.register(TodosController);
const ctrl = container.get(TodosController);
console.log('ctrl.list() =>', JSON.stringify(ctrl.list()));
const a = container.get(TodosService);
const b = container.get(TodosService);
console.log('同一个实例吗?', a === b);
console.log('注入的 === 容器里的?', ctrl.todosService === a);
我实际跑出来的结果:
dart
ctrl.list() => [{"id":1,"title":"学习依赖注入","complete":false}]
同一个实例吗? true
注入的 === 容器里的? true
看最后两行 true------这个 30 行的容器,已经复刻了 NestJS 的两个核心行为:
① 单例 :同一个类,不管被
get多少次,返回的都是同一个实例(第二个true证明注入进来的就是容器里那个,不是复制品)。 ② 递归注入 :get(TodosController)时,容器发现它$$deps里要TodosService,就先new一个TodosService塞进去,再new出 Controller。
你现在回头再看 NestJS,它其实就是这 30 行代码的「豪华加强版」 。加强在哪?就一条------你刚才 $$deps = [TodosService] 是手动写 的,NestJS 让它自动了。
Nest 的真实机制:类型就是「注入的暗号」
在迷你容器里,我手动声明了 TodosController.$$deps = [TodosService]。但你看 NestJS 的源码,根本没人写这行。那 Nest 是怎么知道 Controller 的构造函数需要什么类型的?
答案藏在你项目的 tsconfig.json 和 package.json 里:
json
// tsconfig.json ------ 这两个开关默认就是开的
{
"compilerOptions": {
"experimentalDecorators": true, // 允许写 @ 装饰器
"emitDecoratorMetadata": true // 🔑 编译时,把「参数类型」写进元数据
}
}
json
// package.json ------ reflect-metadata 是 NestJS 的硬依赖
{
"dependencies": {
"reflect-metadata": "^0.2.2",
"@nestjs/common": "^11.0.1"
}
}
机制是这样的,一句一句跟:
text
你写:
constructor(private readonly todosService: TodosService)
│
▼ ① TypeScript 编译时(emitDecoratorMetadata: true)
自动生成元数据:
Reflect.defineMetadata('design:paramtypes', [TodosService], TodosController)
│
▼ ② NestJS 启动时扫描 @Module 的 providers
在容器里登记 TodosService(相当于我那个 register)
│
▼ ③ 实例化 TodosController 时
Reflect.getMetadata('design:paramtypes', TodosController)
→ 拿到 [TodosService]
│
▼ ④ 按类型去容器里找 TodosService(没有就 new 并缓存)
│
▼ ⑤ new TodosController(那个 service 实例)
这就是「自动 new」的全部秘密 :emitDecoratorMetadata 让 TypeScript 在编译期把「构造函数参数的类型」写进类上,NestJS 运行时用 reflect-metadata 读出来,按类型去容器里找------我手写的 $$deps,在 Nest 里变成了编译期自动生成的元数据。
所以「类型」在 NestJS 里不只是给 IDE 看的,它就是依赖注入的键(key) 。constructor(private readonly todosService: TodosService) 这句,本质上是在说:「去容器里,把 TodosService 那个类型的实例拿给我。」
一个新手必踩的坑:为什么不能用 interface 当注入类型
理解了「类型 = 键」之后,这个坑就不难懂了------但几乎所有 NestJS 新手都踩过。
TypeScript 的 interface 是编译期才存在的 ,编译成 JS 后 interface 直接消失,运行时不留下任何东西。而 design:paramtypes 记录的是运行时类型,所以:
typescript
// ❌ 报错:Cannot resolve dependency 'TODO_SERVICE'
interface TodoRepository { find(): Todo[] }
export class TodosService {
constructor(private repo: TodoRepository) {} // ⚠️ interface 编译后没了,Nest 读不到类型
}
Nest 读到 design:paramtypes 时,TodoRepository 这个位置是 Object 甚至 undefined,它自然不知道该 new 什么。这就是为什么 NestJS 官方让你在「接口 + 运行时 token」场景用 @Inject() 手动指定键:
typescript
// ✅ 用 token 显式声明注入的键
constructor(@Inject('TODO_REPOSITORY') private repo: TodoRepository) {}
所以 NestJS 不是「故意」让你写 class 而不是 interface,是它这套「类型即键」的机制天生依赖运行时类型。 想通这一层,以后看到 @Inject() 你就不会觉得它莫名其妙了。
回到 todos 模块,把链路串起来
现在把刚才的知识,套回你手上这个真实的 todos 模块。三个文件,一条完整的注入链:
text
TodosModule(装配声明)
@Module({ controllers: [TodosController], providers: [TodosService] })
│
│ ① providers 里登记了 TodosService → 容器认识它了
▼
TodosService(服务层)
@Injectable() ← 🔑 标记「我能被容器 new、也能被注入」
findAll() / findOne() / create() / update() / remove()
│
│ ② TodosController 声明要它
▼
TodosController(入口层)
constructor(private readonly todosService: TodosService)
│
│ ③ 容器读 design:paramtypes → 找到 TodosService → new 一次 → 注入
▼
@Get() findAll() { return this.todosService.findAll(); } // this.todosService 就有了
再看 Todos.service.ts 里那个 @Injectable()------它不是可有可无的装饰:
typescript
import { Injectable, NotFoundException } from '@nestjs/common';
@Injectable() // 🔑 没有它,Nest 无法把这个类登记进容器
export class TodosService {
findAll(): Todo[] {
return todos;
}
findOne(id: number): Todo {
const todo = todos.find((t) => t.id === id);
if (!todo) throw new NotFoundException(`Todo ${id} 不存在`); // ⚠️ 后端容错,标准化错误输出
return todo;
}
// ... create / remove / update
}
@Injectable() 就是我这个迷你容器里的 register()------它把类登记进容器,让别的类能按类型把它注入进来。 少了它,Controller 构造函数里的注入就会失败,报 Cannot resolve dependency。
结尾:下次看到 constructor 注入,别再找 new 了
回到开头那个让我卡半小时的问题------「这个 service 到底哪儿来的?」
答案一句话:它是依赖注入容器根据构造函数参数的类型,自动 new 出来、又只 new 一次的那个单例。 你写的 constructor(private readonly todosService: TodosService),不是「创建」,是「声明需求」。
如果只记住一句话,记住这句:
在 NestJS 里,
constructor不是用来new的,是用来「点单」的------你报出类型,容器负责做出来、送上门、还保证全店只有一个。
下次再看 NestJS 代码,别满世界找 new 了。看到 constructor(private readonly x: X),你就知道:「这是 Controller 在向容器点了一杯 X」 。而容器的本事,就是我上面那 30 行------登记、递归 new、缓存单例,仅此而已。
留一个开放问题:依赖注入让「测试时替换实现」变得很轻松,但代价是「对象从哪来」变得不直观、排查链路变长。你觉得对一个中小型项目来说,是「自己 new 的直白」更值,还是「容器注入的灵活」更值?欢迎在评论区说说你的判断标准。