先记住这个答案
每个NestJS模块声明一组providers和imports/exports。当模块A需要模块B提供的服务时,A在imports中列出B,同时B必须将该服务在exports中导出。未导出的Provider只能被B自身的providers通过构造函数注入。exports可声明类、token或整个模块,实际是重新导出已注册的Provider。该机制维护了模块的封装边界,使依赖关系显式且可管理。
- Provider默认只在声明模块内可注入。
- 必须在exports中声明才能被其他模块使用。
- imports仅是引入模块,并不自动获得其所有Provider。
三个元件的编译期与运行期协作
在模块定义中,imports数组告诉Nest该模块依赖哪些模块,Nest会解析这些模块导出的Provider并放入当前模块的上下文。providers注册的是本模块自己创建和管理的依赖。exports则从已注册的providers或已导入模块的导出中挑选一组公开给其他模块。三者共同构建了模块图,图在应用启动时被Nest依据类型元数据解析。
关键点:即使模块A在imports中声明了模块B,A也只能注入B的exports字段里列出的Provider。若只写入providers而未放入exports,B内部的Provider就像模块私有成员,其他模块无法通过构造函数参数获取。这类似于Java的包私有或C#的internal,但Nest通过显式列表控制。
@Module({
providers: [SharedService],
exports: [SharedService],
})
export class SharedModule {}
@Module({
providers: [SecretService], // 不导出
})
export class PrivateModule {}
@Module({
imports: [SharedModule, PrivateModule],
providers: [ConsumerService],
})
export class ConsumerModule {
constructor(
private readonly shared: SharedService, // OK
private readonly secret: SecretService, // 编译通过,运行报错
) {}
}SharedService被导出,可注入到ConsumerModule;SecretService未导出,即使PrivateModule被导入,ConsumerModule的构造函数也无法解析SecretService,Nest会抛出无法解析依赖的异常。
业务模块复用数据库访问服务
假设系统有DatabaseModule,它提供TypeOrmModule.forRoot和UserRepository Provider。UserRepository是通过TypeOrmModule.forFeature注册或自定义的useFactory。该模块需要被UserModule和OrderModule使用。若DatabaseModule只将UserRepository放入providers而不列进exports,则UserModule即使imports了DatabaseModule,也无法在构造函数注入UserRepository,应用启动便会失败。
正确处理是将UserRepository加入exports。同时若UserModule还需导入TypeOrmModule本身(因为其内部其他Provider依赖Connection),则可通过DatabaseModule再导出TypeOrmModule来传递。决策依据是:任何需要被其他模块注入的Provider都必须出现在exports中,且export的token与providers注册的一致。
容易失效的条件与模块可见性代价
当使用自定义Token或useFactory时,exports中必须写相同的字符串Token或类,否则运行时找不到。若两个模块导出了同名不同实现的Provider,解析顺序不受imports顺序保证,这种设计应避免。此外,循环依赖的模块若通过exports互导,可能引发初始化问题,需结合forwardRef处理,但应优先重构模块边界。
另一种失效场景是模块A导入了B,又导出了从B导入的某个Provider。此时A的exports里可以写那个Provider的类,但Nest实际导出的是从B解析到的实例。若B没有导出它,A也无法重新导出。所以exports只能导出A自身providers或A能访问的已导入模块的导出,不能凭空生成。这一规则保证了依赖链的可追溯性。
容易答错的地方
- 只要imports就都能注入
- 错误认识:imports了某个模块,就能注入它providers里的任意服务。实际上imports只建立模块之间的上下文,但Nest会按exports列表限制可注入的Provider。若服务未导出,即使被imports也不能注入,控制台会报Nest can't resolve dependencies错误。
- exports需要重复声明全部providers
- 误解:exports必须和providers一样列出所有服务。其实exports可以只列一部分,也可以列从其他模块导入并需要再导出的服务。但未列入exports的Provider对模块外部绝对不可见,这是刻意的封装设计,不是遗漏。
面试官还会怎么问?
exports能导出另一个模块吗?
可以。在exports中写入一个模块类,相当于将该模块所有导出的Provider再次导出。常用于创建聚合模块,比如DatabaseModule导出TypeOrmModule。这不会重新注册,只是扩展可见性。
@Global()模块还需要exports吗?
仍需在exports中声明要公开的Provider。@Global()只是让模块的providers全局可见,无需在其他模块imports中重复,但exports列表依然决定哪些Provider被公开。若模块不exports任何东西,全局注册也不会暴露该服务的实例。
exports中写useClass或useValue的Token与类不一致时如何处理?
当用自定义Token时,exports必须用相同的Token字符串或Symbol,而不是提供实现的类。例如providers里用了{ provide: 'DB', useClass: DbConn },exports要写'DB',否则注入'DB'的消费者无法解析。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。