3 種方式都屬 Angular 官方作法

根據 依賴反轉原則 ,component 與 service,或 service 與 service 的相依,引僅限於 interface,而不該直接相依於另一個 service。但真正在 Angular 使用 interface 解耦合後,又會發現因為 JavaScript 天生沒有 interface,因此 TypeScript 與 Angular DI 必須在實務上妥協,本文整理出 3 種 Angular 官方認可 interface 注入 Object 方式。

Version


Node.js 8.9.3
Angular CLI 1.6.2
Angular 5.1.2

User Story


di001

  • counter 初始值為 2
  • +counter + 1,按 -counter -1

Task


di002

  • +- 放在 CounterComponent
  • counter 顯示仍在 AppComponent

Architecture


di000

  • CounterComponent 負責 +- 的 button;而 CounterService 負責計算 counter 與保存共用的 counter,為一 BehaviorSubject
  • 根據 依賴反轉原則CounterComponent 不應該直接相依於 CounterService,而是兩者相依於 interface
  • 根據 介面隔離原則ConterComponent 只相依於它所需要的 interface,因此以 CounterComponent 的角度訂出 ChangeCounterInterface,且 CounterSerive 必須實踐此 interface
  • 因為 CounterComponentCounterService 都相依於 ChangeCounterInterface,兩者都只知道 ChangeCounterInterface 而已,而不知道彼此,因此 CounterComponentCounterService 徹底解耦合
  • 透過 DI container 將實作 ChangeCounterInterfaceCounterService 注入進 CounterComponent

Implementation


change-counter.interface.ts

1
2
3
4
5
6
7
8
import { Observable } from 'rxjs/Observable';

export interface ChangeCounterInterface {
counter$: Observable<number>;
setInitialCount(initialValue: number);
addOne(): void;
minusOne(): void;
}

根據 依賴反轉原則介面隔離原則,我們訂出了 ChangeCounterInterface

app.module.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { CounterComponent } from './component/counter/counter.component';
import { CounterService } from './service/count/counter.service';
import { ChangeCounterInterface } from './interface/change-counter.interface';

@NgModule({
declarations: [
AppComponent,
CounterComponent
],
imports: [
BrowserModule
],
providers: [
CounterService,
{provide: ChangeCounterInterface, useExisting: CounterService}
],
bootstrap: [AppComponent]
})
export class AppModule { }

AppModule 需定義 interface 與 class 的對應關係。

18 行

1
{provide: ChangeCounterInterface, useExisting: CounterService}

當使用 ChangeCounterInterface 時,請 DI container 幫我們注入 CounterService

其中 provide 為 token,DI container 會以此 token 為識別。

di003

以一般使用 DI 的經驗,如此的設定完全合乎邏輯,但 Language Service 已經提出警告。

  1. 開啟 app.module.ts
  2. provide 使用的是 ChangeCounterInterface
  3. Language Service 已經提出警告,表示 ChangeCounterInterface 是個 value,而不是 type 型別

interface 在 TypeScript 的認知與傳統強型別語言不同,因為 JavaScript 沒有 interface,因此 TypeScript 的 interface 只拿來當 編譯檢查 用,不算是一個 type,而 provide 要求的是一個 type,如 class,interface 在此只當成 value 看待。

我們無法在 Angular DI 直接使用 interface 當 token,觀念上仍是 interface,但作法上必須有所妥協

String Token

Angular DI 雖然不允許我們用 interface 當 token,卻允許我們使用 string 當 token。

app.module.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { CounterComponent } from './component/counter/counter.component';
import { CounterService } from './service/count/counter.service';
import { ChangeCounterInterface } from './interface/change-counter.interface';

@NgModule({
declarations: [
AppComponent,
CounterComponent
],
imports: [
BrowserModule
],
providers: [
CounterService,
{provide: 'ChangeCounterInterface', useExisting: CounterService}
],
bootstrap: [AppComponent]
})
export class AppModule { }

18 行

1
{provide: 'ChangeCounterInterface', useExisting: CounterService}

provide 的 token 改成 ChangeCounterInterface 字串。

app.component.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { Component, Inject } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { ChangeCounterInterface } from './interface/change-counter.interface';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
initialCount = 2;
counter$: Observable<number> = this.counterService.counter$;

constructor(@Inject('ChangeCounterInterface') private counterService: ChangeCounterInterface) {
}
}

14 行

1
2
constructor(@Inject('ChangeCounterInterface') private counterService: ChangeCounterInterface) {
}

原本的 private counterService: ChangeCounterInterface 前面加上 @Inject() decorator,並傳入剛剛定義的 ChangeCounterInterface 字串。

原本 DI container 是以 constructor 參數的型別作為 DI 注入的依歸,但若加上 @Inject() decorator 後,會接管所有的 DI 型別,也就是改用 @Inject() 的參數作為 token。

剛剛在 AppModule 定義 ChangeCounterInterface string token,其對應的 class 為 CounterService,因此在 CounterComponent 會將 CounterService 注入。

優點

  1. 仍然可使用 interface

缺點

  1. String token 可能重複,尤其若跟 Angular 或 3rd Party 使用相同 string token 時,將會後蓋前
  2. 在 constructor 內必須加上 @Inject() decorator,程式碼比較冗長

Injection Token

change-counter.interface.ts 仍然保持不變,也就是仍然使用 interface。

injection-token.ts

1
2
3
4
import { InjectionToken } from '@angular/core';
import { CounterInterface } from './change-counter.interface';

export const CounterInterfaceToken = new InjectionToken<CounterInterface>('');

第 4 行

1
export const CounterInterfaceToken = new InjectionToken<CounterInterface>('');

有鑑於 string token 可能重複,因此 Angular 4 提出 InjectToken,簡單的說,就是由 string 換成 InjectionToken 物件,由於每次 new 的物件都不會重複,因此儘管 token 名稱相同,但實質上仍然是不同的物件。

InjectionToken 可帶泛型,也就是我們原本的 interface,初始值可帶一字串當 description,傳入空字串即可。

app.module.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { CounterComponent } from './component/counter/counter.component';
import { CounterService } from './service/count/counter.service';
import { CounterInterfaceToken } from './interface/interface-token';

@NgModule({
declarations: [
AppComponent,
CounterComponent
],
imports: [
BrowserModule
],
providers: [
CounterService,
{provide: CounterInterfaceToken, useExisting: CounterService}
],
bootstrap: [AppComponent]
})
export class AppModule { }

18 行

1
{provide: CounterInterfaceToken, useExisting: CounterService}

provide 由 string token 改成剛剛所建立的 InjectionToken

counter.component.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { Component, Inject, Input, OnInit } from '@angular/core';
import { CounterInterface } from '../../interface/change-counter.interface';
import { Observable } from 'rxjs/Observable';
import { CounterInterfaceToken } from '../../interface/interface-token';

@Component({
selector: 'app-counter',
templateUrl: './counter.component.html',
styleUrls: ['./counter.component.css']
})
export class CounterComponent implements OnInit {
@Input() initialCount;
counter$: Observable<number> = this.counterService.counter$;

constructor(@Inject(CounterInterfaceToken) private counterService: CounterInterface) {
}

ngOnInit() {
this.counterService.setInitialCount(this.initialCount);
}

onIncrementClick() {
this.counterService.addOne();
}

onDecrementClick() {
this.counterService.minusOne();
}
}

15 行

1
2
constructor(@Inject(CounterInterfaceToken) private counterService: CounterInterface) {
}

一樣使用 @Inject() decorator,只是帶入改換成 CounterInterfaceToken,不再是字串,而是個 InjectionToken 物件。

優點

  1. 仍然可使用 interface
  2. 每個 InjectionToken 都獨一無二,不再會有後蓋前的問題

缺點

  1. 在 constructor 內必須加上 @Inject() decorator,程式碼比較冗長

Abstract Class

change-counter.interface.ts

1
2
3
4
5
6
7
8
import { Observable } from 'rxjs/Observable';

export abstract class CounterInterface {
abstract counter$: Observable<number>;
abstract setInitialCount(initialValue: number);
abstract addOne(): void;
abstract minusOne(): void;
}

將原本的 interface 寫法,全部改用 abstract class 寫法。

Interface 與 abstract class,在 OOP 的觀念上,皆屬於 抽象,兩者地位幾乎一樣;但在 TypeScript,interface 僅用於 編譯檢查 用,並不會實際編譯成 JavaScript,而 abstract class 卻會編譯成對應的 JavaScript。

另外在 TypeScript,class 也可以被 implement ,稱為 class interface,這也是 TypeScript 與傳統 OOP 語言不同之處,因此我們可以借用 abstract class 當成 interface 被 service 拿去 implement

可自行到 TypeScript Playground 實際貼上 interface 與 abstract class 寫法,就會發現兩者的 JavaScript 是不一樣的

app.module.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';


import { AppComponent } from './app.component';
import { CounterComponent } from './component/counter/counter.component';
import { CounterService } from './service/count/counter.service';
import { CounterInterface } from './interface/change-counter.interface';

@NgModule({
declarations: [
AppComponent,
CounterComponent
],
imports: [
BrowserModule
],
providers: [
CounterService,
{provide: CounterInterface, useExisting: CounterService}
],
bootstrap: [AppComponent]
})
export class AppModule { }

20 行

1
{provide: CounterInterface, useExisting: CounterService}

因為改用 abstract class 後,此時已是個 type,可光明正大在 provide 當 token 使用。

counter.component.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { Component, Input, OnInit } from '@angular/core';
import { CounterInterface } from '../../interface/change-counter.interface';
import { Observable } from 'rxjs/Observable';

@Component({
selector: 'app-counter',
templateUrl: './counter.component.html',
styleUrls: ['./counter.component.css']
})
export class CounterComponent implements OnInit {
@Input() initialCount;
counter$: Observable<number> = this.counterService.counter$;

constructor(private counterService: CounterInterface) {
}

ngOnInit() {
this.counterService.setInitialCount(this.initialCount);
}

onIncrementClick() {
this.counterService.addOne();
}

onDecrementClick() {
this.counterService.minusOne();
}
}

14 行

1
2
constructor(private counterService: CounterInterface) {
}

不必使用 @Inject() decorator,DI container 會自動使用參數的型別作為 DI 注入的依據,這也於其他 OOP 語言的經驗一樣。

優點

  1. 不必使用 InjectionToken
  2. 不必使用 @Inject() decorator,用法與一般 OOP 寫法一樣

缺點

  1. 必須由 interface 改用 abstract class
  2. abstract class 可以被 implement,與一般 OOP 的觀念不一樣

Conclusion


  • 3 種寫法都在 Angular 官網出現過,都屬官方寫法
  • 基於程式碼的精簡,個人偏好第三種寫法,也就是將 interface 改成 abstract class,而不必使用 string token 或 InjectionToken

Sample Code


完整的範例可以在 GitHub上找到

2018-01-09