Providers 是 Nest 中的一个核心概念。许多基础的 Nest 类,如服务、仓库、工厂和辅助工具,都可以被视为提供者。提供者的核心思想是它可以作为依赖被注入,从而允许对象之间形成各种关系。“连接”这些对象的责任在很大程度上由 Nest 运行时系统处理。
但是 Providers 的注入方式有很多种,对此不了解的同学在开发中遇到时,可能难以选择该用哪一种方式,这篇文章就针对这一点做一个详细的阐述
0. 先把话说清楚:你纠结的其实是两件事
在 Nest 里,“我想用一个 Provider”通常包含两步:
- 注册(registration):把某个 token 和“怎么得到这个值/实例”的规则,交给 Nest 的 IoC 容器管理(通常写在
@Module({ providers: [...] })里)。 - 注入(injection):在需要它的地方声明依赖,让 Nest 在创建类实例时把它“塞进来”(最常见是构造函数注入)。
另外要记住一个关键词:token。
- 最常用的 token:类本身(例如
CatsService)。 - 也可以用:字符串、
Symbol、TypeScriptenum(官方明确提到可以用这些)。 - 不建议/不能直接用:TypeScript
interface(运行时不存在,容器没法拿它当 token 匹配)。
接下来所有“方式”,本质都是围绕 token 在做文章:要么变更“这个 token 对应哪个实现/值”,要么变更“这个实例的创建时机与生命周期”。
1. 方式一(默认首选):按类名 token 的构造器注入(Standard provider)
何时使用
- 绝大多数业务场景的默认选择:Service / Repository / Helper 这类“可复用、可测试”的逻辑单元。
- 当你不需要动态切换实现、不需要注入常量/第三方实例时,用它最省心。
典型场景
- Controller 调 Service,Service 调 Repository。
- 业务逻辑都在 class 里,依赖关系清晰。
注意事项
- 别忘了注册:类写了
@Injectable()只是“允许被容器管理”,但你仍要把它放进某个模块的providers(或被某个模块导入后可见)。 - 跨模块要 export/import:Provider 默认只在声明它的模块内部可见;要给别的模块用,需要
exports。
伪代码
// 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')更不容易冲突。
伪代码
// 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 里通常靠结构化类型兼容)。
伪代码
const mockCatsService = { findAll: () => [] };
@Module({
providers: [
{ provide: CatsService, useValue: mockCatsService },
],
})
class TestModule {}
4. 方式四:useClass(类提供者 Class provider)
何时使用
- 你想让一个 token 在不同环境/条件下解析到不同的实现类。
- 例如开发环境用
DevConfigService,生产环境用ProdConfigService。
典型场景
- 多套实现按环境切换(dev/prod)。
- 同一抽象能力的多实现(例如不同供应商的短信服务)。
注意事项
- 依赖方注入的是 token(通常是一个“抽象入口”),不要在依赖方写 if/else 去挑实现,把选择逻辑放在 provider 注册处。
伪代码
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”章节强调这一点)。
伪代码(同步 + 可选依赖)
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 },
],
};
伪代码(异步初始化)
const asyncConnectionProvider = {
provide: 'ASYNC_CONNECTION',
useFactory: async () => {
const conn = await createConnection(options);
return conn;
},
};
注入时和普通 provider 一样,只是 token 不同:
constructor(@Inject('ASYNC_CONNECTION') conn: Connection) {}
6. 方式六:useExisting(别名提供者 Alias provider)
何时使用
- 你想让两个 token 指向同一个 Provider 实例(官方称之为 alias)。
- 常见于迁移期:旧代码用旧 token,新代码用新 token,但底层实现先共用一份。
典型场景
- 日志服务从
LoggerService迁到'LOGGER',但一段时间内两种写法都得兼容。
注意事项
useExisting不是创建新实例,而是“多一个入口指向同一个实例”。- 在默认单例(
DEFAULT)下,两边拿到的是同一对象;如果你用了请求级/瞬态作用域,要更小心理解生命周期(见后文“作用域”)。
伪代码
const loggerAliasProvider = {
provide: 'AliasedLoggerService',
useExisting: LoggerService,
};
@Module({ providers: [LoggerService, loggerAliasProvider] })
class AppModule {}
7. 跨模块使用:导出(export)自定义 Provider
何时使用
- 你的 Provider 定义在
DbModule、ConfigModule里,但别的模块要注入它。
典型场景
- 在
DbModule里创建连接 provider,在UserModule/OrderModule注入使用。
注意事项
- 自定义 Provider 默认只在本模块可见,要给别人用必须导出。
- 官方给了两种导出方式:
exports: [TOKEN](导出 token)exports: [providerObject](导出整个 provider 定义)
伪代码
const connectionFactory = { provide: 'CONNECTION', useFactory: ..., inject: [...] };
@Module({
providers: [connectionFactory],
exports: ['CONNECTION'], // 或 exports: [connectionFactory]
})
class DbModule {}
8. “可选依赖”到底怎么写?
官方文档里最直接、最可控的一种可选依赖写法,是在 useFactory 的 inject 里声明 optional: true:
inject: [MyOptionsProvider, { token: 'SomeOptionalProvider', optional: true }]
这会让工厂函数对应参数可能为 undefined。使用场景通常是“有则增强、无则降级”的依赖,比如可选的前缀、可选的扩展配置、可选的监控上报器等。
注意事项很朴素:你必须把 undefined 当成合法输入处理掉,否则等同于把问题从“容器解析阶段”推迟到“运行时崩溃阶段”。
9. 属性注入(Property-based injection)要不要用?
Nest 支持用 @Inject(token) 在属性上注入,但官方长期更强调构造器注入这条主路径。实际工程里,一般建议把属性注入当成“应急方案”:
何时使用
- 你在做一些元编程/基类封装,构造器签名不方便改动。
- 你非常明确这不会让依赖关系变得隐蔽(例如只在框架层封装里用)。
不太建议的原因
- 依赖不在构造器里显式声明,阅读类定义时更难一眼看出“需要哪些东西”。
- 测试替换与重构成本更高,容易留下隐性依赖。
伪代码
@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。
伪代码:
@Injectable()
class AService {
constructor(@Inject(forwardRef(() => BService)) private b: BService) {}
}
@Injectable()
class BService {
constructor(@Inject(forwardRef(() => AService)) private a: AService) {}
}
模块之间循环 import 也同理:
@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 等也有类似限制。
伪代码
@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是为边界问题准备的“手术刀”,别当“菜刀”乱用。