Angular 17+ 高级教程 – NgModule

前言

NgModule 在 Angular v14 以前是一门必修课。然而,自 Angular v14 推出 Standalone Component 以后,它的地位变得越来越边缘化了。

本教程从开篇到本篇,所有例子使用的都是 Standalone Component,一点 NgModule 的影子也没有😔。

但是!NgModule 还是有价值的,而且在越复杂的项目中你越可以感受到它的价值。

本篇,就让我们一起学习这个被遗忘了但其实很强大的 NgModule 吧🚀。

 

NgModule 有啥用?

NgModule 主要是用于 (组件 / 指令 / Pipe) 的管理。

是的,你没听错 -- 管理。

一个项目即使完全不使用 NgModule 也不会有什么功能做不出来。

用与不用只在管理上有区别,在功能上是没有区别的。

这也是为什么 NgModule 会越来越不被重视,毕竟项目只有复杂到一定程度才需要管理的丫。

 

使用 Standalone Component 管理组件 / 指令 / Pipe

Standalone Component 对组件 / 指令 / Pipe (简称组件) 的管理方式是这样的:

  1. 当一个组件想使用另一个组件时,就 import 它。

  2. 每一个组件都是公开的,意思就是任何一个组件都可以 import 任何一个组件。

Standalone Component 的管理方式简单明了,但在复杂项目中经常会遇到二个烦人问题。

A lot of component imports

比如说,我们有一个封装好的 table 组件,长这样

它不是简简单单的一个组件,要使用它还需要搭配一些相关的指令。

每一次要使用这个 table 组件,就需要 import 一个组件 +  5 个指令,总共 6 个 importable。

这 6 个 importable 是有密切关系的,但是从 imports 代码上完全看不出来。

有关联的东西,却没有被 group 在一起,这在管理上是扣分的。

或许你会想,为什么一个 table 组件需要配那么多指令,不可以一个 table 组件完事吗?

理论上是可以,但是管理上不行,因为 table 是一个复杂的组件,如果把所有逻辑都塞在一个地方,管理就会很乱。

软件工程的奥义就是把大的逻辑拆小,通过组合和关联把小的逻辑组装大。这样只要确保小的可运行,慢慢推导到大的也可以运行,软件就稳定了。

再看一个例子:

select 和 table 都遇到了相同的问题。

Private 组件

Standalone Component 的第二条规则:

每一个组件都是公开的,意思就是任何一个组件都可以 import 任何一个组件

表面上,select 组件是由 select, option, optgroup 组件组装而成。但或许 select 组件内还使用了其它的组件,只是对于使用者来说它被封装起来了而且。

如果说 select 组件内部所使用的组件也是公开的 (因为 Standalone Component 一定是公开的),这样就会对使用者没有约束,更正确的做法是引入 Private 概念。

也就是说,某些组件应该只能被某些组件使用,而不总是所有组件都可以使用。

 

使用 NgModule 管理组件 / 指令 / Pipe

创建一个项目

ng new module --routing=false --ssr=false --skip-tests --style=scss

Create NgModule

创建一个 NgModule 和一些组件

ng generate module dialog;
ng generate component dialog/dialog --standalone=false --module=dialog;
ng generate component dialog/dialog-public --standalone=false --module=dialog;
ng generate component dialog/dialog-private --standalone=false --module=dialog;

shortform 版本

ng g m dialog;
ng g c dialog/dialog --standalone false -m dialog;
ng g c dialog/dialog-public --standalone false -m dialog;
ng g c dialog/dialog-private --standalone false -m dialog;
View Code

这里有几个 CLI 小知识:

  1. dialog/dialog 是 path 的概念

    folder 结构是这样的

    dialog 组件要放进 dialog folder

  2.  --standalone=false

    组件要使用 NgModule 做管理,就不可以是 Standalone Component。

    Standalone 的意思是独立,也就是没人管。

  3. --module=dialog

    这句的意思是,这个组件交给 dialog module 管理。 

NgModule Definition

这是一个完整的 NgModule Definition

import { NgModule } from '@angular/core';
import { DialogComponent } from './dialog/dialog.component';
import { DialogPublicComponent } from './dialog-public/dialog-public.component';
import { DialogPrivateComponent } from './dialog-private/dialog-private.component';
import { OtherModule } from '../other/other.module';              // 其它 NgModule
import { StandaloneComponent } from '../standalone/standalone.component'; // 其它 Standalone Component

@NgModule({
  declarations: [DialogComponent, DialogPublicComponent, DialogPrivateComponent],
  imports: [OtherModule, StandaloneComponent],
  exports: [DialogComponent, DialogPublicComponent],
})
export class DialogModule {}

语法上看,NgModule 是一个 class with @NgModule Decorator,class 本身是空的,Definition 都写在 @NgModule Decorator 的参数对象里。

它有好几个规则,我们一个一个看:

  1. declarations

    declarations 表示这个 NgModule 负责管理的组件集合。

    集合内的组件可以直接相互使用对方,不需要再声明 @Component imports。

    举例:

    declarations: [DialogComponent, DialogPublicComponent, DialogPrivateComponent]

    在 Dialog Template 使用 DialogPrivate 组件

    Dialog 组件不需要 import DialogPrivate 组件。

  2. exports

    declarations 组件集合可以相互使用对方,但是集合外的其它组件都无法使用它们,也不能 import 它们,declarations 就是一个封闭的孤岛。

    要让外部的组件也能使用到 declarations 组件集合需要做两件事情。

    第一件:在 @NgModule Definition 声明 exports 

    举例:

    exports: [DialogComponent, DialogPublicComponent]

    没有 export 的组件称为 Privare,有 export 的称为 Public。

    第二件:在外部组件 (比如 App 组件) Definition import NgModule

    是 import NgModule 而不是 import 组件哦。

    App 组件 import DialogModule 后,App Template 就可以使用所有 DialogModule export 的组件了,没有 export 的组件依然无法使用。

  3. imports

    imports 表示这个 NgModule 依赖其它 NgModule 或组件 (Standalone Component)

    imports: [OtherModule, StandaloneComponent]

    DialogModule 所有 declarations 组件都可以使用 OtherModule export 的组件和 Standalone 组件。

  4. re-export

    我们除了可以 export declarations 组件集合,还可以 export import 的 NgModule 和组件 (Standalone Component)。

    import 了又 export 就叫 re-export。

    举例:

    imports: [OtherModule, StandaloneComponent],
    exports: [DialogComponent, DialogPublicComponent, OtherModule, StandaloneComponent],

    假如 App 组件 import 了 DialogModule,那 App Template 可以使用 Dialog 组件,DialogPublic 组件,OtherModule export 的组件,和 Standalone 组件。

    假如 OtherModule 也有 re-export 其它 NgModule,那 App 也可以使用到这个 NgModule export 的组件,以此类推一直到没有更多的 re-export。

通过以上几个规则,NgModule 就解决了 Standalone Component 的 2 大问题:

  1. A lot of component imports

    当 App Template 要使用 Dialog 组件集合时,它不需要把每一个 Dialog 相关的组件都写进 imports,它只需要写一个 DialogModule 到 imports 就可以了。干净👍

  2. Private 组件

    DialogPrivate 组件不是 Standalone Component,App 组件无法直接 import 它。

    DialogPrivate 也不在 DialogModule 的 exports list 里,即使 App 组件 import DialogModule 依然无法使用 DialogPrivate。

    只有 DialogModule declarations 组件集合才可以使用到 DialogPrivate。

 

NgModule 静态分析 & Compilation

上面我们提到的各种 NgModule 对组件管理的规则检测,并不是发生在 runtime,而是在 compilation 之前 Angular 就已经通过 Angular Language Service 做静态分析 (Static Program Analysis) 了。

规则 1:NgModule declarations 组件集合可以直接相互使用对方。

假设 DialogModule 没有 declare DialogPublic 和 DialogPrivate。

@NgModule({
  declarations: [
    DialogComponent,
    // DialogPublicComponent,
    // DialogPrivateComponent
  ]
})
export class DialogModule {}

当我们在 Dialog Template 尝试使用 DialogPublic 或 DialogPrivate 时,IDE 就会报错。

declare DialogPublic 和 DialogPrivate 组件

@NgModule({
  declarations: [DialogComponent, DialogPublicComponent, DialogPrivateComponent],
})
export class DialogModule {}

declare 后 error 就没有了

规则 2:NgModule 有 export 的才是 public 组件,没有 export 的是 private 组件

假设 DialogModule 没有任何 export

@NgModule({
  declarations: [DialogComponent, DialogPublicComponent, DialogPrivateComponent],
  exports: [
    // DialogComponent,
    // DialogPublicComponent
  ],
})
export class DialogModule {}

即便 App 组件 import 了 DialogModule 也无法使用任何 Dialog 组件。

@Component({
  selector: 'app-root',
  standalone: true,
  templateUrl: './app.component.html',
  styleUrl: './app.component.scss',
  imports: [DialogModule], // imported DialogModule
})
export class AppComponent {
  constructor() {}
}

error on App Template

三个组件都报错了。

export Dialog 组件 和 DialogPublic 组件。但没有 export DialogPrivate 组件。

@NgModule({
  declarations: [DialogComponent, DialogPublicComponent, DialogPrivateComponent],
  exports: [DialogComponent, DialogPublicComponent],
})
export class DialogModule {}

效果

只剩下 DialogPrivate 报错,因为它是 Private 组件。

规则 3:declarations 组件集合可以使用 NgModule import 的其它 NgModule 和组件 (Standalone)

假设有一个 Standalone 组件和一个 OtherModule

Standalone 组件

OtherModule

Dialog Template 想使用 Standalone 组件和 Other 组件

在 DialogModule import Standalone 组件和 OtherModule (Other 组件不是 Standalone Component,所以不可以直接被 import,只可以 import 它的管理负责人 -- OtherModule)

import 后,Dialog Template 就可以使用 Standalone 组件和 Other 组件了。

规则 4:NgModule re-export

假设 App 组件 import 了 DialogModule,App Template 想使用 Other 组件

DialogModule re-export OtherModule

re-export 后,App Template 就可以使用 Other 组件了。

NgModule After Compilation

我们来看看 compile 后的 NgModule 长啥样。

这是 DialogModule,它 export 了很多组件。

@NgModule({
  declarations: [DialogComponent, DialogPublicComponent, DialogPrivateComponent,
  ],
  exports: [DialogComponent, DialogPublicComponent, OtherModule],
  imports: [StandaloneComponent, OtherModule],
})
export class DialogModule {}

App 组件 import 了 DialogModule

@Component({
  selector: 'app-root',
  standalone: true,
  templateUrl: './app.component.html',
  styleUrl: './app.component.scss',
  imports: [DialogModule],
})
export class AppComponent {
  constructor() {}
}

App Template 使用了 Dialog 组件

<h1>Hello World</h1>
<app-dialog />

运行 compile

yarn run ngc -p tsconfig.json

app.component.js

App Definition 的 dependencies 有一个 DialogModule 和 DialogComponent。

dialog.module.js

虽然 DialogModule export 了很多组件,但由于 App Template 只用了其中的 Dialog 组件,所以 App Definition 只依赖了一个 Dialog 组件 (这一点和 import Standalone Component 的规则是完全一样的) 和 DialogModule。

而 DialogModule 里并没有任何 declarations 组件的信息。也就是说那些信息只用于 Angular Language Service 做静态分析而已,compile 后就不需要了。

两个结论:

  1. NgModule 管理组件只限于 Angular Language Service 静态分析,compile 以后它的行为和 Standalone Component 是完全一样的。

  2. 既然和 Standalone Component 一样,那为什么 compile 后还需要生成 DialogModule?而且 App Definition 的 dependencies 也包含了 DialogModule 丫?

    这是因为 NgModule 除了可以拿来管理组件,它还可以拿来搞 Dependency Injection Providers (为什么我用 "搞" 而不是 "管理"?因为我觉得它把东西越搞越乱就有😵)。

    这个部分下面会详解讲解。

 

我该使用 NgModule 管理组件吗?

成也萧何,败也萧何。NgModule 也不是没有弊端的,在使用 NgModule 前,我们应该要好好权衡它对项目的利与弊。

NgModule 的好,上面已经讲了,这里我们看看它的弊端。

用 Standalone Component 管理,我们可以看到组件跟组件的依赖关系。比如:

D 组件依赖了 A、B、C 组件。

在引入 NgModule 后,我们只能知道 Module 跟 Module 之间的依赖关系 ,我们再也看不到 D 组件依赖 A、B、C 组件了。

再比如,H 组件是否依赖了其它 7 个组件?我们也无从知晓 (除非你打开组件 Template 挨个挨个 selector 找匹配😵)

总结:

Standalone Component 颗粒度小,可以清楚知道组件对组件的依赖关系。它的好处是规则简单,有细节。坏处是多,乱。

NgModule 颗粒度大,只能知道 Module 对 Module 的依赖,Module 里组件的依赖关系则无从知晓。它的好处是有个上层管理,表面干净,坏处是部分细节丢失。

目前大部分 Angular 项目会采取 Standalone Component 优先策略,有需要时才使用 NgModule。整个项目属于混搭模式。

如果项目是 Library 类型 (比如 Angular Material),那会偏向 NgModule 多一些。

 

NgModule の DI Providers

NgModule 除了可以用来管理组件,它还可以用来管理 Provider。

不熟悉 Dependency Injection (DI) 的朋友,可以先看这两篇 Dependency Injection 依赖注入 和 Component 组件 の Dependency Injection & NodeInjector

我们学习过 3 种提供 Provider 的方式:

  1. 通过 @Injectable 的 providedIn

    或者 InjectionToken 的 providedIn

  2. 通过 ApplicationConfig.providers

  3. 通过组件 / 指令 Decorator 的 providers

使用 NgModule 提供 Provder 是我们将学习到的四种方式。

虽然它是第四种方式,但其实它诞生的比 providedIn 和 ApplicationConfig.providers 都要早,只是它落伍了,所以我们才一直不需要去学它。

NgModule providers 长这样

如果我们按照 NgModule 管理组件的方式去推测 NgModule 管理 Provider 的方式,那我们得出的结论应该是这样:

所有 declarations 的组件都可以注入 TestService,TestService 是 Private 的,除非我们 export TestService,否则外面其它组件都无法注入 TestService。

非常合理的推测,但是!事实并非如此🙄。

NgModule providers 的效果和 providedIn: 'root' 的效果,几乎一摸一样。

唯一的区别是 NgModule providers 需要一个 “激活动作”,这个 “激活动作” 就是 import NgModule。

NgModule providers 例子

我们来看一个例子和它涉及的源码。

创建一个 TestModule 和 TestService。

ng g m test;
ng g s test/test;

移除 TestService 的 providedIn: 'root' (默认会有)

添加 TestService 到 TestModule providers

创建一个 Child 组件

ng g c child

添加 Child 组件 到 App Template

App 组件 import Child 组件

在 Child 组件 inject TestService

结果报错了

因为 TestService 不是 providedIn: ‘root’,而 providedIn NgModule 需要一个 “激活动作” -- import NgModule。

在 Child 组件 import TestModule

效果

我们知道 providedIn: 'root' 是把 Provider 提供给 Root Injector。

那 NgModule 的 Provider 被提供给了哪一个 Injector 呢?

是 import NgModule 的 Child NodeInjector 吗?还是 Root Injector 呢?

我们来做个小测试:

在 App 组件也注入 TestService,但是不要 import TestModule。

效果

App 组件和 Child 组件都成功注入到了 TestService。即便只有 Child 组件 import 了 TestModule,但 App 组件同样能注入到 TestService。

好,现在我们看看 App 注入的 TestService 和 Child 注入的 TestService 是否是同一个引用,是的话就表示它们出自同一个 Injector,很可能就是 Root Injector。

App 组件注入 TestService 并且记入起来

Child 组件注入 TestService 和 App 组件,然后对比两个 TestService 对象引用。

效果

两个 TestService 果然是相同引用,证实了它们出自同一个 Injector。

总结:

  1. NgModule 在没有被 import 前,没有组件可以注入到 NgModule providers。

  2. 不管是哪一个组件 import 了 NgModule,NgModule providers 都被提供给了 Root Injector。

  3. Child import NgModule -> NgModule provide to Root Injector -> App inject from Root Injector

 

NgModule 源码逛一逛

依据我们的认知,Injector 的 providers 是不可能动态添加的,在创建 Injector 时 providers 一定已经准备好。

那 NgModule 是怎样依据有没有被 import 去决定是否提供 providers 给 Root Injector 的呢?

我们一起逛一逛它的源码🚀

首先 run compilation

yarn run ngc -p tsconfig.json

App Definition.dependencies 中有 Child 组件

这是因为 App Template 中有使用到 <app-child />

Child Definition.dependencies 中有 TestModule

这是因为 Child 组件有 import TestModule。

TestModule 的 static 属性 ɵinj 有包含 providers: [TestService] 的资讯。

上面是它们的关联,从 App -> Child -> TestModule -> TestService。

好,开始逛 NgModule 源码 🚀

不熟悉 Angular bootstrapApplication 源码的朋友,可以看这篇 Angular bootstrapApplication 源码逛一逛 复习一下。

我们直接跳到 step 2.5

在创建 Root Injector 之后,创建 ChainedInjector 之前。

ComponentFactory.create 方法的源码在 component_ref.ts

这个 App Definition.getStandaloneInjector 方法是在执行 ɵɵStandaloneFeature 函数时设定的。

ɵɵStandaloneFeature 函数的源码在 standalone_feature.ts

它内部注入了 StandaloneService 并且调用了 getOrCreateStandaloneInjector,看来这个方法便是我们正在找的主角了。

每一个 Standalone Component Definition 都会有这么一个 ɵɵStandaloneFeature。

ɵɵdefineComponent 函数中,ɵɵStandaloneFeature 会被执行。

焦点回到主角 StandaloneService.getOrCreateStandaloneInjector 身上。

internalImportProvidersFrom 函数的源码在 provider_collection.ts

walkProviderTree 函数的源码在 provider_collection.ts

递归执行第二轮 walkProviderTree 函数

递归执行第三轮 walkProviderTree 函数

流程总结:

  1. 创建 Root Injector

  2. 从 class AppComponent 递归到 class TestModule 收集出 App Standalone Injector 的所以 providers。

    App Definition.dependencies -> Child Definition.dependencies -> TestModule.ɵinj.providers-> TestService

  3. 创建 App Standalone Injector 

逻辑总结:

  1. 每一个组件 Template 里使用到的其它组件,都会被记入到组件 Definition.dependencies。

    比如:App Definition.dependencies = [ChildComponent]。

  2. 组件如果有 import Module,Module 也会被记入到组件 Definition.dependencies。

    比如:Child Definition.dependencies = [TestModule]。

  3. Module 如果有 import 其它 Module,它们会被记入到 Module.ɵinj.imports。

  4. Module 如果有 providers,它们会被记入到 Module.ɵinj.providers。

  5. 从 App Definition.dependencies 出发,递归一直找,可以找到:

    a. 整个项目所有用到的组件

    b. 所有组件 import 的 Module

    c. 所有 Module import 的 Module

    d. 所有 Module 的 providers

揭秘:

  1. 为什么 NgModule 需要被 import 后,providers 才能用?

    因为 App Standalone Injector 收集 providers 的递归查找的路线是 Component Definition.dependencies。

    如果 NgModule 没有被 import,它就不会出现在 dependencies 里。

    那查找过程就会找不到,找不到就不会被 provide to App Standalone Injector,也就 inject 不到了。

  2. NgModule providers 是给了哪一个 Injector?

    严格来说是 App Standalone Injector。

    App Standalone Injector 的 parent 才是 Root Injector。

    但通常我们不会刻意区分它们俩,所以把它理解为 NgModule providers is provide to Root Injector 也是可以的。

 

我该使用 NgModule 管理 Provider 吗?

Angular 官方文档

Angular 不推荐我们使用 NgModule 管理 Provider。有好几个原因:

  1. NgModule providers 对 Tree Shaking 不友好。

  2. NgModule 管理 Provider 和管理组件的逻辑不一致,这很容易造成混淆。

  3. providedIn: 'root' 完全可以取代 NgModule Provider,它俩唯一的区别是 NgModule 需要一个 “激活动作” -- import NgModule。

总结:除非你真的需要有一个 "激活" 概念来管理你的 Provider,否组请坚定的使用 providedIn: 'root' 就好。

 

当 NgModule 遇上 Dynamic Component 和 Dynamic Import (lazyload)

不熟悉 Dynamic Component 的朋友,可以看这篇 Component 组件 の Dynamic Component 动态组件 复习一下。

当 NgModule 遇上 Dynamic Component 和 Dynamic Import 时,会产生一系列的化学反应,我们需要搞清楚它们,不然一不小心就会掉坑了。

ng serve Compilation Error

理论上来讲,在 Compilation 后 NgModule (without providers) 和 Standalone Component 是一样的,所以即便遇上 Dynamic Component 也不应该会出现问题。

但是!我却遇到了 Compilation Error。提交了 Github Issue,不过 Angular Team 一如既往的没有给出任何解释,而我又懒惰翻 ngtsc 源码,所以这里记入一下就好呗。

我们来 reproduction 这个 Compilation Error。

首先,创建 TestModule、Test1 组件、Test2 组件

ng g m test;
ng g c test/test1 --standalone=false -m=test;
ng g c test/test2 --standalone=false -m=test;

在 Test2 Template 使用 Test1 组件

在 TestModule export Test1 和 Test2 组件

在 App 组件使用 createComponent 函数动态创建 Test2 组件。

ng serve 启动项目

竟然报错了😲 (注:这个错误来自 ngtsc 的 typecheck,执行 ng serve 或 ng build 都会报错,但执行 yarn run ngc -p tsconfig.json 则不会,我猜是因为 ngc 跳过了 typecheck)

其实只要 import test2.component 哪怕没有使用里面的 class Test2Component

import './test/test2/test2.component';

它就会报错了。

解决方法也很简单

import test.module,哪怕没有使用里面的 class TestModule

import './test/test.module';

它就不会报错了。

另外,如果 Test2 Template 没有使用 Test1 组件,那完全不会有 Compilation Error 的问题。

从这个解决方案,我们很难猜出它背后的原理,我又实在不想去翻 ngtsc 的源码...

所以大家记起来:每当你要动态创建一个由 NgModule 管理的组件时,你一定要先 import 它的 NgModule,这个 import 指的是 TypeScript import 而不是 @Component.imports 哦。

NgModule providers not provide to Root Injector

依据上一 part 我们观察的源码,NgModule providers 是在创建 App Standalone Injector 之前,

透过递归查找组件 Definition.dependencies 的方式进行收集的。然后,这些 providers 会被放入 App Standalone Injector。

试想想:

假如我有一个动态输出的组件 (Dynamic 组件),它 import 了带 providers 的 NgModule (TestModule provide TestService)。

由于它是 Dynamic Component,它不会对使用它的组件 (App 组件) 产生 Definition.dependencies。

这就导致在递归查找的过程中,TestModule providers 不会被发现,也不会被收集起来放入 App Standalone Injector。

最终 App 组件将无法注入到 TestService。

我们测试看看:

TestService

import { Injectable } from '@angular/core';

@Injectable()
export class TestService {}

TestModule

import { NgModule } from '@angular/core';
import { TestService } from '../test.service';

@NgModule({
  providers: [TestService],
})
export class TestModule {}

Dynamic 组件

import { Component, inject } from '@angular/core';
import { TestModule } from '../test/test.module';
import { TestService } from '../test.service';

@Component({
  standalone: true,
  imports: [TestModule], // 1. import TestModule
  templateUrl: './dynamic.component.html',
  styleUrl: './dynamic.component.scss',
})
export class DynamicComponent {
  constructor() {
    const testService = inject(TestService, { optional: true }); // 2. 注入 TestService
    console.log('testService', testService);
  }
}

App Template

<button (click)="append()">append</button>
<ng-container #container></ng-container>

App 组件

import { Component, Injector, ViewChild, ViewContainerRef, inject } from '@angular/core';
import { TestService } from './test.service';

@Component({
  selector: 'app-root',
  standalone: true,
  templateUrl: './app.component.html',
  styleUrl: './app.component.scss',
})
export class AppComponent {
  constructor() {
    // 1. 注入 TestService
    const testService = inject(TestService, { optional: true });
    console.log('App 组件尝试注入 TestService', testService);
  }

  @ViewChild('container', { read: ViewContainerRef })
  viewContainerRef!: ViewContainerRef;

  private readonly appNodeInjector = inject(Injector);

  async append() {
    // 2. 动态创建 Dynamic 组件
    console.log('create and append Dynamic 组件');
    const { DynamicComponent } = await import('./dynamic/dynamic.component');
    this.viewContainerRef.createComponent(DynamicComponent);

    setTimeout(() => {
      // 3. 再尝试注入 TestService
      const testService = this.appNodeInjector.get(TestService, null, {
        optional: true,
      });
      console.log('App 组件再一次尝试注入 TestService', testService);
    }, 1000);
  }
}

效果

App 组件始终无法注入到 TestService。

inject 查找图

在创建 Dynamic 组件过程中,递归查找 NgModule providers 再度发生,只是这一次收集到的 providers 是被放入到 Dynamic Standalone Injector 中。

蓝线是 Dynamic 组件 inject TestService 的查找过程,最终在 Dynamic Standalone Injector 找到 TestService。

红线是 App 组件 inject TestService 的查找过程,一直到 NullInjector 都没有找到 TestService,因为它的路线根本就连不到 Dynamic Standalone Injector。

总结:

  1. import NgModule 的组件如果不是 Dynamic Component,那 NgModule providers will provide to App Standalone Injector,全世界都可以 inject 到。

  2. import NgModule 的组件如果是 Dynamic Component,那 NgModule providers will provide to Dynamic Standalone Injector,只有 under Dynamic 组件才可以 inject 到。

Dynamic Import NgModule

我们的完整需求是:dynamic import and create 一个由 NgModule 管理的组件,并且这个组件内还要注入 NgModule Provider。

创建 DialogModule,Dialog 组件,DialogPublic 组件。

ng g m dialog;
ng g c dialog/dialog --standalone=false -m=dialog;
ng g c dialog/dialog-public --standalone=false -m=dialog;

在 Dialog Template 使用 DialogPublic 组件

我们不需要在 DialogModule export 任何组件,因为我们做的是 Dynamic Component (runtime),而 export 的目的只是为了让 Angular Langauge Service 做静态分析 (static analysis phase)。

App 组件

import { Component, ViewContainerRef, inject } from '@angular/core';
import './dialog/dialog.module'; // 1. 一定要先 import module,不然 ng serve 会报错!
import { DialogComponent } from './dialog/dialog/dialog.component';

@Component({
  selector: 'app-root',
  standalone: true,
  templateUrl: './app.component.html',
  styleUrl: './app.component.scss',
})
export class AppComponent {
  constructor() {
    const hostViewContainerRef = inject(ViewContainerRef);
    // 2. 动态创建和输出 Dialog 组件
    hostViewContainerRef.createComponent(DialogComponent);
  }
}

提醒:记得一定要 import dialog.module 哦

效果

接着创建 DialogService

ng g s dialog/dialog

去除 providedIn: 'root'

import { Injectable } from '@angular/core';

@Injectable()
export class DialogService {}

把 DialogService 添加 DialogModule providers

@NgModule({
  declarations: [DialogComponent, DialogPublicComponent],
  providers: [DialogService],
})
export class DialogModule {}

在 Dialog 组件 inject DialogService

export class DialogComponent {
  constructor() {
    const dialogService = inject(DialogService, { optional: true });
    console.log('Dialog 组件尝试注入 DialogService 的结构:', dialogService);
  }
}

效果

注入失败了,结果是 null,WHY?

依据我们的认知,DialogModule providers 应该会被 provide to Dialog Standalone Injector,然后 Dialog 组件就可以注入到 DialogService 了。

啊!Dialog 不是 Standalone Component,所以不会创建 Dialog Standalone Injector,而 NgModule providers 是在创建 Dialog Standalone Injector 时加进去的,所以注入会失败。

我们上一 part 的例子是:动态创建一个 Standalone Component,该组件 import 了带有 providers 的 NgModule。

而这一 part 的例子是:动态创建一个 NgModule 内的组件,该 NgModule 带有 providers。

这两个情况是不相同的。

怎么破?首先 import DialogModule

import { DialogModule } from './dialog/dialog.module';

接着 create NgModuleRef

export class AppComponent {
  constructor() {
    const environmentInjector = inject(EnvironmentInjector);
    const moduleRef = createNgModule(DialogModule, environmentInjector);
  }
}

createNgModule 函数的源码在 ng_module_ref.ts

它会返回一个 NgModuleRef,这个 NgModuleRef 类型,我们以前见过的。

NodeInjector 文章中,我们有一 part 逛了 Angular bootstrapApplication 源码

在 internalCreateApplication 函数中,创建完 Platform Injector 后,会创建 EnvironmentNgModuleRefAdapter

它就是一种 NgModuleRef

而 EnvironmentNgModuleRefAdapter.injector 就是 Root Injector。

好,回忆结束。

class NgModuleRef 的源码在 ng_module_ref.ts

createInjectorWithoutInjectorInstances 函数源码在 create_injector.ts

到这里,我们已经看到核心了。NgModuleRef.injector 是一个 R3Injector,它的 providers 便包括了 DialogModule providers。

我们做个测试,验证一下

export class AppComponent {
  constructor() {
    const environmentInjector = inject(EnvironmentInjector);
    const moduleRef = createNgModule(DialogModule, environmentInjector);
    const dialogService = moduleRef.injector.get(DialogService);
    console.log('get DialogService from moduleRef.injector:', dialogService);
  }
}

效果

接着用 Module Injector 作为 Dynamic Component 的 Environment Injector 即可。

export class AppComponent {
  constructor() {
    const environmentInjector = inject(EnvironmentInjector);
    const moduleRef = createNgModule(DialogModule, environmentInjector);
    const hostViewContainerRef = inject(ViewContainerRef);
    hostViewContainerRef.createComponent(DialogComponent, {
      environmentInjector: moduleRef.injector,
    });
  }
}

效果

注入成功🎉,我们再加上 Dynamic Import 看看

export class AppComponent {
  constructor() {
    const hostViewContainerRef = inject(ViewContainerRef);
    const environmentInjector = inject(EnvironmentInjector);
    Promise.all([
      import('./dialog/dialog.module'),
      import('./dialog/dialog/dialog.component'),
    ]).then(([{ DialogModule }, { DialogComponent }]) => {
      const moduleRef = createNgModule(DialogModule, environmentInjector);
      hostViewContainerRef.createComponent(DialogComponent, {
        environmentInjector: moduleRef.injector,
      });
    });
  }
}

总结

当 NgModule 遇上 Dynamic Component 会产生一些化学反应:

  1. 动态创建 NgModule 的组件时,记得要 import '/example.module.ts',不然 ng serve 会报错。

  2. 动态创建一个 Standalone Component,该组件 import 了带有 providers 的 NgModule,

    NgModule providers 将会 provide to Dynamic Standalone Injector 而不是 Root Injector。

  3. 动态创建一个 NgModule 内的组件,该 NgModule 带有 providers,

    我们需要 create NgModuleRef,把 NgModuleRef.injector 用于创建 Dynamic Component。

    这样 Dynamic Component 才能 inject 到 NgModule providers。

第二和第三条都和 NgModule providers 有关,所以奉劝大家谨用 NgModule providers。

 

Bootstrap Root NgModule

如果有一个项目,大部分的组件都使用 NgModule 做管理,只有少数的 Standalone Component,

那我们可能会为了要达到统一规范,硬硬替这些 Standalone Component wrap 上一层 NgModule。

像这样

ng g m test; 
ng g c test --standalone=false -m=test

一般的组件 wrap 一层 NgModule 不会有多大的影响,但是 App 组件可不同了,因为 App 组件是 Root Component 丫。

创建一个 NgModule 的项目。

ng new play-module --standalone=false --ssr=false --routing=false --skip-tests --st
yle=scss

关键是 --standalone=false

Different between NgModule and Standalone Component on bootstrap phase

NgModule 和 Standalone Component 项目在 boostrap 阶段有几个不同之处

下图是 Standalone Component 的 boostrap

下图是 NgModule 的 boostrap

我们稍做对比

bootstrapApplication 我们在 NodeInjector 文章中研究过,它前期的步骤如下:

  1. 创建 Platform Injector

  2. 创建 EnvironmentNgModuleRefAdapter (NgModuleRef)

    EnvironmentNgModuleRefAdapter.injector 就是 Root Injector

  3. 如果 App 是 Standalone Component 那就创建 App Standalone Injector

  4. ApplicationRef.bootstrap(AppComponent)

Platform Injector Phase

platformBrowserDynamic 函数主要负责创建 PlatformRef 和 Platform Injector

在进入 createPlatformFactory 函数前,我们先看 platformCoreDynamic 函数

再看 platformCore 函数

platformBrowserDynamic、platformCoreDynamic、platformCore 函数都是通过 createPlatformFactory 函数创建出来的,它们的区别只是名字和参数三的 providers。

createPlatformFactory 函数的源码在 platform.ts

createPlatform 相等于 bootstrapApplication 中用到的 createOrReusePlatformInjector

createPlatformInjector 在 bootstrapApplication 中也有用到

结论:platformBrowserDynamic 等同于 bootstrapApplication 的第一个阶段 -- 创建 Platform Injector。

NgModuleRef Phase

先看 AppModule 里面有什么,用在哪里

bootstrap: [AppComponent] 等同于 bootstrapApplication(AppComponent)

import BrowserModule 是必须的哦,BrowserModule provider 了 BROWSER_MODULE_PROVIDERS

BrowserModule 等同于 bootstrapApplication 中调用的 createProvidersConfig 函数,它们负责提供 BROWSER_MODULE_PROVIDERS。

PlatformRef.bootstrapModule 方法的源码在 platform_ref.ts

bootstrapModuleFactory

createNgModuleRefWithProviders 函数的源码在 ng_module_ref.ts

NgModuleRef 我们上面研究过,它可以递归找出 AppModule 所有的 providers 用于创建 NgModuleRef.injector。

结论:bootstrapModule 等同于 bootstrapApplication 的第二个阶段 -- 创建 EnvironmentNgModuleRefAdapter。

App Standalone Injector Phase

NgModule 没有这个阶段。

Standalone 阶段负责递归出 App 组件 import 的 NgModule providers 然后创建 App Standalone Injector。

NgModule 在上一个阶段已经做到了这一点,它递归了 AppModule providers 然后创建了 Root Injector (NgModuleRef.injector 和 EnvironmentNgModuleRefAdapter.injector 就是 Root Injector)

Boostrap AppComponent Phase

bootstrapModuleFactory 继续往下

_moduleDoBootstrap 方法

moduleRef._bootstrapComponents 就是 AppModule Definition.boostrap,也就是我们在 @NgModule 声明的 boostrap: [AppComponent]

结论:

bootstrapModuleFactory 上半部分负责创建 NgModuleRef 等同于 bootstrapApplication 的第二个阶段 -- 创建 EnvironmentNgModuleRefAdapter。

下半部分负责执行 ApplicationRef.boostrap 等同于 bootstrapApplication 的第四个阶段 -- ApplicationRef.bootstrap(AppComponent)。

 

总结

NgModule 替 Angular Learning Curve 贡献了很多坑,尤其是 NgModule 管理 Provider 的部分。

幸好,后来有了 providedIn: 'root' 和 Standalone Component,大家才可以脱离泥沼。

这篇比较深入的讲解了有重大设计缺陷的 NgModule providers 也是想让大家感受一下一个设计缺失可以造成多大的伤害,

还有 Angular Team 如何在这种情况下一点一点的矫正它。

 

目录

上一篇 Angular 17+ 高级教程 – Component 组件 の Control Flow

下一篇 Angular 17+ 高级教程 – HttpClient

想查看目录,请移步 Angular 17+ 高级教程 – 目录

 

posted @ 2024-02-06 17:21  兴杰  阅读(496)  评论(0编辑  收藏  举报