NgModule 的作用域

Oct 16, 2017 · #angular

前言

在开始聊这个话题之前,先提一个我遇到过问题:假设有一个SpinnerService,这是一个可以在进行发送 HTTP 请求等异步操作的显示一个加载动画的 Service。

这样看来,它应该是每个 Module 和 Component 中都可能用到的一个 Service,那么我们把放到SharedModuleproviders 当中。然后在需要这个SpinnerService 的 Module 中导入这个SharedModule 即可。代码看起来大概是这样的:

// app/shared/spinner.service.ts
@Injectable()
export class SpinnerService {
  show() {
    /* code logic */
  }
  hide() {
    /* code logic */
  }
}

// app/shared/shared.module.st
@NgModule({
  providers: [SpinnerService]
  // ... other staffs
})
export class SharedModule {}

// app/some/some.module.ts
@NgModule({
  imports: [SharedModule]
  // ... other staffs
})
export class SomeModule {}

代码看起来没什么问题,而且也能正常运行。但是如果你在两个不同的 Component 中分别调用SpinnerService 中的show()hide() 函数,你会发现加载动画没有如愿的消失;更近一步,如果在 Service 中有共用的数据的,你会发现两个不同的 Component 中对数据的修改也无法同步。Component 中的 Service 是由 Module 提供的,所以问题就出在 Module 上。

有的读者的可能已经猜到了:虽然两个 Module 中会提供一个SpinnerService,但是他们不是同一个实例。

Dependency Injection

在继续解释为什么不是同一个实例的之前,我想先粗略地说一下为什么应该会是同一个实例。这就涉及到一个概念:Dependency Injection,动态注入。

以 angular.io 上的例子为例:假设有一个Car 类,其依赖一个Engine 类。

于是你可以这么写(不使用 Dependency Injection):

export class Car {
  public engine: Engine;
  constructor() {
    this.engine = new Engine();
  }
}

当然也可以这么写(使用 Dependency Injection):

export class Car {
  constructor(public engine: Engine) {}
}

两者看起来区别不大,而且当需要调用engine 的时候,用法也是相同的。但是后者的好处却有不少:

  1. EngineCar 的结构是完全独立的:传入Car 中的是Engine 的一个实例而不是在Car 的新建这个实例。所以如果Engine 的构造函数发生变化的时候也不需要修改Car 中的构造函数。

  2. 因为传入Car 中的是一个实例,所以可以复用这个实例,减少内存占用。

  3. 因为传入的只是一个实例,所以甚至可以传入一个虚假用于单元测试的Engine

Angular 通过 Hierarchical Dependency Injectors 实现了上述的 Dependency Injection。Hierarchical Dependency Injectors 具体的实现,例如 Service 的复用,Component的 Injectors 等都是很值得一说的,不过和本文相关性不大,这里按下不表。

Providers & Declarations

我们知道一个 Module 中可以有 Components、Directives、Pipes 和 Services。前三者都是与 HTML 模板相关的,需要放在在 Module 的declarations 中;后者一般用来处理数据,放在providers 中。

那么providersdeclarations 的区别是什么呢?其中一个就是两者的作用域不同:providers 中是全局的;而declarations 则是本地的,只有该 Module 中可以使用。

Services 为什么会是全局的呢?其实很好理解,因为 Services 经过编译之后最后都是生成一个类,在导入或是导出它们的时候都会限定在命名空间内。

所以,Services 应该只在providers 中出现一次;而其他三者则是在需要的 Module 的declarations 地方都出现。

当然,在所有出现的地方都需要写一遍也是非常烦杂。这时我们就可以把他们放到一个SharedModuledeclarationsexports,最后在需要的 Module 中导入该SharedModule 即可。

对应到上面的问题,你会发现确实也只有在SharedModuleproviders 中出现了SpinnerService 呀,但是问题还是存在啊。

其实不止SharedModule,导入了SharedModuleSomeModule 相当于也提供了SpinnerService。所以在两个 Module 中就会有两个由不同的 Module 提供的,两个不同的SpinnerService

导入拥有提供了 Services 的 Module,相当与自己提供了相同的 Services。这样的例子这样的情况你可能早就接触过了:当你在AppModule 中导入了HttpModule 之后,你就可以使用Http 这个全局 Service 来发送 HTTP 请求了。

这里的AppModule 指 Root Module,下同。

另一方面,如果一个 Module 既有 Components 也有 Services 时则需要分别对待了:在AppModule 中导入这个 Module 的时候需要调用forRoot(),它返回的是一个ModuleWithProviders;而在其他的 Module 则是直接导入这个 Module 或者调用forChild()。例如RouterModule 就既有 Component<router-outlet> 和 DirectiverouterLink,也有 ServiceActivatedRoute

Best Practice

至此,要解决文章开头的问题可以很简单:将SpinnerService 放到AppModuleproviders 里即可。

但是,这样的简单粗暴地将每一个 Service 都交由AppModule 提供的解决方法违反了我们一贯的原则:尽可能保持每个 Moudle 的功能和结构简单。

所以,我们确实应该将SpinnerService 移出SharedModule,然而也不应该放进AppModule 而是可以考虑放进一个新建的CoreModule 中。而这个CoreModule 也应该作为一个纯粹的只提供 Services 的 Module,而只在AppModule 中导入它。

当然,因为只在AppModule 中导入,所以如果有一些只需要在AppComponent 中使用的 Component,如NavComponentFooterComponent 等也可以考虑放到其中。

References

  1. 文章中提到了可以使用一个虚假的 Service 用于 Component 的单元测试,这里介绍了具体应该怎么做。

  2. Angular 的 Hierarchical Dependency Injectors 系统,这是一个很有趣的系统,每一个 Component 都有一个与之对应的可编辑的 Injector。具体可以查看的 Angular 的官方文档:Hierarchical Dependency Injectors

  3. 写 Angular 应用的一个原则都是保持每一个 Module 的功能和结构的简单和统一,这一点和 Unix 的哲学不谋而合:Write programs that do one thing and do it well.那么我们怎么应该这么设计一个好的 Module 呢?Angular 官方的 NgModule FAQs 中其实给出了答案。从中我们可以看出,CoreModule 这种只提供 Services 和SharedModule 这种只提供 Components,Directives 和 Pipes 的 Module 是目前来说官方认为最好的设计。

Prev Spacemacs 和 Org-mode 和 LaTeX
Next 2017 HTTPS 调查