Angular依賴注入:全面講解(翻譯中)

在實際使用Angular依賴注入系統時,你需要知道的一切都在本文中。我們將以實用易懂並附帶示例的形式解釋它的所有高級概念。

Angular最強大、最獨特的功能之一就是它內置的依賴注入系統。

大多數時候,依賴注入就那麼工作著,我們使用它,幾乎不會想到要歸功於它的便利且直觀的Angular API。

但也有些時候,我們也許需要深入研究一下依賴注入系統,手動配置它。

這種對依賴注入的深入了解,在下面的情況下是必須的:

  • 為了解決一些古怪的依賴注入錯誤
  • 為單元測試手動配置依賴
  • 為了理解某些第三方模組的不尋常的依賴注入配置
  • 為了創建一個能夠在多個應用中裝載跟使用的第三方模組
  • 為了以一個更加模組化的方式來設計你的應用
  • 為了確保你的應用中的各個部分很好的互相獨立,不影響彼此

在本指南中,我們將準確理解Angular依賴注入是如何工作的,我們將涵蓋它的所有配置項,並學習何時以及為什麼使用每個特性。

我們將以一種非常實用並易於理解的方法來實現這一點,從頭開始實現我們自己的provider和injecttion token。作為一個練習,並使用基於示例的方式涵蓋所有的特性。

隨著時間的推移,作為Angular開發著,對Angular依賴注入系統的深入理解對你來說將是非常彌足珍貴的。

內容一覽

在本文中,我們將覆蓋一下主題:

  • 對依賴注入的介紹
  • 如何從頭開始在Angular中設置依賴注入
  • 什麼是Angular依賴注入提供商(provider)?
  • 如何編寫我們自己的提供商?
  • 對注入令牌的介紹
  • 如何手動配置一個提供商?
  • 使用類名作為注入令牌
  • 提供商的簡化配置:useClass
  • 理解Angular的多值依賴
  • 何時使用提供商useExisting
  • 理解Angular的分層依賴注入
  • 分層依賴注入的優勢是什麼
  • 組建分層依賴注入 - 一個示例
  • 模組分層依賴注入 - 一個示例
  • 模組依賴注入vs組建依賴注入
  • 配置依賴注入解決機制
  • 理解@Optional裝飾器
  • 理解@SkipSelf裝飾器
  • 理解@Self裝飾器
  • 理解@Host裝飾器
  • 什麼是可樹抖動(Tree-Shakeable)的提供商?
  • 通過一個示例理解可樹抖動的提供商
  • 總結

本文是我們正在進行的Angular核心特性系列的一部分,你可以從這裡找到所有的文章。

那麼話不多說,讓我們開始學習關於Angular依賴注入的所有必須知道的內容吧!

對依賴注入的介紹

那麼依賴注入具體是什麼呢?

當你正在開發系統中的一個更小的部分時,比如一個模組或者一個類,你將需要一些外部的依賴。

舉個例子,像其他的依賴一樣,你可能需要一個HTTP服務去做調用後端。

每當需要它們的時候,你也許甚至會嘗試在本地創建屬於你自己的依賴。像下面這樣:

export class CoursesService() {

   http: HttpClient;

   constructor() {
     this.http = new HttpClient(... dependencies needed by HTTPClient ...);
   }
...
}

01.ts

這看起來像是一個簡單的解決辦法,但是這段程式碼有個問題:非常難於測試。

因為這段程式碼知道本身的依賴,並直接創建了它們。你無法將實際的HTTP客戶端替換為一個模擬HTTP客戶端,也無法替換為單元類。

注意這個類不僅知道如何創建自己的依賴,還知道它的依賴的依賴,意味著它也知道HTTPClient的依賴。

按照這段程式碼的寫法,基本上無法在運行時將這個依賴替換為其他可選內容,比如:

  • 出於測試目的
  • 也因為你可能需要在不同的運行時環境中使用不同的HTTP客戶端,比如在伺服器上和在瀏覽器中。

把這個跟相同類的一個變更版本進行比較,它用了依賴注入:

@Injectable()
export class CoursesService() {

   http: HttpClient;

   constructor(http: HttpClient) {
     this.http = http;
   }
...
}

02.ts

正如你在這個新版本中看到的,這個類無法知道如何創建它的http依賴。

這個新版的類簡單地從構造函數的輸入參數中接受它所需要的所有的依賴,就是這樣!

這個新版本的類只是知道如何使用它的依賴去實現具體的任務,但是它不知道依賴的內部是如何工作的,依賴是如何被創建的,也不知道依賴的依賴是什麼。

用於創建依賴的程式碼已經從當前類中移除,被放在了你程式碼庫的某個地方,歸功於@Injectable()裝飾器的使用。

有了這個新的類,可以非常簡單地:

  • 為了測試的目的,替換某個依賴的實現
  • 支援多運行時環境
  • 在使用了你的服務作為第三方的程式碼庫中,提供服務的新版本···

這種只從輸入中接收你的依賴,不知道它們內部是如何工作,以及如何創建的技術,就叫做依賴注入,它是Angular的基礎。

現在讓我們來學習Angular依賴注入具體是如何工作的。

如何從頭開始在Angular中設置依賴注入?

理解Angular中依賴注入的最好的方法,就是從頭開始,取一個簡單的TypeScript類,不應用任何的裝飾器到該類,然後手動把它轉變為Angular可注入服務。

比聽起來還要簡單。

讓我們從一個簡單的服務類開始,沒有任何的@Injectable()裝飾器應用到該類:

export class CoursesService() {

   http: HttpClient;

   constructor(http: HttpClient) {
     this.http = http;
   }
...
}

03.ts

我們可以看到,這不過是一個簡單的TypeScript類,它期望從構造函數中注入一些依賴。

但其實這個類根本沒有途徑聯結到Angular依賴注入系統。

讓我們來看下,把這個類當作一個依賴注入到另外一個類中,會發生什麼:

@Component({
    selector: 'course-card',
    templateUrl: './course-card.component.html',
    styleUrls: ['./course-card.component.css']
})
export class CourseCardComponent  {

    constructor(private coursesService: CoursesService) {
      ...
    }
    ...
}

04.ts

我們可以看到,我們嘗試注入這個類的一個實例當作依賴。

但是我們的類沒有聯結到Angular的依賴注入系統,所以我們程式中的哪一塊會知道如何通過調用CoursesService構造函數來創建這個類的實例,然後當作依賴傳入呢?

答案很簡單:不可能!而且我們會得到一個錯誤!

 NullInjectorError: No provider for CoursesService!

注意錯誤資訊:很明顯缺少某個被稱為provider的東西。

你可能之前見過類似的資訊,在開發中時有發生。

現在讓我們來理解一下這個資訊具體是什麼意思,以及如何解決它。

什麼是Angular依賴注入提供商?

錯誤資訊「沒有提供商」僅僅表示Agnular依賴注入系統無法實例化一個給定的依賴,因為它不知道如何創建它。

為了讓Angular知道如何創建一個依賴,像是比如在CourseCardComponent的構造函數中注入CoursesService的實例,它需要知道什麼可以被稱為提供商工廠函數。

提供商工廠函數就是一個簡單的函數,Angular可以調用它來創建依賴,很簡單:它就是一個函數。

那個提供商工廠函數可以使用我們即將談到的一些簡單的約定方式被Angular隱式創建。這個實際上就是通常我們的大部分依賴發生的情況。

不過根據需要,我們也可以自主編寫那個函數。

在任何情況下,必須要理解的是,在你應用程式的每一個依賴中,讓它成為一個服務,一個組件,或者其他什麼,在某個地方有一個簡單的函數正在被調用,它知道如何創建你的依賴。

如何編寫我們自己的提供商?

為了真正理解一個提供商是什麼,讓我們為CoursesService類編寫我們自己的提供商工廠函數:

function coursesServiceProviderFactory(http:HttpClient): CoursesService {
  return new CoursesService(http);
}

05.ts

正如你所看到的,這就是一個普通的函數,它接收CoursesService需要的任何依賴項作為輸入。

這個提供商工廠函數接著手動調用CoursesService的構造函數,傳入所有需要的依賴項,然後返回CoursesService的新的實例作為輸出。

那麼任何時候Angular的依賴注入系統需要一個CoursesService的實例,它僅僅只需要調用這個函數!

這看起來很簡單,但問題是Angular依賴注入系統暫時還不知道這個函數。

更為重要的是,即使Angular知道這個函數,它如何會知道要調用它去注入這個特殊的依賴項呢:

@Component({
    selector: 'course-card',
    templateUrl: './course-card.component.html',
    styleUrls: ['./course-card.component.css']
})
export class CourseCardComponent  {

    constructor(private coursesService: CoursesService) {
      ...
    }
    ...
}

04.ts

我的意思是,沒法讓Angular將被注入的CoursesService的實例跟這個提供商工廠函數關聯起來,對吧?

介紹注入令牌

那麼Angular如何知道在哪裡注入什麼,還有提供商工廠函數要調用什麼去創建哪個依賴項?

Angular需要能夠以某種方式歸類依賴項,為了識別一個給定的依賴項集合是屬於同樣類型。

為了獨一無二地識別一組依賴項,我們可以定義一些東西當作Angular的注入令牌。

下面是我們如何為我們的CoursesService依賴項手動創建我們的注入令牌:

export const COURSES_SERVICE_TOKEN = 
      new InjectionToken<CoursesService>("COURSES_SERVICE_TOKEN");

06.ts

這個注入令牌對象將在依賴注入系統中被用來明確地識別我們的依賴項CoursesService。

這個依賴注入令牌是一個對象,所以它是獨一無二的,不像比如說字元串。

因此,可以使用這個令牌對象來唯一地識別一組依賴項。

那麼我們如何使用它呢?

如何手動配置一個提供商?

現在我們已經擁有了提供商工廠函數還有注入令牌,我們可以在Angular依賴注入系統中配置一個提供商,它會知道如何根據需要創建CoursesService的實例。

提供商本身就是一個簡單的配置對象,我們把它傳給一個模組或者組件的providers數組中:

@NgModule({
  imports: [
    ...
  ],
  declarations: [
    ...
  ],
  providers: [
      {
      provide: COURSES_SERVICE_TOKEN,
      useFactory: coursesServiceProviderFactory,
      deps: [HttpClient]
    }
    ]
})
export class CoursesModule { }

07.ts

正如我們所見,這個手動配置的提供商需要定義下列項:

  • useFactory:它應該包含對提供商工廠函數的一個引用,當需要創建依賴項和注入它們的時候,Angular會調用這個提供商工廠函數
  • provide:它包含了關聯到這種依賴項的注入令牌。注入令牌會幫助Angular決定何時使用或不使用一個給定的提供商工廠函數
  • deps:這個數組包含了任何useFactory函數需要運行的依賴項,在這個例子中是HTTP client

那麼現在Angular知道了如何創建CoursesService的實例,對吧?

讓我們看看假如我們嘗試注入一個CoursesService的實例到我們的應用程式中,會發生什麼:

@Component({
    selector: 'course-card',
    templateUrl: './course-card.component.html',
    styleUrls: ['./course-card.component.css']
})
export class CourseCardComponent  {

    constructor(private coursesService: CoursesService) {
      ...
    }
    ...
}

04.ts

我們也許有點驚訝,看到同樣的錯誤又發生了:

NullInjectorError: No provider for CoursesService!

那麼這裡發生什麼了?我們不是剛剛定義了提供商嗎?

對,我們是定義了提供商,但是當Angular試圖創建這個依賴項時,它無法知道它是否需要使用我們特定的提供商工廠函數,對吧?

那麼我們如何做出那個關聯呢?

我們需要顯式地告訴Angular它應該使用我們的提供商來創建這個依賴項。

我們可以在任何CoursesService被注入的地方使用@Inject注釋來做這個:

@Component({
    selector: 'course-card',
    templateUrl: './course-card.component.html',
    styleUrls: ['./course-card.component.css']
})
export class CourseCardComponent  {

    constructor( @Inject(COURSES_SERVICE_TOKEN) private coursesService: CoursesService) {
      ...
    }
    ...
}

08.ts

正如我們所看到的,顯式使用@Inject裝飾器允許我們告訴Angular,為了創建這個依賴項,它需要使用關聯到COURSES_SERVICE_TOKEN的指定的提供商。

這個注入令牌從Angular的視角,獨一無二地識別出一個依賴項類型,這就是依賴注入系統如何知道使用哪個提供商的。

因此現在Angular知道了調用哪個提供商工廠函數去創建正確的依賴項,它就是這樣做了。

然後有了這些,我們的應用程式現在正確地工作著,不再有錯誤了!

我想現在你對Angular的依賴注入系統是如何工作的應該有了一個很好的理解,不過我猜你可能會在想:

但是為什麼我從來沒有手動配置過提供商呢?

你看,即使一般你不必自己手動配置提供商工廠函數或者注入令牌,這些其實都在底層發生著。

對於你的應用程式中每個單獨的依賴項類型而言,服務、組件後者其他的,永遠都會有一個提供商,並且永遠都有一個注入令牌,後者其他的機制來獨一無二地識別一個依賴類型。

這是有意義的,因為你的類的構造函數需要在你的系統的其他地方被調用,Angular總是需要知道創建哪個依賴項,對吧?

因此即使當你用簡化的方式來配置你的依賴項的時候,底層永遠都有一個提供商。

為了更好地理解這點,讓我們逐漸簡化我們提供商的定義,一直到我們碰到那些你更加熟悉的地方。

使用類名作為注入令牌

Angular依賴注入系統的最有趣的特性之一,就是你可以使用在JavaScript運行時能保證唯一的任何事物,來識別一個依賴項類型,它不必非要是一個顯式的注入令牌對象。

舉例來說,在JavaScript的運行時,構造函數用來代表類名,指向某個函數的引用比如說它的名字,被保證在運行時是唯一的。

類名可以在運行時被它的構造函數唯一地表示,因為它保證是唯一的,它可以被用來作為注入令牌。

因此我們可以利用這個強大的特性,稍微簡化我們提供商的定義:

@NgModule({
  imports: [
    ...
  ],
  declarations: [
    ...
  ],
  providers: [
      {
      provide: CoursesService,
      useFactory: coursesServiceProviderFactory,
      deps: [HttpClient]
    }
    ]
})
export class CoursesModule { }

09.ts

正如我們所見,我們手動創建的用來識別我們的依賴項類型的注入令牌COURSES_SERVICE_TOKEN,已經不再需要了。

其實,我們已經把那個對象從我們的程式碼庫中一併移除了,因為在服務類的特定用例中,我們可以使用類名本身來識別依賴項類型!

不過如果不做更多修改,我們嘗試運行程式的話,我們可能又會得到錯誤no provider。

為了再次正常工作,你還需要使用CoursesService的構造函數來識別你需要哪個依賴項:

@Component({
    selector: 'course-card',
    templateUrl: './course-card.component.html',
    styleUrls: ['./course-card.component.css']
})
export class CourseCardComponent  {

    constructor( @Inject(CoursesService) private coursesService: CoursesService) {
      ...
    }
    ...
}

10.ts

然後有了這個,Angular知道了要注入哪個依賴項,一切又如期工作了!

所以好消息是,在大多數情況下,我們無需顯式地創建一個注入令牌。

現在讓我們來看下我們如何進一步地簡化我們的提供商。

簡化提供商的配置:useClass

不同於使用useFactory顯式地定義一個提供商工廠函數,我們有其他辦法告訴Angular如何實例化一個依賴項。

在提供商的情況下,我們可以使用useClass屬性。

在這種方式下Angular會知道我們傳入的值是一個合法的構造函數,Angular可以簡單地使用new操作符來調用它:

@NgModule({
 imports: [
   ...
 ],
 declarations: [
   ...
 ],
 providers: [
     {
     provide: CoursesService,
     useClass: CoursesService,
     deps: [HttpClient]
   }
   ]
})
export class CoursesModule { }

11.ts

這已經相當地簡化了我們的提供商,因為我們不需要自己手動編寫一個提供商工廠函數。

useClass的另外一個超級便利的特性就是,對於這個依賴項類型,基於TypeScript的類型注釋,Angular會在運行時推斷注入令牌。

這意味著,有了useClass依賴項,我們甚至不再需要Inject裝飾器,這可以為什麼你極少看到它:

@Component({
    selector: 'course-card',
    templateUrl: './course-card.component.html',
    styleUrls: ['./course-card.component.css']
})
export class CourseCardComponent  {

    constructor(private coursesService: CoursesService) {
      ...
    }
    ...
}

12.ts

那麼Angular是如何知道注入哪個依賴項的呢?

Angular可以通過檢查被注入屬性的類型來決定,這裡是CoursesService,然後使用那個類型為那個依賴項決定一個提供商。

正如我們所見,類依賴項使用起來更為方便,相對於不得不顯示地使用@Inject!

在useClass提供商的特定情況下,我們可以更加簡化這一切。

無需手動定義提供商對象,我們可以簡單地傳入類本身的名字作為合法的提供商配置項:

@NgModule({
  imports: [
    ...
  ],
  declarations: [
    ...
  ],
  providers: [
    CoursesService
  ]
})
export class CoursesModule { }

14.ts

Angular會確定這個提供商是一個構造函數,因此Angular會檢查這個函數,它會創建一個工廠函數確定必要的依賴項,然後根據需要創建這個類的實例。

這是基於函數的名稱隱式地發生的。

這是你通常在大多數情況下用到的記號方法,它超級簡單易用!

有了這個簡化的記號方法,你甚至不會意識到在幕後有提供商和注入令牌。

不過要注意,僅僅像這樣設置你的提供商是不會工作的,因為Angular不會知道如何查找這個類的依賴項(記住屬性deps)。

為了讓它工作,你仍然需要應用Injectable()裝飾器到這個服務類中:

@Injectable()
export class CoursesService() {

   http: HttpClient;

   constructor(http: HttpClient) {
     this.http = http;
   }
...
}

15.ts

這個裝飾器將會告訴Angular通過在運行時檢查構造函數參數的類型來嘗試查找該類的依賴項!

因此正如你所見,這個相當簡化了的記號方法,就是我們通常使用Angular依賴注入系統的方式,甚至沒有考慮到在底層使用的具體細節。

有件事要記住,就是useClass選項將不會與介面名稱工作,它只工作於類的名稱。

這是因為介面只是TypeScript語言的僅在編譯時的結構,因此介面不會存在於運行時。

這意味著介面名稱,不像類名(通過它的運行時構造函數),不會被用來唯一地識別依賴項類型。

除了提供商、依賴項和注入令牌的基本概念以外,還有一些其他的你必須記住的關於Angular依賴注入系統的東西。

理解Angular的多值依賴

我們系統中大多數的依賴項都只對應於一個值,比如一個類。

但是有一些情形下,我們想要多個不同值的依賴項。

一個你應該已經遇到的很常見的例子就是表單控制項的值的訪問器。

這些是特殊的表單指令,它們綁定到一個給定的表單控制項,讓表單控制項的值對於表單模組(Forms module)是可見的。

問題是不會僅有一個像這樣的指令,有很多。

但是如果全部獨立地配置這些依賴項,那將很不實用,因為通常你想要一次性的一起訪問它們。

因為解決辦法就是擁有一個特殊的依賴項類型,它會接收多個值,不僅僅一個,關聯到相同的依賴注入令牌。

在表單控制項的值訪問器的情況下,那個特殊的令牌就是NG_VALUE_ACCESSOR注入令牌。

舉個例子,下面是一個自定義表單控制項的示例,它想把自己註冊為一個控制項的值訪問器:

@Component({
  selector: 'choose-quantity',
  templateUrl: "choose-quantity.component.html",
  styleUrls: ["choose-quantity.component.scss"],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      multi:true,
      useExisting: ChooseQuantityComponent
    }
  ]
})
export class ChooseQuantityComponent implements ControlValueAccessor {

}

16.ts

注意在這裡我們為NG_VALUE_ACCESSOR注入令牌定義了一個提供商。

但是如果我們不使用multi屬性,這個注入令牌的值將會被覆寫。(後面會提到)

但是因為multi被設置為true,我們實際上把值添加到了依賴項的值的數組中,而不是覆寫它。

任何需要所有控制項值的訪問器的組件或者指令,將會通過請求NG_VALUE_ACCESSOR的注入來接收它們。

這將對應於包含所有標準表單控制項值訪問器的數組,以及我們自定義的數組。

什麼時候使用useExisting提供商

還要注意為了創建提供商,useExisting選擇的使用。

當我們想基於其他已經存在的提供商創建一個提供商時這個選項是很有用的。

在這個例子中,我們僅僅想用一種簡單的方式通過指明ChooseQuantityComponent的類的名稱來定義一個提供商,我們已經學習過可以使用這個類名作為提供商。

useExisting功能也對一個已經存在的提供商定義別名很有用。

現在關於提供商和注入令牌是如何工作的,我們已經有了一個很好的理解,讓我們談談Angular依賴注入系統的另一個基本方面。

理解Angular的分層依賴注入

跟之前的AngularJS版本不同,Angular的依賴注入系統可以說是分層的。

那麼這具體是什麼呢?

如果你注意到了,在Angular中你可以在多個地方為你的依賴項定義提供商:

  • 在模組層級
  • 在組件層級
  • 或者甚至在指令層級!

那麼在所有這些不同的地方定義提供商,有什麼區別呢?它是如何工作的?以及為什麼會有那些不同的選擇呢?

你能在多個地方定義提供商,是因為Angular的依賴注入系統是分層式的。

你看,如果你在某處需要一個依賴項,比如你需要注入一個服務到組件中,Angular首先會嘗試在組件的提供商列表中查找那個依賴項的提供商。

如果Angular在組件本身的層級中沒有找到需要該依賴項的提供商,那麼Angular會嘗試在父組件中查找那樣的提供商。

如果找到了提供商,它會實例化並使用它,但如果沒有,它會詢問父組件的父組件是否有它需要的提供商,以此類推。

這個過程會重複到應用程式的根組件為止。

如果在此過程中沒有找到提供商,你知道會發生什麼的:對,我們得到我們的老朋友「No provider found」資訊。

這個在組件樹中一直向上查找正確的提供商的過程,就是依賴解析,因為它遵循我們組件樹的分層式結構,我們說Angular依賴系統是分層式的。

我們也需要知道為何這個特性是有用的。

分層式依賴注入的好處是什麼?

Angular典型地被用來構建大型應用程式,在某些情況下可能會相當大。

管理這個複雜度的一個方法就是把應用程式分解為許多封裝好的小模組,這些模組本身又分解為定義良好的組件樹。

頁面中這些不同的部分需要特定的服務還有其他的依賴項來工作,這些依賴項也許會或者不會想要與應用程式中其他部分共享。

我們可以想像頁面中一個完全隔離的部分,與應用程式的其他部分相比,它以一種完全獨立的方式工作,具有一系列服務的本地副本和它需要的其他依賴項。

我們想要確保這些依賴項保持私有,並且無法被應用程式的其他地方所接觸到,這樣來避免BUG和其他維護的問題。

在應用程式中我們的獨立的部分使用的一些服務,可能與其他部分共享,或者與組件樹中更深一層的父組件分享,同時其餘依賴項是私有的。

分層式依賴注入系統允許我們實現這個!

利用分層式依賴注入,我們可以隔離應用程式的各個部分,給它們不與應用程式中其他部分共享的私有的依賴項,我們可以讓父組件僅與子組件共享某些依賴項,但不與組件樹中其他部分共享,以此類推。

分層式依賴注入系統允許我們以更模組化的方式構建我們的系統,允許我們僅在需要的時候在應用程式的不同部分之間共享依賴項。

這個最初的解釋是一個很好的起點,但是要真正理解這一切是如何工作的,我們需要一個完整的示例。

通過示例理解組件分層式依賴注入

(to be continued...)

Tags: