原文出处:angularindepth
翻译说明: 本文翻译采用意译并对原文进行适当排版以方便阅读。
术语采用加粗斜体表示, 术语第一次出现时其后括号内标注英文术语。
Angular 的模块(module) 是个相当复杂的话题。Angular 团队完成了一项很棒的工作 —— 展示了很长的关于NgModule
的文档页面, 可以在 这里 找到。 文档中对大多数话题都有清晰的解释,但是有些方面依旧欠缺,因此经常被开发者误解。我见到很多人误解了解释,并且不正确地使用了那些建议, 因为他们不明白模块在底层是如何运作的。
本文会深入讲述这些内容,理清我每天在stackoverflow上看到的普遍的误解。
模块封装(Module encapsulation)
Angular 引入了和 ES 模块类似的模块封装的概念。 它基本上意味着可声明(declarable)的类型 —— 组件(components), 指令(directive) 和 管道(pipes) —— 只能被在此模块声明的组件中使用。例如, 如果我想在 App 模块的 App 组件中使用 来自 A 模块的 a-comp:
@Component({
selector: 'my-app',
template: `
<h1>Hello {{name}}</h1>
<a-comp></a-comp>
`
})
export class AppComponent { }
就会遇到错误:
Template parse errors: ‘a-comp’ is not a known element
这是因为在 App 模块中并没有声明 a-comp. 如果我想使用这个组件, 我必须导入定义这个组件的模块。 可以这样做:
@NgModule({
imports: [..., AModule]
})
export class AppModule { }
这里就是封装起作用的地方。要让这个配置能够工作,A模块必须通过把 a-comp 添加到 exports 数组的方式,将 a-comp声明为公共的 。
@NgModule({
...
declarations: [AComponent],
exports: [AComponent]
})
export class AModule { }
其他的可声明类型—— 指令和管道也是如此。
@NgModule({
...
declarations: [
PublicPipe,
PrivatePipe,
PublicDirective,
PrivateDirective
],
exports: [PublicPipe, PublicDirective]
})
export class AModule {}
请注意: 添加到 entryComponents
中的组件并没有封装。如果你想用Here is what you need to know about dynamic components in Angular 中描述的动态视图以及动态组件实例, 你可以使用 来自 A
模块的组件而不用添加他们到 exports
数组中。 当然, 你仍然需要导入 A
模块。
大多数新手开发者有时会认为 供应商(providers)也有封装, 其实并没有。 在任何非懒加载的模块中声明的供应商可以被应用中的任何地方访问到。接下来的章节解释了原因。
模块的层次结构(Modules hierarchy)
对导入模块最大的困惑是开发人员认为它们构成了层次结构。模块导入其他模块后成为导入模块的父模块, 这种假设可能是合理的 。然而, 这种情况并没有发生。 所有的模块会在编辑阶段合并。 因此, 在导入模块和被导入模块之间并没有层次关系。
和 组件 一样,Angular 编译器也生成 根模块的工厂。根模块就是你在 main.ts
bootstrapModule
方法中所指定的:
platformBrowserDynamic().bootstrapModule(AppModule);
Angular 使用 createNgModuleFactory 函数所生成的工厂有:
- 模块类引用
- 启动组件
- 具有 入口组件的组件工厂解析器
- 具有合并模块的provider 的定义工厂
最后两个强调的点解释了为什么没有供应商和入口组件(entry components)的模块封装。 这是因为在编译后, 并不会有这几个模块。 你只会得到 合并的模块。并且在编译期间, 编译器并不会知道你在哪里以及如何使用这些provider 和 动态组件。 所以它不能控制封装。 但是当解析组件模板的时候, 这个信息可以得到, 它使能私有可声明的——组件, 指令和管道。
让我们来看个模块生成工厂的例子。假设你有 A
和 B
模块, 每个模块定义了 一个供应商和 一个 入口组件:
@NgModule({
providers: [{provide: 'a', useValue: 'a'}],
declarations: [AComponent],
entryComponents: [AComponent]
})
export class AModule {}
@NgModule({
providers: [{provide: 'b', useValue: 'b'}],
declarations: [BComponent],
entryComponents: [BComponent]
})
export class BModule {}
App
根模块也定义了一个供应商和 一个 根 app 组件, 并导入了 A
和 B
模块。
@NgModule({
imports: [AModule, BModule],
declarations: [AppComponent],
providers: [{provide: 'root', useValue: 'root'}],
bootstrap: [AppComponent]
})
export class AppModule {}
当编译器为 App
根模块生成模块工厂时, 它会将所有模块的供应商合并到一起, 然后只为这个合并的模块创建一个工厂。 这是这个工厂的样子:
createNgModuleFactory(
// 引用 AppModule
AppModule,
// 引用 AppComponent, 用于启动应用
[AppComponent],
// 模块定义, 含有合并的 provider
moduleDef([
...
// 引用组件工厂 解析器以及
//合并的 entry 组件
moduleProvideDef(512, jit_ComponentFactoryResolver_5, ..., [
ComponentFactory_<BComponent>,
ComponentFactory_<AComponent>,
ComponentFactory_<AppComponent>
])
// 引用合并的模块以及模块各自的provider
moduleProvideDef(512, AModule, AModule, []),
moduleProvideDef(512, BModule, BModule, []),
moduleProvideDef(512, AppModule, AppModule, []),
moduleProvideDef(256, 'a', 'a', []),
moduleProvideDef(256, 'b', 'b', []),
moduleProvideDef(256, 'root', 'root', [])
]);
你可以看到: 所有模块中的供应商和入口组件都被合并到了一起, 然后传给 moduleDef
函数。 所有不管你导入了多少模块, 只有一个具有合并的供应商的工厂 会被创建。 这个工厂用于使用它自己的注入器创建模块实例。 因为我们只会得到一个合并的模块, Angular 会使用这些供应商创建单个根注入器。
现在你可能会想知道如果定义了两个有相同供应商令牌的模块会发生什么?
首个规则: 定义在模块中的供应商总会胜过导入的其他模块中的 供应商。 开始我们的配置, 在根模块中定义 供应商 a
:
@NgModule({
...
providers: [{provide: 'a', useValue: 'root'}],
})
export class AppModule {}
检查下工厂:
moduleDef([
...
moduleProvideDef(256, 'a', 'root', []),
moduleProvideDef(256, 'b', 'b', []),
]);
你可以看到: 最终合并的模块工厂里包含来自 App
模块的供应商 {provide: ‘a’, useValue: ‘root’}
, 这个供应商 覆盖了 A
模块中的供应商, 因为他们使用了相同的令牌。
The second rule is that the provider from the last imported module overrides providers in the preceding modules expect for the importing module (follows from the first rule). Let’s again tweak our setup and define a provider on B module:
第二个规则: 最后导入模块的供应商会覆盖之前模块的供应商。让我们回到我们的配置, 在 B
模块中定义供应商 a
@NgModule({
...
providers: [{provide: 'a', useValue: 'b'}],
})
export class BModule {}
现在, App
模块以下面的顺序导入 A
和 B
模块:
@NgModule({
imports: [AModule, BModule],
...
})
export class AppModule {}
B
模块包含和 A
模块相同的 供应商。 让我们看下最终的工厂:
moduleDef([
...
moduleProvideDef(256, 'a', 'b', []),
moduleProvideDef(256, 'root', 'root', []),
]);
这证明了之前讲到的规则。 供应商中包含了 在B
模块中指定的值 b
。 现在让我们交换下模块的导入顺序:
@NgModule({
imports: [BModule, AModule],
...
})
export class AppModule {}
我们看到生成的工厂:
moduleDef([
...
moduleProvideDef(256, 'a', 'a', []),
moduleProvideDef(256, 'root', 'root', []),
]);
一切按照所预料的进行。因为我们交换了模块,现在 A
模块中的供应商覆盖了 B
模块中有着同样令牌的供应商。
懒加载的模块
现在又出现了个令人困惑的点 - 懒加载的模块。 这里是官方文档所讲的关于懒加载模块的内容:
Angular creates a lazy-loaded module with its own injector,
a child of the root injector…
So a lazy-loaded module that imports that shared module
makes its own copy of the service.
(译: Angular 所创建的 懒加载模块有自己的注入器, 这个注入器是根
注入器的子注入器。所以导入共享模块的懒加载模块会有服务的自己的副本)
所以我们知道了 Angular 会给懒加载的模块创建自己的注入器。之所以会发生这样的事情是因为 Angular 为每个懒加载的模块生成单独的工厂。这意味着在这些模块中定义的providers 并不会被合并到主模块的注入器中。
所以如果懒加载的模块定义了和根模块中 同样token的 provider, Angular会创建新的服务实例,即使在主模块注入器中已经有了一个。
所以懒加载的模块的确会创建层次, 但是这是注入器的层次, 不是模块的。所有导入的模块仍然会在编译期间合并到一个工厂, 和非懒加载的模块一样。这里是RouterConfigLoader 中的相关代码—— 加载懒加载模块,创建注入器层次:
export class RouterConfigLoader {
load(parentInjector, route) {
...
const modFactory = this.loadModuleFactory(route.loadChildren);
const module = modFactory.create(parentInjector);
}
private loadModuleFactory(loadChildren) {
...
return this.loader.load(loadChildren)
}
}
你可以看到这行:
const module = modFactory.create(parentInjector);
创建了加载的模块的新的实例, 父级注入器传入进去。
forRoot 和 forChild 静态方法
让我们来看官方文档讲的:
Add a CoreModule.forRoot method that configures the core UserService… Call forRoot only in the root application module,
Add a CoreModule.forRoot method that configures the core UserService… Call forRoot only in the root application module, AppModule
这是个合理的建议, 但是如果你不明白为什么那样做,你可能会最终有这样的配置
@NgModule({
imports: [
SomeLibCarouselModule.forRoot(),
SomeLibCheckboxModule.forRoot(),
SomeLibCloseModule.forRoot(),
SomeLibCollapseModule.forRoot(),
SomeLibDatetimeModule.forRoot(),
...
]
})
export class SomeLibRootModule {...}
每个导入的模块根本没有定义任何供应商。 在这里我没看到使用 forRoot的理由。 首先, 让我们看看我们为什么需要 forRoot这个方法.
当你引入模块时, 你通常会使用模块类的应用:
@NgModule({ providers: [AService] })
export class A {}
@NgModule({ imports: [A] })
export class B {}
所有在模块 A
上用这种方式定义的供应商都会被添加到根注入器中, 并且会被整个应用使用。 你已经知道了这为什么会发生——因为所有的模块供应商都被合并到一起, 我在第一节中已经描述了。
Angular 也支持另一个方式在模块中注册供应商。出了传入模块类应用,你可以传入实现了 ModuleWithProviders
接口的对象:
interface ModuleWithProviders {
ngModule: Type<any>
providers?: Provider[]
}
这里是对于我们上面的例子,我们如何使用这种方法:
@NgModule({})
class A {}
const moduleWithProviders = {
ngModule: A,
providers: [AService]
};
@NgModule({
imports: [moduleWithProviders]
})
export class B {}
相对于直接导入并使用对象引用 moduleWithProviders
, 在模块类上顶一个一个返回这个对象的静态方法更好。让我们将这个方法明明为 forRoot
, 然后重构我们的例子:
@NgModule({})
class A {
static forRoot() {
return {ngModule: A, providers: [AService]};
}
}
@NgModule({
imports: [A.forRoot()]
})
export class B {}
这仅仅是演示用途。这个简单的情况下, 没有必要定义 forRoot
方法并返回带有 供应商对象的模块, 因为两个模块都定义了同样的供应商集合。然而, 当我们想把供应商分开, 基于要导入的模块定义不同的供应商集合, 这就会变得有意义。
举个例子, 我们想为非懒加载模块提供全局的服务 A
, 为懒加载模块提供服务 B
. 现在, 使用上面的途径是有意义的。 我们将使用 forRoot
方法为非懒加载模块返回供应商, forChild
为懒加载模块返回供应商。
@NgModule({})
class A {
static forRoot() {
return {ngModule: A, providers: [AService]};
}
static forChild() {
return {ngModule: A, providers: [BService]};
}
}
@NgModule({
imports: [A.forRoot()]
})
export class NonLazyLoadedModule {}
@NgModule({
imports: [A.forChild()]
})
export class LazyLoadedModule {}
因为 非懒加载的模块被合并了, 你在 forRoot
中所指定的供应商会在整个应用中都可访问到。但是因为懒加载的模块由自己的注入器, 你在 forChild
中指定的供应商只能在这个懒加载的模块内可以访问到。
请注意: 你所使用的 返回 ModuleWithProviders
结构的方法名字可以是完全任意的。 我上面的例子中所用的 forChild
和 forRoot
仅仅是被Angular 团队建议的约定的名字, 并且被用于 RouterModule
的实现中。
回到我们上面的例子:
@NgModule({
imports: [
SomeLibCarouselModule.forRoot(),
SomeLibCheckboxModule.forRoot(),
...
为只定义被整个应用使用的供应商的模块实现 forRoot
方法是没有意义的, 在懒加载模块中并不会有特别的子集。 如果导入的模块中压根就没有定义任何供应商, 这会更加迷惑人。
只把 forRoot/forChild 用于将要同时被贪婪模块和懒模块导入的具有供应商的共享模块
还有个和 forRoot
forChild
相关的方法。 因为他们是很简单的方法, 调用它们的时候你可以传入任何选项或者附加供应商。 这里有个很好的例子,就是 RouterModule
. 它定义了 forRoot
方法, 这个方法可以携带额外的供应商和配置:
export class RouterModule {
static forRoot(routes: Routes, config?: ExtraOptions)
你传入到方法中的路由使用 ROUTES
令牌进行注册。
static forRoot(routes: Routes, config?: ExtraOptions) {
return {
ngModule: RouterModule,
providers: [
{provide: ROUTES, multi: true, useValue: routes}
你提供的作为第二个参数的选项用于配置其他供应商:
static forRoot(routes: Routes, config?: ExtraOptions) {
return {
ngModule: RouterModule,
providers: [
{
provide: PreloadingStrategy,
useExisting: config.preloadingStrategy ?
config.preloadingStrategy :
NoPreloading
}
正如你所看到的, RouterModule
利用了 forRoot
和 forChild
方法将供应商集合分开, 然后基于传入的选项进行配置。
模块缓存
在 stackoverflow 上偶尔会出现新的问题: 开发者会担心在懒加载和非懒加载模块中导入某个模块会造成运行时模块代码的重复。这是个可以理解的假设。但是没有必要担心, 因为所有现存模块加载程序都会缓存它们加载的模块。
当 SystemJS 加载模块时,会把模块放到缓存中。 下一次请求这个模块的时候, SystemJS就会从缓存中返回这个模块, 并不会进行额外的网络请求。这个过程发生在每个模块中。举个例子, 当你写 Angular 组件的时候, 你会从 angular/core 模块中导入组件装饰器:
import { Component } from '@angular/core';
在应用中你会引用这个模块很多次。但是 SystemJS 并不会每次都加载 angular/core 包。 SystemJS 只加载 angular/core 一次然后缓存这个包。
某些相似的事情同样会发生在 Webpack 中,如果你使用 angular-cli 或者 自己配置 webpack。在一个 包中, Webpack 只包含 模块代码一次, 然后给模块一个ID。 所有其他模块使用这个ID 从这个模块导入符号。