Angular9 NgModule 常见问题
NgModules
可以帮你把应用组织成一些紧密相关的代码块。
这里回答的是开发者常问起的关于 NgModule
的设计与实现问题。
我应该把哪些类加到 declarations 中?
把可声明的类(组件、指令和管道)添加到 declarations
列表中。
这些类只能在应用程序的一个并且只有一个模块中声明。 只有当它们从属于某个模块时,才能把在此模块中声明它们。
什么是可声明的?
声明的就是组件、指令和管道这些可以被加到模块的 declarations
列表中的类。它们也是所有能被加到 declarations
中的类。
哪些类不应该加到 declarations 中?
只有可声明的类才能加到模块的 declarations
列表中。
不要声明:
- 已经在其它模块中声明过的类。无论它来自应用自己的模块(
@NgModule
)还是第三方模块。
- 从其它模块中导入的指令。例如,不要声明来自
@angular/forms
的FORMS_DIRECTIVES
,因为FormsModule
已经声明过它们了。
- 模块类。
- 服务类
- 非 Angular 的类和对象,比如:字符串、数字、函数、实体模型、配置、业务逻辑和辅助类。
为什么要把同一个组件声明在不同的 NgModule 属性中?
AppComponent
经常被同时列在 declarations
和 bootstrap
中。 另外你还可能看到 HeroComponent
被同时列在 declarations
、exports
和 entryComponent
中。
这看起来是多余的,不过这些函数具有不同的功能,从它出现在一个列表中无法推断出它也应该在另一个列表中。
AppComponent
可能被声明在此模块中,但可能不是引导组件。
AppComponent
可能在此模块中引导,但可能是由另一个特性模块声明的。
- 某个组件可能是从另一个应用模块中导入的(所以你没法声明它)并且被当前模块重新导出。
- 某个组件可能被导出,以便用在外部组件的模板中,也可能同时被一个弹出式对话框加载。
"Can't bind to 'x' since it isn't a known property of 'y'"是什么意思?
这个错误通常意味着你或者忘了声明指令“x”
,或者你没有导入“x”
所属的模块。
如果
“x”
其实不是属性,或者是组件的私有属性(比如它不带@Input
或@Output
装饰器),那么你也同样会遇到这个错误。
我应该导入什么?
导入你需要在当前模块的组件模板中使用的那些公开的(被导出的)可声明类。
这意味着要从 @angular/common
中导入 CommonModule
才能访问 Angular 的内置指令,比如 NgIf
和 NgFor
。 你可以直接导入它或者从重新导出过该模块的其它模块中导入它。
如果你的组件有 [(ngModel)]
双向绑定表达式,就要从 @angular/forms
中导入 FormsModule
。
如果当前模块中的组件包含了共享模块和特性模块中的组件、指令和管道,就导入这些模块。
只能在根模块 AppModule
中导入 BrowserModule
。
我应该导入 BrowserModule 还是 CommonModule?
几乎所有要在浏览器中使用的应用的根模块(AppModule)都应该从 @angular/platform-browser
中导入 BrowserModule
。
BrowserModule
提供了启动和运行浏览器应用的那些基本的服务提供者。
BrowserModule
还从 @angular/common
中重新导出了 CommonModule
,这意味着 AppModule
中的组件也同样可以访问那些每个应用都需要的 Angular 指令,如 NgIf
和 NgFor
。
在其它任何模块中都不要导入BrowserModule
。 特性模块和惰性加载模块应该改成导入 CommonModule
。 它们需要通用的指令。它们不需要重新初始化全应用级的提供者。
如果我两次导入同一个模块会怎么样?
没有任何问题。当三个模块全都导入模块'A'
时,Angular 只会首次遇到时加载一次模块'A'
,之后就不会这么做了。
无论 A
出现在所导入模块的哪个层级,都会如此。 如果模块'B'
导入模块'A'
、模块'C'
导入模块'B'
,模块'D'
导入 [C, B, A]
,那么'D'
会触发模块'C'
的加载,'C'
会触发'B'
的加载,而'B'
会加载'A'
。 当 Angular 在'D'
中想要获取'B'
和'A'
时,这两个模块已经被缓存过了,可以立即使用。
Angular 不允许模块之间出现循环依赖,所以不要让模块'A'
导入模块'B'
,而模块'B'
又导入模块'A'
。
特性模块中导入 CommonModule
可以让它能用在任何目标平台上,不仅是浏览器。那些跨平台库的作者应该喜欢这种方式的。
我应该导出什么?
导出那些其它模块希望在自己的模板中引用的可声明类。这些也是你的公共类。 如果你不导出某个类,它就是私有的,只对当前模块中声明的其它组件可见。
你可以导出任何可声明类(组件、指令和管道),而不用管它是声明在当前模块中还是某个导入的模块中。
你可以重新导出整个导入过的模块,这将导致重新导出它们导出的所有类。重新导出的模块甚至不用先导入。
我不应该导出什么?
不要导出:
- 那些你只想在当前模块中声明的那些组件中使用的私有组件、指令和管道。如果你不希望任何模块看到它,就不要导出。
- 不可声明的对象,比如服务、函数、配置、实体模型等。
- 那些只被路由器或引导函数动态加载的组件。 比如入口组件可能从来不会在其它组件的模板中出现。 导出它们没有坏处,但也没有好处。
- 纯服务模块没有公开(导出)的声明。 例如,没必要重新导出
HttpClientModule
,因为它不导出任何东西。 它唯一的用途是一起把 http 的那些服务提供者添加到应用中。
我可以重新导出类和模块吗?
毫无疑问!
模块是从其它模块中选取类并把它们重新导出成统一、便利的新模块的最佳方式。
模块可以重新导出其它模块,这会导致重新导出它们导出的所有类。 Angular 自己的 BrowserModule
就重新导出了一组模块,例如:
exports: [CommonModule, ApplicationModule]
模块还能导出一个组合,它可以包含自己的声明、某些导入的类以及导入的模块。
不要费心去导出纯服务类。 纯服务类的模块不会导出任何可供其它模块使用的可声明类。 例如,不用重新导出 HttpClientModule
,因为它没有导出任何东西。 它唯一的用途是把那些 http
服务提供者一起添加到应用中。
forRoot()方法是什么?
静态方法 forRoot()
是一个约定,它可以让开发人员更轻松的配置模块的想要单例使用的服务及其提供者。RouterModule.forRoot()
就是一个很好的例子。
应用把一个 Routes
对象传给 RouterModule.forRoot()
,为的就是使用路由配置全应用级的 Router
服务。 RouterModule.forRoot()
返回一个ModuleWithProviders
对象。 你把这个结果添加到根模块 AppModule
的 imports
列表中。
只能在应用的根模块 AppModule
中调用并导入 forRoot()
的结果。 在其它模块,特别是惰性加载模块中,不要导入它。 要了解关于 forRoot()
的更多信息,参见单例服务一章的 the forRoot()
模式部分。
对于服务来说,除了可以使用 forRoot()
外,更好的方式是在该服务的 @Injectable()
装饰器中指定 providedIn
: 'root'
,它让该服务自动在全应用级可用,这样它也就默认是单例的。
RouterModule
也提供了静态方法 forChild()
,用于配置惰性加载模块的路由。
forRoot()
和 forChild()
都是约定俗成的方法名,它们分别用于在根模块和特性模块中配置服务。
当你写类似的需要可配置的服务提供者时,请遵循这个约定。
为什么服务提供者在特性模块中的任何地方都是可见的?
列在引导模块的 @NgModule.providers
中的服务提供者具有全应用级作用域。 往 NgModule.providers
中添加服务提供者将导致该服务被发布到整个应用中。
当你导入一个模块时,Angular 就会把该模块的服务提供者(也就是它的 providers
列表中的内容)加入该应用的根注入器中。
这会让该提供者对应用中所有知道该提供者令牌(token
)的类都可见。
通过 NgModule
导入来实现可扩展性是 NgModule
体系的主要设计目标。 把 NgModule
的提供者并入应用程序的注入器可以让库模块使用新的服务来强化应用程序变得更容易。 只要添加一次 HttpClientModule
,那么应用中的每个组件就都可以发起 Http
请求了。
不过,如果你期望模块的服务只对那个特性模块内部声明的组件可见,那么这可能会带来一些不受欢迎的意外。 如果 HeroModule
提供了一个 HeroService
,并且根模块 AppModule
导入了 HeroModule
,那么任何知道 HeroService
类型的类都可能注入该服务,而不仅是在 HeroModule
中声明的那些类。
要限制对某个服务的访问,可以考虑惰性加载提供该服务的 NgModule
。
为什么在惰性加载模块中声明的服务提供者只对该模块自身可见?
和启动时就加载的模块中的提供者不同,惰性加载模块中的提供者是局限于模块的。
当 Angular 路由器惰性加载一个模块时,它创建了一个新的运行环境。 那个环境拥有自己的注入器,它是应用注入器的直属子级。
路由器把该惰性加载模块的提供者和它导入的模块的提供者添加到这个子注入器中。
这些提供者不会被拥有相同令牌的应用级别提供者的变化所影响。 当路由器在惰性加载环境中创建组件时,Angular 优先使用惰性加载模块中的服务实例,而不是来自应用的根注入器的。
如果两个模块提供了同一个服务会怎么样?
当同时加载了两个导入的模块,它们都列出了使用同一个令牌的提供者时,后导入的模块会“获胜”,这是因为这两个提供者都被添加到了同一个注入器中。
当 Angular 尝试根据令牌注入服务时,它使用第二个提供者来创建并交付服务实例。
每个注入了该服务的类获得的都是由第二个提供者创建的实例。 即使是声明在第一个模块中的类,它取得的实例也是来自第二个提供者的。
如果模块 A
提供了一个使用令牌'X'
的服务,并且导入的模块 B
也用令牌'X'
提供了一个服务,那么模块 A
中定义的服务“获胜”了。
由根 AppModule
提供的服务相对于所导入模块中提供的服务有优先权。换句话说:AppModule
总会获胜。
我应该如何把服务的范围限制到模块中?
如果一个模块在应用程序启动时就加载,它的 @NgModule.providers
具有全应用级作用域。 它们也可用于整个应用的注入中。
导入的提供者很容易被由其它导入模块中的提供者替换掉。 这虽然是故意这样设计的,但是也可能引起意料之外的结果。
作为一个通用的规则,应该只导入一次带提供者的模块,最好在应用的根模块中。 那里也是配置、包装和改写这些服务的最佳位置。
假设模块需要一个定制过的 HttpBackend
,它为所有的 Http
请求添加一个特别的请求头。 如果应用中其它地方的另一个模块也定制了 HttpBackend
或仅仅导入了 HttpClientModule
,它就会改写当前模块的 HttpBackend
提供者,丢掉了这个特别的请求头。 这样服务器就会拒绝来自该模块的请求。
要消除这个问题,就只能在应用的根模块 AppModule
中导入 HttpClientModule
。
如果你必须防范这种“提供者腐化”现象,那就不要依赖于“启动时加载”模块的 providers
。
只要可能,就让模块惰性加载。 Angular 给了惰性加载模块自己的子注入器。 该模块中的提供者只对由该注入器创建的组件树可见。
如果你必须在应用程序启动时主动加载该模块,就改成在组件中提供该服务。
继续看这个例子,假设某个模块的组件真的需要一个私有的、自定义的 HttpBackend
。
那就创建一个“顶层组件”来扮演该模块中所有组件的根。 把这个自定义的 HttpBackend
提供者添加到这个顶层组件的 providers
列表中,而不是该模块的 providers
中。 回忆一下,Angular 会为每个组件实例创建一个子注入器,并使用组件自己的 providers
来配置这个注入器。
当该组件的子组件想要一个 HttpBackend
服务时,Angular 会提供一个局部的 HttpBackend
服务,而不是应用的根注入器创建的那个。 子组件将正确发起 http 请求,而不管其它模块对 HttpBackend
做了什么。
确保把模块中的组件都创建成这个顶层组件的子组件。
你可以把这些子组件都嵌在顶层组件的模板中。或者,给顶层组件一个 <router-outlet>
,让它作为路由的宿主。 定义子路由,并让路由器把模块中的组件加载进该路由出口(outlet
)中。
虽然通过在惰性加载模块中或组件中提供某个服务来限制它的访问都是可行的方式,但在组件中提供服务可能导致这些服务出现多个实例。因此,应该优先使用惰性加载的方式。
我应该把全应用级提供者添加到根模块 AppModule 中还是根组件 AppComponent 中?
通过在服务的 @Injectable()
装饰器中(例如服务)指定 providedIn: 'root'
来定义全应用级提供者,或者 InjectionToken
的构造器(例如提供令牌的地方),都可以定义全应用级提供者。 通过这种方式创建的服务提供者会自动在整个应用中可用,而不用把它列在任何模块中。
如果某个提供者不能用这种方式配置(可能因为它没有有意义的默认值),那就在根模块 AppModule
中注册这些全应用级服务,而不是在 AppComponent
中。
惰性加载模块及其组件可以注入 AppModule
中的服务,却不能注入 AppComponent
中的。
只有当该服务必须对 AppComponent
组件树之外的组件不可见时,才应该把服务注册进 AppComponent
的 providers
中。 这是一个非常罕见的异常用法。
更一般地说,优先把提供者注册进模块中,而不是组件中。
讨论
Angular 把所有启动期模块的提供者都注册进了应用的根注入器中。 这些服务是由根注入器中的提供者创建的,并且在整个应用中都可用。 它们具有应用级作用域。
某些服务(比如 Router
)只有当注册进应用的根注入器时才能正常工作。
相反,Angular 使用 AppComponent
自己的注入器注册了 AppComponent
的提供者。 AppComponent
服务只在该组件及其子组件树中才能使用。 它们具有组件级作用域。
AppComponent
的注入器是根注入器的子级,注入器层次中的下一级。 这对于没有路由器的应用来说几乎是整个应用了。 但对那些带路由的应用,路由操作位于顶层,那里不存在 AppComponent
服务。这意味着惰性加载模块不能使用它们。
我应该把其它提供者注册到模块中还是组件中?
提供者应该使用 @Injectable
语法进行配置。只要可能,就应该把它们在应用的根注入器中提供(providedIn: 'root'
)。 如果它们只被惰性加载的上下文中使用,那么这种方式配置的服务就是惰性加载的。
如果要由消费方来决定是否把它作为全应用级提供者,那么就要在模块中(@NgModule.providers
)注册提供者,而不是组件中(@Component.providers
)。
当你必须把服务实例的范围限制到某个组件及其子组件树时,就把提供者注册到该组件中。 指令的提供者也同样照此处理。
例如,如果英雄编辑组件需要自己私有的缓存英雄服务实例,那就应该把 HeroService
注册进 HeroEditorComponent
中。 这样,每个新的 HeroEditorComponent
的实例都会得到一份自己的缓存服务实例。 编辑器的改动只会作用于它自己的服务,而不会影响到应用中其它地方的英雄实例。
总是在根模块 AppModule
中注册全应用级服务,而不要在根组件 AppComponent
中。
为什么在共享模块中为惰性加载模块提供服务是个馊主意?
急性加载的场景
当急性加载的模块提供了服务时,比如 UserService
,该服务是在全应用级可用的。如果根模块提供了 UserService
,并导入了另一个也提供了同一个 UserService
的模块,Angular 就会把它们中的一个注册进应用的根注入器中(参见如果两次导入了同一个模块会怎样?)。
然后,当某些组件注入 UserService
时,Angular 就会发现它已经在应用的根注入器中了,并交付这个全应用级的单例服务。这样不会出现问题。
惰性加载场景
现在,考虑一个惰性加载的模块,它也提供了一个名叫 UserService
的服务。
当路由器准备惰性加载 HeroModule
的时候,它会创建一个子注入器,并且把 UserService
的提供者注册到那个子注入器中。子注入器和根注入器是不同的。
当 Angular 创建一个惰性加载的 HeroComponent
时,它必须注入一个 UserService
。 这次,它会从惰性加载模块的子注入器中查找 UserService
的提供者,并用它创建一个 UserService
的新实例。 这个 UserService
实例与 Angular 在主动加载的组件中注入的那个全应用级单例对象截然不同。
这个场景导致你的应用每次都创建一个新的服务实例,而不是使用单例的服务。
为什么惰性加载模块会创建一个子注入器?
Angular 会把 @NgModule.providers
中的提供者添加到应用的根注入器中…… 除非该模块是惰性加载的,这种情况下,Angular 会创建一子注入器,并且把该模块的提供者添加到这个子注入器中。
这意味着模块的行为将取决于它是在应用启动期间加载的还是后来惰性加载的。如果疏忽了这一点,可能导致严重后果。
为什么 Angular 不能像主动加载模块那样把惰性加载模块的提供者也添加到应用程序的根注入器中呢?为什么会出现这种不一致?
归根结底,这来自于 Angular 依赖注入系统的一个基本特征: 在注入器还没有被第一次使用之前,可以不断为其添加提供者。 一旦注入器已经创建和开始交付服务,它的提供者列表就被冻结了,不再接受新的提供者。
当应用启动时,Angular 会首先使用所有主动加载模块中的提供者来配置根注入器,这发生在它创建第一个组件以及注入任何服务之前。 一旦应用开始工作,应用的根注入器就不再接受新的提供者了。
之后,应用逻辑开始惰性加载某个模块。 Angular 必须把这个惰性加载模块中的提供者添加到某个注入器中。 但是它无法将它们添加到应用的根注入器中,因为根注入器已经不再接受新的提供者了。 于是,Angular 在惰性加载模块的上下文中创建了一个新的子注入器。
我要如何知道一个模块或服务是否已经加载过了?
某些模块及其服务只能被根模块 AppModule
加载一次。 在惰性加载模块中再次导入这个模块会导致错误的行为,这个错误可能非常难于检测和诊断。
为了防范这种风险,可以写一个构造函数,它会尝试从应用的根注入器中注入该模块或服务。如果这种注入成功了,那就说明这个类是被第二次加载的,你就可以抛出一个错误,或者采取其它挽救措施。
某些 NgModule
(例如 BrowserModule
)就实现了那样一个守卫。 下面是一个名叫 GreetingModule
的 NgModule
的 自定义构造函数。
Path:"src/app/greeting/greeting.module.ts (Constructor)" 。
constructor (@Optional() @SkipSelf() parentModule?: GreetingModule) {
if (parentModule) {
throw new Error(
'GreetingModule is already loaded. Import it in the AppModule only');
}
}
什么是入口组件?
Angular 根据组件类型命令式加载的组件是入口组件.
而通过组件选择器声明式加载的组件则不是入口组件。
Angular 会声明式的加载组件,它使用组件的选择器在模板中定位元素。 然后,Angular 会创建该组件的 HTML 表示,并把它插入 DOM 中所选元素的内部。它们不是入口组件。
而用于引导的根 AppComponent
则是一个入口组件。 虽然它的选择器匹配了 "index.html" 中的一个元素,但是 "index.html" 并不是组件模板,而且 AppComponent
选择器也不会在任何组件模板中出现。
在路由定义中用到的组件也同样是入口组件。 路由定义根据类型来引用组件。 路由器会忽略路由组件的选择器(即使它有选择器),并且把该组件动态加载到 RouterOutlet
中。
引导组件和入口组件有什么不同?
引导组件是入口组件的一种。 它是被 Angular 的引导(应用启动)过程加载到 DOM 中的入口组件。 其它入口组件则是被其它方式动态加载的,比如被路由器加载。
@NgModule.bootstrap
属性告诉编译器这是一个入口组件,同时它应该生成一些代码来用该组件引导此应用。
不需要把组件同时列在 bootstrap
和 entryComponent
列表中 —— 虽然这样做也没坏处。
什么时候我应该把组件加到 entryComponents 中?
大多数应用开发者都不需要把组件添加到 entryComponents
中。
Angular 会自动把恰当的组件添加到入口组件中。 列在 @NgModule.bootstrap
中的组件会自动加入。 由路由配置引用到的组件会被自动加入。 用这两种机制添加的组件在入口组件中占了绝大多数。
如果你的应用要用其它手段来根据类型引导或动态加载组件,那就得把它显式添加到 entryComponents
中。
虽然把组件加到这个列表中也没什么坏处,不过最好还是只添加真正的入口组件。 不要添加那些被其它组件的模板引用过的组件。
为什么 Angular 需要入口组件?
原因在于摇树优化。对于产品化应用,你会希望加载尽可能小而快的代码。 代码中应该仅仅包括那些实际用到的类。 它应该排除那些从未用过的组件,无论该组件是否被声明过。
事实上,大多数库中声明和导出的组件你都用不到。 如果你从未引用它们,那么摇树优化器就会从最终的代码包中把这些组件砍掉。
如果Angular 编译器为每个声明的组件都生成了代码,那么摇树优化器的作用就没有了。
所以,编译器转而采用一种递归策略,它只为你用到的那些组件生成代码。
编译器从入口组件开始工作,为它在入口组件的模板中找到的那些组件生成代码,然后又为在这些组件中的模板中发现的组件生成代码,以此类推。 当这个过程结束时,它就已经为每个入口组件以及从入口组件可以抵达的每个组件生成了代码。
如果该组件不是入口组件或者没有在任何模板中发现过,编译器就会忽略它。
有哪些类型的模块?我应该如何使用它们?
每个应用都不一样。根据不同程度的经验,开发者会做出不同的选择。下列建议和指导原则广受欢迎。
SharedModule
为那些可能会在应用中到处使用的组件、指令和管道创建 SharedModule
。 这种模块应该只包含 declarations
,并且应该导出几乎所有 declarations
里面的声明。
SharedModule
可以重新导出其它小部件模块,比如 CommonModule
、FormsModule
和提供你广泛使用的 UI 控件的那些模块。
SharedModule
不应该带有 providers
,原因在前面解释过了。 它的导入或重新导出的模块中也不应该有 providers
。 如果你要违背这条指导原则,请务必想清楚你在做什么,并要有充分的理由。
在任何特性模块中(无论是你在应用启动时主动加载的模块还是之后惰性加载的模块),你都可以随意导入这个 SharedModule
。
特性模块
特性模块是你围绕特定的应用业务领域创建的模块,比如用户工作流、小工具集等。它们包含指定的特性,并为你的应用提供支持,比如路由、服务、窗口部件等。 要对你的应用中可能会有哪些特性模块有个概念,考虑如果你要把与特定功能(比如搜索)有关的文件放进一个目录下,该目录的内容就可能是一个名叫 SearchModule
的特性模块。 它将会包含构成搜索功能的全部组件、路由和模板。
在 NgModule 和 JavaScript 模块之间有什么不同?
在 Angular 应用中,NgModule
会和 JavaScript 的模块一起工作。
在现代 JavaScript 中,每个文件都是模块(参见模块)。 在每个文件中,你要写一个 export
语句将模块的一部分公开。
Angular 模块是一个带有 @NgModule
装饰器的类,而 JavaScript 模块则没有。 Angular 的 NgModule
有自己的 imports
和 exports
来达到类似的目的。
你可以导入其它 NgModules
,以便在当前模块的组件模板中使用它们导出的类。 你可以导出当前 NgModules
中的类,以便其它 NgModules
可以导入它们,并用在自己的组件模板中。
Angular 如何查找模板中的组件、指令和管道?什么是 模板引用 ?
Angular 编译器在组件模板内查找其它组件、指令和管道。一旦找到了,那就是一个“模板引用”。
Angular 编译器通过在一个模板的 HTML 中匹配组件或指令的选择器(selector
),来查找组件或指令。
编译器通过分析模板 HTML 中的管道语法中是否出现了特定的管道名来查找对应的管道。
Angular 只查询两种组件、指令或管道: 1)那些在当前模块中声明过的,以及 2)那些被当前模块导入的模块所导出的。
什么是 Angular 编译器?
Angular 编译器会把你所编写的应用代码转换成高性能的 JavaScript 代码。 在编译过程中,@NgModule
的元数据扮演了很重要的角色。
你写的代码是无法直接执行的。 比如组件。 组件有一个模板,其中包含了自定义元素、属性型指令、Angular 绑定声明和一些显然不属于原生 HTML 的古怪语法。
Angular 编译器读取模板的 HTML,把它和相应的组件类代码组合在一起,并产出组件工厂。
组件工厂为组件创建纯粹的、100% JavaScript 的表示形式,它包含了 @Component
元数据中描述的一切:HTML、绑定指令、附属的样式等……
由于指令和管道都出现在组件模板中,*Angular
编译器**也同样会把它们组合进编译后的组件代码中。
@NgModule
元数据告诉 Angular 编译器要为当前模块编译哪些组件,以及如何把当前模块和其它模块链接起来。
更多建议: