前言:本文是我在工作中應用和實踐Angular時的一系列知識總結,本文是作為我在團隊內做的一次技術分享的輔助和大綱。雖然沒辦法分享現場的音訊,但是我也花了不少心思來準備這篇文章,希望能對剛接觸Angular的同學有所幫助。 本次先帶來第一階段的分享:Angular初探-應用架構,適合在通讀Angular官方文件(中文)至少1遍後參考本文來梳理一些比較大的概念。
一 Angular 應用架構
理解 Angular,首先需要理解三大核心概念:模組、元件和服務,其餘的特性都是基於這三大概念衍生出來的。比如元件與服務之間有依賴注入特性,模組為元件和服務提供了編譯的上下文以及一些功能(指令、管道等)支援。檢視的更新依賴於雙向繫結,檢視的變換對應著元件的切換,而元件的切換需要路由機制......
1. 模組
1.1 什麼是 Angular 模組?
Angular 應用是模組化的,它擁有自己的模組化系統,稱作 NgModule。
一個 NgModule 就是一個容器,用於存放一些內聚的程式碼塊,這些程式碼塊專注於某個應用領域、某個工作流或一組緊密相關的功能。 它可以包含一些元件、服務或其它程式碼檔案,他們的作用域由包含它們的 NgModule 定義。
他作為模組還可以匯入一些由其它模組中匯出的功能,同時自身也可以匯出一些指定的功能供其它 NgModule 使用。
實現上,NgModule 是一個帶有 @NgModule 裝飾器的類:
關於 es7 裝飾器,可以參考我翻譯的這篇文章:探索 EcmaScript 裝飾器
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
@NgModule({
imports: [ BrowserModule ], // 匯入了本模組中的元件模板所需的類的其它模組
providers: [ Logger ], // 本模組嚮應用全域性貢獻的服務。 這些服務能被本應用中的任何部分使用。
declarations: [ AppComponent ], // 屬於本 NgModule 的元件、指令、管道
// 每個元件都應該(且只能)宣告在一個 NgModule 類中。
exports: [ AppComponent ], // 能在其它模組的元件模板中使用的可宣告物件的子集
bootstrap?: [ AppComponent ] // 應用的主檢視,稱為根元件。它是應用中所有其它檢視的宿主。
// Angular 建立它並插入 index.html 宿主頁面。
// 只有根模組才應該設定這個 bootstrap 屬性。
entryComponents?: [SomeComponent]
})
export class AppModule { }
複製程式碼
@NgModule 的引數是一個後設資料物件,用於描述如何編譯元件的模板,以及如何在執行時建立注入器。
它會標出該模組自己的元件、指令和管道,通過 exports 屬性公開其中的一部分,以便外部元件使用它們。 NgModule 還能把一些服務提供商新增到應用的依賴注入器中。
1.2 NgModule 與 JavaScript 模組的區別
Angular 應用中使用的模組其實有兩種,一種是 JavaScript 模組,一種是 NgModule。
關於 js 模組系統,可以參考我翻譯的這篇文章:模組系統
- js 模組是一個包含程式碼的獨立檔案,通過匯入匯出機制為該檔案內的程式碼加上獨立的名稱空間,避免了變數的衝突。
- NgModule 是一個帶裝飾器的類,是 Angular 內部的一個概念。其作用也是把某一部分的特性程式碼組織起來,比如元件,服務,指令等,形成一個大的應用單元。只不過這些特性程式碼存在不同的檔案內,或者說存在於不同的類中。
- 應該說,js 模組包含了 NgModule,NgModule 通過特定的語法(裝飾器 + 後設資料)將一些程式碼組織起來,然後通過 js 模組將其匯入/匯出。
- 另外,NgModule 只在宣告 Angular 模組時會用到,而 js 模組貫穿了整個專案,因為每一塊特性程式碼都需要通過 js 模組的匯入匯出機制來將其串在一起。
1.3 NgModule 的種類
在 angular 內部,將 NgModule 劃分為了兩種(注意,無論根模組還是特性模組,其 NgModule 結構都是一樣的,只是從功能上劃分出這兩個概念):
-
根模組:顧名思義,“根”模組必然為整個 Angular 應用的基礎和核心,他的功能就是將所有開發者自創的特性模組組織起來,接收一些全域性的配置項,以及引導應用在瀏覽器中啟動。根模組與其他任何特性模組的一個定義方式上的差異就是根模組裝飾器的後設資料中多了一個 bootstrap 屬性,通過 bootstrap 屬性宣告一個應用的根元件(入口元件),然後基於這個根元件來生長整個應用的元件樹,呈現出一個完整的應用。
這裡插播一個元件的概念:入口元件(entry component)。他是命令式生成的一種元件,區別於宣告式(開發者顯式地在模板中引用某一個元件)的方式,入口元件可能是根元件(由 Angular 的啟動機制自動載入到 index.html 模板中),也可以是定義在路由中的元件(此時由路由器根據相應的路由規則來動態插入到當前檢視中)。
理論上說,所有的入口元件都應該宣告到@NgModule 裝飾器的 entryComponents 後設資料物件屬性中,但是 Angular 編譯器會隱式地把根元件和路由定義的元件新增為 entryComponents,所以我們不需要顯示宣告這一類入口元件了。
Angular 編譯器只會為那些可以從 entryComponents 中直接或間接訪問到的元件生成程式碼,內部使用搖樹優化(tree shaker)來剝離那些無關的元件,保持應用的精簡和高效。基於這個原理,有時候我們可能會使用到一些 UI 庫提供的元件(彈窗),為了避免這類元件被編譯器排除,我們就需要手動在 entryComponents 中新增這些元件的引用宣告。
-
特性模組:可以理解為開發者建立的其他 NgModule 的統稱。根據不同特性模組的功能特徵,又可以分為:
- 領域特性模組
- 帶路由的特性模組
- 路由模組
- 服務特性模組
- 可視部件特性模組
這個分類其實只是為了區分不同 NgModule 的功能,是一個組織應用的最佳實踐準則,並不需要嚴格劃分。舉個例子,幾乎每一個複雜的模組都需要定義路由,但是我們可以把路由這個關注點專門抽離出來,統一到一個路由特性模組中,這樣方便我們統一定義和劃分應用路由層次。這樣就可以在應用不斷成長時保持應用的良好結構,並且當複用本模組時,你可以輕鬆的讓其路由保持完好。
2. 元件
元件控制螢幕上被稱為檢視的一小片區域。我們在類中定義元件的應用邏輯,為檢視提供資料和行為支援。 元件通過一些由屬性和方法組成的 API 與檢視互動。
實現上,通過@Component 裝飾器和後設資料來將一個類標記為元件:
@Component({
selector: 'app-hero-list',
templateUrl: './hero-list.component.html',
providers: [HeroService]
})
export class HeroListComponent implements OnInit {
/* . . . */
}
複製程式碼
- selector:是一個選擇器,它會告訴 Angular,一旦在模板 HTML 中找到了這個選擇器對應的標籤,就建立並插入該元件的一個例項。
- templateUrl:該元件的 HTML 模板檔案地址。 或者,使用 template 屬性的值來直接定義內聯的 HTML 模板。 這個模板定義了該元件的宿主檢視。
- providers 是當前元件所需的依賴注入提供商的一個陣列。在這裡宣告的依賴注入會提供一個與元件一同建立和銷燬的例項。
2.1 元件的模板
模板很像標準的 HTML,但是它還包含 Angular 的模板語法,這些模板語法可以根據應用邏輯、應用狀態和 DOM 資料來修改這些 HTML。 模板可以使用資料繫結來協調應用和 DOM 中的資料,使用管道在顯示出來之前對其進行轉換,使用指令來把程式邏輯應用到要顯示的內容上。
下面的例子:
<h2>Hero List</h2>
<p><i>Pick a hero from the list</i></p>
<ul>
<li *ngFor="let hero of heroes" (click)="selectHero(hero)">
{{hero.name}}
</li>
</ul>
<app-hero-detail *ngIf="selectedHero" [hero]="selectedHero"></app-hero-detail>
複製程式碼
這個模板使用了典型的 HTML 元素,比如 <h2>
和 <p>
,還包括一些 Angular 的模板語法元素,如 *ngFor
,{{hero.name}}
,(click)
、[hero]
和 <app-hero-detail></app-hero-detail>
。這些模板語法元素告訴 Angular 該如何根據程式邏輯和資料在螢幕上渲染 HTML。
模板語法分成三類:資料繫結,管道和指令。
2.1.1 資料繫結
通過特定的繫結語法,Angular 可以自動地將元件中的屬性和方法與模板對應起來,建立一種帶有方向的對映關係。
上面的例子中,{{hero.name}}
的語法稱為插值表示式,他是一種從元件類繫結屬性到 DOM 的模板語法。
[hero]="selectedHero"
的語法稱為屬性繫結,他也是一種將元件類的屬性繫結到 DOM 的模板語法。他們之間的區別在於,使用插值表示式是為了顯示對應的資料到檢視中,而屬性繫結是一種資料的傳遞,將宿主元件的屬性傳遞到子元件或者指令中,然後由接受者決定如何使用這部分資料,某種意義上說,屬性繫結是一種通訊機制。
(click)="selectHero(hero)"
的語法稱為事件繫結,這裡的事件是一系列使用者觸發的 UI 行為,滑鼠操作,鍵盤行為等,這些行為附帶一些資訊。我們需要從 DOM 監聽到這些行為和資訊,然後傳遞給我們的元件類中相應的處理函式,在元件類中來處理這些行為,比如更新檢視中的部分屬性,向伺服器請求一些資料,甚至是引起檢視的變換等。很明顯,它的繫結方向是從 DOM 到元件的,與前兩種相反。
實際上事件繫結同樣支援開發者自己定義的事件,這部分會在深入使用部分詳細介紹。
還有一種繫結形式是 angular 最為著名的雙向繫結。他是屬性繫結和事件繫結的結合,語法如下:
<input [(ngModel)]="hero.name">
複製程式碼
在 Angular 內部會將這種語法做即時、自動的處理(隱式),使用者的輸入會自動更新到元件類中對應的屬性上,而元件類中對應屬性的變化也會立即反映到檢視中來。
2.1.2 管道
管道是一類轉換函式,他接收一個輸入,通過預先設定的處理規則對輸入做加工和轉化,然後輸出結果,並用結果替換掉原本的輸入。 管道的使用需要與插值表示式和管道操作符( | )結合起來:
<p>Today is {{today | date}}</p>
通過管道我們可以很方便地對檢視內一些資料進行統一轉化,以更為友好的方式來展示這些資料。同時他又具有通用性,他從本該做這部分工作的元件類中分離出來,讓我們的元件類更加精簡和專注於業務。
2.1.3 指令
指令就是一個帶有 @Directive 裝飾器的類。和元件一樣,指令的後設資料把指令類和一個選擇器關聯起來,選擇器用來把該指令插入到 HTML 中。 他們都是 Angular 編譯器對模板做解析和編譯的基礎,Angular 編譯器找到這些選擇器 指令分為結構型指令和屬性性指令,結構型指令通過新增、移除或替換 DOM 元素來修改佈局,屬性型指令會修改現有元素的外觀或行為。
上文中提到的*ngIf
就是一個結構型指令,而 ngModel 則是一個屬性型指令。
2.1.4 總結一下
模板會把 HTML 和 Angular 的模板標記語法組合起來,這些模板標記語法可以在 HTML 元素顯示出來之前修改它們。
模板中的指令會提供程式邏輯,而繫結語法會把你應用中的資料和 DOM 連線在一起。
- 屬性繫結讓你將從應用資料中計算出來的值插入到 HTML 中,顯示給使用者。
- 事件繫結讓你的應用可以通過更新應用的資料來響應目標環境下的使用者輸入。
在檢視顯示出來之前,Angular 會先根據你的應用資料和邏輯來執行模板中的指令並解析繫結表示式,以修改 HTML 元素和 DOM。
Angular 支援雙向資料繫結,這意味著 DOM 中發生的變化(比如使用者的選擇)同樣可以反映回你的程式資料中。
模板也可以用管道轉換要顯示的值以增強使用者體驗。
2.2 路由
在使用者使用應用程式時,Angular 的路由器能讓使用者從一個檢視導航到另一個檢視。
首先分析一下瀏覽器的導航模式:
- 在位址列輸入 URL,瀏覽器就會導航到相應的頁面。
- 在頁面中點選連結,瀏覽器就會導航到一個新頁面。
- 點選瀏覽器的前進和後退按鈕,瀏覽器就會在你的瀏覽歷史中向前或向後導航。
Angular 的 Router(即“路由器”)借鑑了這個模型:
- 它把瀏覽器中的 URL 看做一個指南, 據此導航到一個由客戶端生成的檢視,並可以把引數傳給支撐檢視的相應元件,幫它決定具體該展現哪些內容。
- 你可以為頁面中的連結繫結一個路由,這樣,當使用者點選連結時,就會導航到應用中相應的檢視。 當使用者點選按鈕、從下拉框中選取,或響應來自任何地方的事件時,你也可以在(元件)程式碼控制下進行導航。
- 路由器還在瀏覽器的歷史日誌中記錄下這些活動,這樣瀏覽器的前進和後退按鈕也能照常工作。
每個帶路由的 Angular 應用都有一個 Router(路由器)服務的單例物件。 當瀏覽器的 URL 變化時,路由器會查詢對應的 Route(路由),並據此決定該顯示哪個元件。
2.2.1 路由配置
我們使用路由配置來宣告應用中我們預設的一些路由規則,這樣 Angular 的路由器就可以按照這些預設的規則將 URL 的變化與我們的應用檢視變換對應起來。
const appRoutes: Routes = [
{ path: 'crisis-center', component: CrisisListComponent },
{ path: 'hero/:id', component: HeroDetailComponent },
{
path: 'heroes',
component: HeroListComponent,
data: { title: 'Heroes List' }
},
{
path: '',
redirectTo: '/heroes',
pathMatch: 'full'
},
{ path: '**', component: PageNotFoundComponent }
];
複製程式碼
2.2.2 路由出口
路由出口就是用於告訴 Angular 路由器,當 URL 的變化匹配到路由配置裡的某一項的path
時,在模板中的何處插入component
對應的元件例項。
<router-outlet></router-outlet>
<!-- 路由命中時的元件會被插入到此處 -->
複製程式碼
2.2.3 路由連結
稍微瞭解過 html 的話,就會知道頁面中的跳轉、連結是以<a href="SOME_URL">
標籤的形式來實現的,這種方式在 Angular 裡有另一種形式,通過一個叫routerLink
的指令來做這件事,因為 Angular 是單頁應用,儘管看起來在一個 Angular 應用裡可以到處穿梭,頁面不斷變化,但這只是一個障眼法,是 Angular 通過不斷修改同一個頁面中的 DOM,不斷建立、更新、隱藏、銷燬一個個元件來實現的。所以要想在 Angular 裡做“跳轉”的操作,也需要一個障眼法機制,他就是路由連結。
<a routerLink="/heroes" routerLinkActive="active">Heroes</a>
複製程式碼
3 服務
理解服務這個概念,可以從他的詞性來入手。名詞性質的服務是服務這個概念的具體實現,狹義地講,他就是一個類,這個類有一些明確的用途。
服務做了一類事情,比如從伺服器獲取資料並預處理,驗證使用者的輸入,或者是寫日誌,在記憶體中快取一些可以被全域性應用共享的資料或狀態,還可以作為元件之間通訊的中間介質。
動詞性質的服務則與元件關聯起來,依賴注入(Dependency Injection)是他們之間的橋樑,服務是生產者,元件是服務的消費者。
Angular 裡將元件和服務區分開,是為了提高模組性和複用性,同時進一步將元件的功能劃分出來,讓元件更加專注於特定的業務。
3.1 依賴注入
依賴注入是 Angular 另一個著名的特性,他是很多物件導向語言框架設計原則中的“控制反轉”的一種實現方式。在依賴注入模式中,應用元件無需關注所依賴物件的建立和初始化過程,可以認為框架已初始化好了,開發者只管呼叫即可。
依賴注入有利於應用程式中各模組之間的解耦,使得程式碼更容易維護。這種優勢可能一開始體現不出來,但隨著專案複雜度的增加,各模組、元件、第三方服務等相互呼叫更頻繁時,依賴注入的優點就體現出來了。開發者可以專注於所依賴物件的消費,無需關注這些依賴物件的產生過程,這將大大提升開發效率。
舉個例子:
export class Car {
public engine: Engine;
public tires: Tires;
public description = 'No DI';
constructor() {
this.engine = new Engine();
this.tires = new Tires();
}
}
複製程式碼
Car 類在自己的建構函式中建立了它所需的一切。這樣做的話 Car 類是脆弱、不靈活以及難於測試的。
為什麼?
Car 類需要一個引擎 (engine) 和一些輪胎 (tire),它沒有去請求現成的例項, 而是在建構函式中用具體的 Engine 和 Tires 類例項化出自己的副本。如果 Engine 類升級了,它的建構函式要求傳入一個引數,此時這個 Car 類就被破壞了,在更新 Engine 例項化方式之前 Car 類都不可用,本來是 Engine 類的更改,引起了連鎖反應,連累了 Car 類也要跟著修改,Car 類變得脆弱了。 另一方面,測試的時候更麻煩了,你會受制於它背後的那些依賴,你需要考慮 Engine 類的建立是否成功了,如果 Engine 類還有依賴,那就得一層一層繼續深入,本來測試一個 Car 類,結果你沒法控制這輛車背後隱藏的依賴。 當不能控制依賴時,類就會變得難以測試。
如何讓 Car 類更強壯、可測試?
答案是把 Car 的建構函式改造成 DI 的版本:
constructor(public engine: Engine, public tires: Tires) { } 複製程式碼
然後我們在建立一輛車的時候就可以往建構函式中傳入 Engine 和 Tire 的例項來例項化 Car 類:
let car = new Car(new Engine(), new Tires()); 複製程式碼
這樣一來引擎和輪胎這兩個依賴的定義與 Car 類本身解耦了。如果有人擴充套件了 Engine 類,那就不再是 Car 類的煩惱了。
剛剛瞭解了什麼是依賴注入:它是一種程式設計模式,可以讓類從外部源中獲得它的依賴,而不必親自建立它們。他是用來建立物件及其依賴的其它物件的一種方式。 當依賴注入系統建立某個物件例項時,會負責提供該物件所依賴的物件(稱為該物件的依賴)。
但是上面的嘗試,只做到了 Car 類的可維護性,但是對於要例項化 Car 類的外部環境來說,工作量反而多了:原本只關心 Car 類的構造,現在連 Engine 和 Tire 類的構造工作也要做了,WTF?
如果,有一種機制,我們只需要直接列出想要的東西,而不用管這些東西如何建造,直接拿現成的,該多好。
像這樣:let car = injector.get(Car);
,使用 Car 的消費者不需要知道如何建立 Car 類,Car 類也不需要知道如何建立 Engine 和 Tire 類,這些工作都交給注入器,不管是消費者還是 Car 類,都直接向注入器索要需要的依賴。
這種機制就是“依賴注入框架”。A 依賴於 B,那 B 就是 A 的依賴,同時 A 和 B 都會被這個 DI 框架維護起來。
3.2 Angular 中的 DI
首先介紹幾個簡單的概念。
- 注入器(Injector): 就像製造工廠,提供了一系列的介面用於建立依賴物件的例項。
- 我們不需要不用自行建立 Angular 注入器,Angular 會在啟動過程中為你建立全應用級注入器。該注入器維護一個包含它已建立的依賴例項的容器,並儘可能複用它們。
- 提供商(Provider):用於配置注入器,注入器通過它來建立被依賴物件的例項,Provider 把標識對映到工廠方法中,被依賴的物件就是通過該方法建立的。
- 對於 Angular 服務來說,Provider 通常就是這個服務類本身。
- 依賴(Dependence):指定了被依賴物件的型別,注入器會根據此型別建立對應的物件。
在把 Angular 中的服務類註冊進依賴注入器之前,它只是個普通類而已。Angular 本身沒法自動判斷你是打算自行建立服務類的例項,還是等注入器來建立它。你必須通過為每個服務指定服務提供商來配置它。提供商會告訴注入器如何建立該服務,然後 Angular 的依賴注入器負責建立服務的例項,並把它們注入到元件類中。你很少需要自己建立 Angular 的依賴注入器。 當 Angular 執行本應用時,它會為你建立這些注入器,首先會在引導過程中建立一個根注入器。
有很多方式可以為注入器註冊服務提供商。
3.2.1 在服務類前加上裝飾器@Injectable 和相應的後設資料來標記出該服務類是用來注入的,也即配置了一個提供商。
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root' // providedIn 告訴 Angular,它的根注入器要負責呼叫 HeroService 類的建構函式來建立一個例項,並讓它在整個應用中都是可用的。
})
export class HeroService {
constructor() {}
}
複製程式碼
3.2.2 在 NgModule 的 providers 後設資料中註冊提供商
providers: [
UserService, // { provide: UserService, useClass: UserService }
{ provide: APP_CONFIG, useValue: SOME_DI_CONFIG }
],
複製程式碼
3.2.3 在元件中注入服務
Angular 在底層做了大量的初始化工作,這大大簡化了建立依賴注入的過程,在元件中使用依賴注入需要完成以下三個步驟:
- 通過 import 匯入被依賴物件的服務
- 在元件中配置注入器。在啟動元件時,Angular 會讀取@Component 裝飾器裡的 providers 後設資料,它是一個陣列,配置了該元件需要使用到的所有依賴,Angular 的依賴注入框架就會根據這個列表去建立對應物件的例項。
- 在元件建構函式中宣告所注入的依賴。注入器就會根據建構函式上的宣告,在元件初始化時通過第二步中的 providers 後設資料配置依賴,為建構函式提供對應的依賴服務,最終完成注入過程。
當 Angular 建立元件類的新例項時,它會通過檢視該元件類的建構函式,來決定該元件依賴哪些服務或其它依賴項。
當 Angular 發現某個元件依賴某個服務時,它會首先檢查是否注入器中已經有了那個服務的任何現有例項。如果所請求的服務尚不存在,注入器就會使用以前註冊的服務提供商來製作一個,並把它加入注入器中,然後把該服務返回給 Angular。
當所有請求的服務已解析並返回時,Angular 可以用這些服務例項為引數,呼叫該元件的建構函式。
import { Component } from '@angular/core';
import { SomeService } from './some-service.service';
@Component({
selector: 'app-some-component',
templateUrl: './some-component.component.html',
styleUrls: ['./some-component.component.less']
})
export class SomeComponent {
constructor(private _someService: SomeService) {}
}
複製程式碼
4. 例子應用
基於以上知識,我們來分析一個最小化的 Angular 應用,看看以上內容是怎麼有機結合起來的。
這部分內容不便於文字展示,這裡就省略了。