NestJS 依赖注入:Controller 里那个没有 new 的 service,到底从哪来的?

一个 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 又依赖 LoggerDB,你写死 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.jsonpackage.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 的直白」更值,还是「容器注入的灵活」更值?欢迎在评论区说说你的判断标准。


相关推荐
渣波1 小时前
NestJS 企业级后端架构实战:从核心代码到工程化思维的深度重构
前端·typescript·nestjs
不好听6131 小时前
NestJS 是什么?一张图看懂企业级后端的骨架
后端·nestjs
嘟嘟07171 小时前
NestJS 入门:从 NestFactory 入口到 Module/Controller/Service 模块化结构一次讲清
typescript·node.js·nestjs
用户9385156350712 小时前
工厂模式与 Nest.js 核心思想 —— 从蜜雪冰城到企业级架构
后端·设计模式·nestjs
用户9385156350712 小时前
实战 Todo CRUD —— 从路由到异常,手写一个完整模块
后端·typescript·nestjs
烬羽16 小时前
从 nest new 到 Hello World:一个最小 NestJS 项目,讲透工厂 + 装饰器 + 模块化
设计模式·node.js·nestjs
不知疲倦的老鸟18 小时前
Next.js 15 多语言站点的 6 个坑:从 query string 路由到 URL 路径
typescript·next.js
北墨NoLimit18 小时前
鸿蒙线程间通信怎么选:TaskPool、TaskGroup、LongTask 与 Worker 实战
typescript·harmonyos
Orange_sparkle1 天前
从五个 TypeScript 文件看懂 Coding Agent:一次 nano-pi 学习复盘
javascript·学习·typescript