Nestjs 中 Provider 的注入方式扫盲,解决你的选择困难症

Providers 是 Nest 中的一个核心概念。许多基础的 Nest 类,如服务、仓库、工厂和辅助工具,都可以被视为提供者。提供者的核心思想是它可以作为依赖被注入,从而允许对象之间形成各种关系。"连接"这些对象的责任在很大程度上由 Nest 运行时系统处理。

但是 Providers 的注入方式有很多种,对此不了解的同学在开发中遇到时,可能难以选择该用哪一种方式,这篇文章就针对这一点做一个详细的阐述


0. 先把话说清楚:你纠结的其实是两件事

在 Nest 里,"我想用一个 Provider"通常包含两步:

  • 注册(registration) :把某个 token 和"怎么得到这个值/实例"的规则,交给 Nest 的 IoC 容器管理(通常写在 @Module({ providers: [...] }) 里)。
  • 注入(injection):在需要它的地方声明依赖,让 Nest 在创建类实例时把它"塞进来"(最常见是构造函数注入)。

另外要记住一个关键词:token

  • 最常用的 token :类本身(例如 CatsService)。
  • 也可以用 :字符串、Symbol、TypeScript enum(官方明确提到可以用这些)。
  • 不建议/不能直接用 :TypeScript interface(运行时不存在,容器没法拿它当 token 匹配)。

接下来所有"方式",本质都是围绕 token 在做文章:要么变更"这个 token 对应哪个实现/值",要么变更"这个实例的创建时机与生命周期"。


1. 方式一(默认首选):按类名 token 的构造器注入(Standard provider)

何时使用

  • 绝大多数业务场景的默认选择:Service / Repository / Helper 这类"可复用、可测试"的逻辑单元。
  • 当你不需要动态切换实现、不需要注入常量/第三方实例时,用它最省心。

典型场景

  • Controller 调 Service,Service 调 Repository。
  • 业务逻辑都在 class 里,依赖关系清晰。

注意事项

  • 别忘了注册 :类写了 @Injectable() 只是"允许被容器管理",但你仍要把它放进某个模块的 providers(或被某个模块导入后可见)。
  • 跨模块要 export/import :Provider 默认只在声明它的模块内部可见;要给别的模块用,需要 exports

伪代码

ts 复制代码
// cats.module.ts
@Module({
  providers: [CatsService], // 这是最常见的"短写"
  exports: [CatsService],   // 需要给别的模块用就导出
})
class CatsModule {}

// cats.controller.ts
@Controller('cats')
class CatsController {
  constructor(private readonly catsService: CatsService) {}
}

小知识:providers: [CatsService] 其实是下面这种"长写"的语法糖:{ provide: CatsService, useClass: CatsService }。理解这个等价关系,会让你更容易看懂后面的自定义 Provider。


2. 方式二:自定义 token + @Inject(token) 注入(字符串 / Symbol / enum)

何时使用

  • 你要注入的东西不是一个 class:常量、配置对象、第三方库实例(DB 连接、Redis client、SDK)。
  • 你想用一个"抽象 token"来隔离实现:例如用 CONFIG/CONNECTION 这类 token,让依赖方不直接 import 具体实现文件。

典型场景

  • 数据库连接、消息队列 client、第三方 SDK 实例。
  • 为了避免"魔法字符串"到处飘,集中管理 token。

注意事项

  • 尽量别直接散落字符串 token :官方建议把 token 放到独立文件(如 constants.ts)统一导出,避免冲突和拼写错误。
  • 更推荐 Symbol :字符串容易撞名;Symbol('CONNECTION') 更不容易冲突。

伪代码

ts 复制代码
// constants.ts
export const CONNECTION = Symbol('CONNECTION');

// db.module.ts
@Module({
  providers: [
    { provide: CONNECTION, useValue: connectionInstance },
  ],
  exports: [CONNECTION],
})
class DbModule {}

// cats.repository.ts
@Injectable()
class CatsRepository {
  constructor(@Inject(CONNECTION) private readonly conn: Connection) {}
}

3. 方式三:useValue(值提供者 Value provider)

何时使用

  • 注入常量值、配置对象、已经创建好的实例。
  • 测试/本地调试时,用 mock 替换真实实现(官方也拿它举例)。

典型场景

  • useValue: mockService 做单元测试替身。
  • 注入某个第三方库的"现成对象"(例如 logger、连接句柄)。

注意事项

  • useValue 直接把一个值交给容器:不会由 Nest new,也不会帮你管理它的内部依赖。
  • 如果你用它替换一个 class provider,确保这个值的"形状"能满足调用方需要(在 TS 里通常靠结构化类型兼容)。

伪代码

ts 复制代码
const mockCatsService = { findAll: () => [] };

@Module({
  providers: [
    { provide: CatsService, useValue: mockCatsService },
  ],
})
class TestModule {}

4. 方式四:useClass(类提供者 Class provider)

何时使用

  • 你想让一个 token 在不同环境/条件下解析到不同的实现类
  • 例如开发环境用 DevConfigService,生产环境用 ProdConfigService

典型场景

  • 多套实现按环境切换(dev/prod)。
  • 同一抽象能力的多实现(例如不同供应商的短信服务)。

注意事项

  • 依赖方注入的是 token(通常是一个"抽象入口"),不要在依赖方写 if/else 去挑实现,把选择逻辑放在 provider 注册处。

伪代码

ts 复制代码
const configProvider = {
  provide: ConfigService,
  useClass: isDev ? DevConfigService : ProdConfigService,
};

@Module({ providers: [configProvider] })
class AppModule {}

5. 方式五:useFactory(工厂提供者 Factory provider)

何时使用

  • 你需要"动态创建"一个实例:创建过程要读配置、组合参数、甚至依赖别的 Provider。
  • 你需要"异步初始化"后才允许系统启动(比如先连上数据库再接请求)。

典型场景

  • DB 连接创建、缓存 client 创建、按配置生成 SDK 实例。
  • 一部分依赖可选:没有就用默认行为。

注意事项

  • inject 数组的顺序要和工厂函数参数一一对应(官方明确说明会按顺序传参)。
  • inject 里可以声明可选依赖:{ token: XXX, optional: true },工厂函数就要能处理 undefined
  • 异步 provider :工厂返回 Promise 时,Nest 会等待它 resolve 后,才会实例化依赖它的类(官方在"Async providers"章节强调这一点)。

伪代码(同步 + 可选依赖)

ts 复制代码
const connectionProvider = {
  provide: CONNECTION,
  useFactory: (options: OptionsProvider, maybePrefix?: string) => {
    const opts = options.get();
    return new DatabaseConnection({ ...opts, prefix: maybePrefix });
  },
  inject: [
    OptionsProvider,
    { token: 'SOME_OPTIONAL', optional: true },
  ],
};

伪代码(异步初始化)

ts 复制代码
const asyncConnectionProvider = {
  provide: 'ASYNC_CONNECTION',
  useFactory: async () => {
    const conn = await createConnection(options);
    return conn;
  },
};

注入时和普通 provider 一样,只是 token 不同:

ts 复制代码
constructor(@Inject('ASYNC_CONNECTION') conn: Connection) {}

6. 方式六:useExisting(别名提供者 Alias provider)

何时使用

  • 你想让两个 token 指向同一个 Provider 实例(官方称之为 alias)。
  • 常见于迁移期:旧代码用旧 token,新代码用新 token,但底层实现先共用一份。

典型场景

  • 日志服务从 LoggerService 迁到 'LOGGER',但一段时间内两种写法都得兼容。

注意事项

  • useExisting 不是创建新实例,而是"多一个入口指向同一个实例"。
  • 在默认单例(DEFAULT)下,两边拿到的是同一对象;如果你用了请求级/瞬态作用域,要更小心理解生命周期(见后文"作用域")。

伪代码

ts 复制代码
const loggerAliasProvider = {
  provide: 'AliasedLoggerService',
  useExisting: LoggerService,
};

@Module({ providers: [LoggerService, loggerAliasProvider] })
class AppModule {}

7. 跨模块使用:导出(export)自定义 Provider

何时使用

  • 你的 Provider 定义在 DbModuleConfigModule 里,但别的模块要注入它。

典型场景

  • DbModule 里创建连接 provider,在 UserModule / OrderModule 注入使用。

注意事项

  • 自定义 Provider 默认只在本模块可见,要给别人用必须导出。
  • 官方给了两种导出方式:
    • exports: [TOKEN](导出 token)
    • exports: [providerObject](导出整个 provider 定义)

伪代码

ts 复制代码
const connectionFactory = { provide: 'CONNECTION', useFactory: ..., inject: [...] };

@Module({
  providers: [connectionFactory],
  exports: ['CONNECTION'], // 或 exports: [connectionFactory]
})
class DbModule {}

8. "可选依赖"到底怎么写?

官方文档里最直接、最可控的一种可选依赖写法,是在 useFactoryinject 里声明 optional: true

ts 复制代码
inject: [MyOptionsProvider, { token: 'SomeOptionalProvider', optional: true }]

这会让工厂函数对应参数可能为 undefined使用场景通常是"有则增强、无则降级"的依赖,比如可选的前缀、可选的扩展配置、可选的监控上报器等。

注意事项 很朴素:你必须把 undefined 当成合法输入处理掉,否则等同于把问题从"容器解析阶段"推迟到"运行时崩溃阶段"。


9. 属性注入(Property-based injection)要不要用?

Nest 支持用 @Inject(token) 在属性上注入,但官方长期更强调构造器注入这条主路径。实际工程里,一般建议把属性注入当成"应急方案":

何时使用

  • 你在做一些元编程/基类封装,构造器签名不方便改动。
  • 你非常明确这不会让依赖关系变得隐蔽(例如只在框架层封装里用)。

不太建议的原因

  • 依赖不在构造器里显式声明,阅读类定义时更难一眼看出"需要哪些东西"。
  • 测试替换与重构成本更高,容易留下隐性依赖。

伪代码

ts 复制代码
@Injectable()
class CatsRepository {
  @Inject(CONNECTION)
  private readonly conn: Connection;
}

10. 循环依赖:forwardRef()ModuleRef 的取舍

循环依赖指 A 依赖 B、B 也依赖 A。Nest 官方给了两条路:

方式 A:forwardRef()(最常用)

何时使用

  • 两个 Provider 真的是互相需要,而且短期内不好拆。

注意事项(官方强调的坑)

  • 实例化顺序不确定,代码不要依赖"谁先构造"。
  • 如果循环依赖链上出现 Scope.REQUEST 的 provider,可能导致依赖变成 undefined(官方给了明确 warning)。
  • 还有一种"看似 DI 的循环依赖",其实是 barrel file(index.ts 聚合导出)导致的 import 循环;官方建议在模块/Provider 类上尽量避免 barrel file。

伪代码:

ts 复制代码
@Injectable()
class AService {
  constructor(@Inject(forwardRef(() => BService)) private b: BService) {}
}

@Injectable()
class BService {
  constructor(@Inject(forwardRef(() => AService)) private a: AService) {}
}

模块之间循环 import 也同理:

ts 复制代码
@Module({ imports: [forwardRef(() => BModule)] })
class AModule {}

@Module({ imports: [forwardRef(() => AModule)] })
class BModule {}

方式 B:ModuleRef(重构友好)

何时使用

  • 你想把循环依赖"断开一边",让其中一方在运行时按需从容器取实例(而不是在构造器里硬绑死)。

注意事项

  • 这通常意味着你在改设计:把"必须在构造器里就拿到依赖"变成"需要时再取",要保证调用路径上能接受这种变化。

11. 作用域(Injection scopes):默认单例、请求级、瞬态

Nest 官方把 Provider 生命周期分为三类:

  • DEFAULT(默认) :全局单例,应用生命周期内共享一份实例。官方也明确说:大多数场景推荐单例
  • REQUEST:每个请求一份实例,请求结束后释放。适合"按请求隔离状态"的边界场景。
  • TRANSIENT:每个注入点(每个消费者)都会拿到一份新实例。

何时使用 REQUEST

  • GraphQL 的按请求缓存、请求链路追踪、多租户(根据请求头选择租户上下文)等官方列出的典型例子。

注意事项

  • 性能影响:请求级 provider 会让 DI 子树在每个请求都创建实例,官方建议除非必须,否则优先单例。
  • 作用域会沿依赖链"向上冒泡":Controller 依赖了 request-scoped provider,那么 Controller 自己也会变成 request-scoped。
  • WebSocket Gateway 不应使用 request-scoped:官方明确指出它们必须是单例;Passport strategy、Cron 等也有类似限制。

伪代码

ts 复制代码
@Injectable({ scope: Scope.REQUEST })
class RequestCacheService {}

// 或者在自定义 provider 上设置 scope
{ provide: 'CACHE_MANAGER', useClass: CacheManager, scope: Scope.TRANSIENT }

小结

  • 能用构造器注入 + 类 token 就别复杂化providers: [MyService] + constructor(private my: MyService) 是默认正确答案。
  • 要注入"不是 class 的东西" :用自定义 token(优先 Symbol)+ @Inject(token)
  • 要替换实现 / mock / 常量useValue
  • 要按环境/条件切换实现useClass
  • 要动态创建/组合依赖/异步初始化useFactory(需要 async 就直接返回 Promise)。
  • 要做兼容/迁移/多入口同实例useExisting
  • 遇到循环依赖 :优先重构拆分;确实拆不开再用 forwardRef(),并避开 request-scoped 组合的坑。
  • 作用域 :默认单例最香;REQUEST/TRANSIENT 是为边界问题准备的"手术刀",别当"菜刀"乱用。

参考(官方文档)

相关推荐
网络点点滴9 小时前
简述Node.js运行时核心架构
架构·node.js
小粉粉hhh10 小时前
Node.js(三)——模块化
node.js
晓杰'11 小时前
从0到1实现 Balatro 游戏后端(1):项目规划与牌型判断实现
后端·websocket·typescript·node.js·游戏开发·项目实战·nestjs
@PHARAOH11 小时前
WHAT - npm和corepack
前端·npm·node.js
MPGWJPMTJT11 小时前
从 Volta 迁移到 mise:Windows 下 Node 版本管理切换记录
前端·node.js
zhangfeng113311 小时前
Remotion 渲染视频脚本 ,自动化编辑视频 Node.js 层面是“单线程 JS”,但在实际渲染时是“高度并行”的。
node.js·自动化·音视频
羽师12 小时前
Node.js和npx关系
node.js
灵魂学者12 小时前
使用 Electron 打包项目构建 .EXE 桌面应用程序(简)
electron·node.js·vue·build·桌面应用程序
右耳朵猫AI13 小时前
Node.js技术周刊 2026年第14周
node.js
gogoing1 天前
Node.js 模块查找策略(require 完整流程)
javascript·node.js