本文將與你一起探討如何用不可變數據儲存的方式進行Angular應用的狀態管理 :ngrx/store——Angular的響應式Redux。本文將會完成一個小型簡單的Angular應用,最終代碼可以在這里下載。
Angular應用中的狀態管理
近幾年,大型復雜Angular/AngularJS項目的狀態管理一直是個讓人頭疼的問題。在AngularJS(1.x版本)中,狀態管理通常由服務,事件,$rootScope混合處理。在Angular中(2+版本),組件通信讓狀態管理變得清晰一些,但還是有點復雜,根據數據流向不同會用到很多方法。
注意:本文中,AngularJS特指1.x版本,Angular對應2.0版本及其以上。
有人用Redux來管理AngularJS或者Angular的狀態。Redux是JavaScript應用的可預測狀態容器,支持單一不可變數據儲存。Redux最有名的就是結合React的使用,當然它可以用於任意的視圖層框架。Egghead.io發布了一份非常優質的Redux免費視頻教程,視頻由Redux作者Dan Abramov本人講解。
初識ngrx/store
本文將采用ngrx/store管理我們的Angular應用。那么,ngrx/store和Redux什么關系呢?為什么不用Redux呢?
與Redux的關系
ngrx/store的靈感來源於Redux,是一款集成RxJS的Angular狀態管理庫,由Angular的布道者Rob Wormald開發。它和Redux的核心思想相同,但使用RxJS實現觀察者模式。它遵循Redux核心原則,但專門為Angular而設計。
ngrx/store中的基本原則
-
State(狀態) 是指單一不可變數據
-
Action(行為) 描述狀態的變化
-
Reducer(歸約器/歸約函數) 根據先前狀態以及當前行為來計算出新的狀態
-
狀態用State的可觀察對象,Action的觀察者——Store來訪問
我們會詳細解釋說明。先快速過一遍基礎,然后在實戰的過程中慢慢深入解釋。
Actions(行為)
Actions是信息的載體,它發送數據到reducer,然后reducer更新store。Actions是store能接受數據的唯一方式。
在ngrx/store里,Action的接口是這樣的:
// actions包括行為類型和對應的數據載體
export interface Action {
type: string;
payload?: any;
}
type描述我們期待的狀態變化類型。比如,添加待辦'ADD_TODO',增加'DECREMENT'等。payload是發送到待更新store中的數據。store派發action的代碼類似如下:
// 派發action,從而更新store
store.dispatch({
type: 'ADD_TODO',
payload: 'Buy milk'
});
Reducers(歸約器)
Reducers規定了行為對應的具體狀態變化。它是純函數,通過接收前一個狀態和派發行為返回新對象作為下一個狀態的方式來改變狀態,新對象通常用Object.assign和擴展語法來實現。
// reducer定義了action被派發時state的具體改變方式
export const todoReducer = (state = [], action) => {
switch(action.type) {
case 'ADD_TODO':
return [...state, action.payload];
default:
return state;
}
}
開發時特別要注意函數的純性。因為純函數:
-
不會改變它作用域外的狀態
-
輸出只決定於輸入
-
相同輸入,總是得到相同輸出
關於函數的純性,可以點擊這里進一步了解。開發時,要確保函數的純性和狀態不可變性,所以寫reducers的時候要多加小心。
Store(存儲)
store中儲存了應用中所有的不可變狀態。ngrx/store中的store是RxJS狀態的可觀察對象,以及行為的觀察者。
我們可以利用Store來派發行為。當然,我們也可以用Store的select()方法獲取可觀察對象,然后訂閱觀察,在狀態變化之后做出反應。
ngrx/store實戰:個性寵物標簽
目前我們熟悉了ngrx/store的基本工作原理,接下來我們來開發一個能讓用戶自定義寵物名稱標簽的應用。該應用將會有以下功能:
-
用戶可以選擇標簽形狀,字體,文案,以及附加特性
-
創建過程可以預覽標簽效果
-
完成后,可以繼續創建
我們需要創建幾個組件來組合成標簽生成器和標簽預覽,還會添加登錄,創建標簽,完成創建的組件和路由。這個小應用的狀態將會用ngrx/store來管理。
完成后的個性寵物標簽app效果如下:

讓我們開始吧!
Angular應用設置
安裝依賴
確保你已經安裝了NodeJS,推薦LTS版本。
用npm安裝Angular CLI包,方便一鍵生成項目手腳架。運行以下命令來全局安裝angular-cli。
$ npm install -g @angular/cli
創建項目
選好項目所在的文件夾,打開命令行,輸入以下命令來創建一個新的Angular 項目:
$ ng new pet-tags-ngrx
進入新創建的文件夾,安裝必要的包:
$ cd pet-tags-ngrx
$ npm install @ngrx/core @ngrx/store --save
一切准備就緒,可以開始開發了。
定制你的項目模板
讓我們根據這個項目的需求,稍微改造一下項目模板。
創建src/app/core文件夾
首先,創建文件夾src/app/core。應用的根組件和核心文件都會放在這個文件夾下。將所有的app.component.*文件移動到這里。
注意:簡潔起見,該教程不會包含測試。我們會忽略所有的*.spec.ts文件。如果你想寫測試的話,可以自己寫。所以,文中不會再提到這些文件。同樣,出於清晰簡潔性的考慮,github倉庫的最終版代碼刪除了所有測試相關的文件。
更新App模塊
接着,打開src/app/app.module.ts文件,更新app.component 的路徑:
// src/app/app.module.ts
...
import { AppComponent } from './core/app.component';
...
靜態資源整理
定位到src/assets文件夾。
在assets文件夾下新建一個images的文件夾,稍后我們會添加一些圖片。然后,將根目錄下的src/styles.css移動到src/assets下。
styles.css的移動需要我們修改.angular-cli.json的配置。打開這個文件,把styles屬性改成如下:
// .angular-cli.json
...
"styles": [
"assets/styles.css"
],
...
集成Bootstrap
最后,在index.html中添加Bootstrap樣式。在<link>標簽上加上CDN地址。這里我們只用到樣式,不需要腳本文件。順便,更新一下標題,變成Custom Pet Tags:
<!-- index.html -->
...
<title>Custom Pet Tags</title>
...
<!-- Bootstrap CDN -->
<link
rel="stylesheet"
href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.6/css/bootstrap.min.css"
integrity="sha384-rwoIResjU2yc3z8GV/NPeZWAv56rSmLldC3R/AZzGRnGxQQKnKkoFVhFQhNUwEyJ"
crossorigin="anonymous">
</head>
啟動服務
我們可以在本地起個服務,然后監聽文件變化實時更新:
$ ng serve
在瀏覽器中輸入http://localhost:4200,程序成功運行。

App組件
現在開始創建新功能。從根組件app.component.*入手。不要擔心,變化很小。
刪除樣式文件
刪除app.component.css文件。該組件只用Bootstrap來定義樣式,所以不需要額外樣式。
根組件腳本
在app.component.ts文件中刪除對上述樣式文件的引用。我們也可以刪除AppComponent類中的title 屬性。
// src/app/core/app.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html'
})
export class AppComponent {
}
根組件模版
在app.component.html中添加一些內容,變成如下:
<!-- src/app/core/app.component.html -->
<div class="container">
<div class="row">
<div class="col-sm-12">
<h1 class="text-center">Custom Pet Tags</h1>
</div>
</div>
<router-outlet></router-outlet>
</div>
我們用Bootstrap來添加珊格系統和標題。然后添加一個<router-outlet>指令,這是當這個單頁面應用中添加路由后,視圖會渲染的地方。到現在為止,程序會報錯。等我們建好了路由和page組件的時候,就好了。
創建頁面組件
如上所述,應用會包含三個路由:登錄主頁,創建預覽頁,以及完成頁。
我們先創建好各頁面手腳架,以便搭建路由。然后再回來完善各個組件。
在根目錄下運行如下指令創建頁面組件:
$ ng g component pages/home
$ ng g component pages/create
$ ng g component pages/complete
ng g命令可以快速生成組件,指令,過濾器和服務,同時也會自動把生成的文件導入到app.module.ts中。現在,我們有三個頁面組件的腳手架,可以開始搭建路由了。
搭建路由
新建一個路由模塊,在src/app/core文件夾下創建一個app-routing.module.ts文件:
// src/app/core/routing-module.ts
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { HomeComponent } from '../pages/home/home.component';
import { CreateComponent } from './../pages/create/create.component';
import { CompleteComponent } from './../pages/complete/complete.component';
@NgModule({
imports: [
RouterModule.forRoot([
{
path: '',
component: HomeComponent
},
{
path: 'create',
component: CreateComponent
},
{
path: 'complete',
component: CompleteComponent
},
{
path: '**',
redirectTo: '',
pathMatch: 'full'
}
])
],
providers: [],
exports: [
RouterModule
]
})
export class AppRoutingModule {}
現在有三個路由/,/create,/complete,未知路由會重定向到首頁。
打開根模塊文件app.module.ts,添加新增路由模塊AppRoutingModule至imports 屬性。
// src/app/app.module.ts
...
import { AppRoutingModule } from './core/app-routing.module';
@NgModule({
...,
imports: [
...,
AppRoutingModule
],
...
到此,路由設置完畢。我們可以通過路由來訪問不同頁面,訪問首頁的時候,HomeComponent就會渲染在<router-outlet>所在的位置,如下圖所示:

“Home”頁面組件
HomeComponent會有簡單的信息提示,以及登錄按鈕。點擊登錄按鈕,直接跳轉到/create頁面。
現在,讓我們添加提示信息和跳轉到/create頁面的按鈕。打開home.component.html,替換內容如下:
<!-- src/app/pages/home/home.component.html -->
<div class="row">
<div class="col-sm-12 text-center">
<p class="lead">
Please sign up or log in to create a custom name tag for your beloved pet!
</p>
<p>
<button
class="btn btn-lg btn-primary"
routerLink="/create">Log In</button>
</p>
</div>
</div>
現在,首頁效果如下:

寵物標簽模型
開始實現個性寵物標簽生成器功能和狀態管理的工作了。首先,為我們的狀態創建一個數據模型,該模型描述了當前的寵物標簽。
新建文件src/app/core/pet-tag.model.ts:
// src/app/core/pet-tag.model.ts
export class PetTag {
constructor(
public shape: string,
public font: string,
public text: string,
public clip: boolean,
public gems: boolean,
public complete: boolean
) { }
}
export const initialTag: PetTag = {
shape: '',
font: 'sans-serif',
text: '',
clip: false,
gems: false,
complete: false
};
PetTag類聲明了寵物標簽的屬性和類型,接着我們定義一個常量initialTag作為默認初始值,在初始化和重置狀態時需要用到。
寵物標簽行為
現在可以創建行為類型了。回顧之前說的,action被派發到reducer中,從而更新store。現在為我們想要的每種行為定義名字。
創建文件src/app/core/pet-tag.actions.ts
// src/app/core/pet-tag.actions.ts
export const SELECT_SHAPE = 'SELECT_SHAPE';
export const SELECT_FONT = 'SELECT_FONT';
export const ADD_TEXT = 'ADD_TEXT';
export const TOGGLE_CLIP = 'TOGGLE_CLIP';
export const TOGGLE_GEMS = 'TOGGLE_GEMS';
export const COMPLETE = 'COMPLETE';
export const RESET = 'RESET';
將行為定義為常量。我們也可以構造可注入的行為類,就像ngrx/example-app中那樣。但我們這個例子很簡單,用這種方法反而會增加復雜度。
寵物標簽歸約器
現在可以創建我們的歸約函數了,這個函數接受action,更新store。
新建文件src/app/core/pet-tag.reducer.ts:
// src/app/core/pet-tag.reducer.ts
import { Action } from '@ngrx/store';
import { PetTag, initialTag } from './../core/pet-tag.model';
import { SELECT_SHAPE, SELECT_FONT, ADD_TEXT, TOGGLE_CLIP, TOGGLE_GEMS, COMPLETE, RESET } from './pet-tag.actions';
export function petTagReducer(state: PetTag = initialTag, action: Action) {
switch (action.type) {
case SELECT_SHAPE:
return Object.assign({}, state, {
shape: action.payload
});
case SELECT_FONT:
return Object.assign({}, state, {
font: action.payload
});
case ADD_TEXT:
return Object.assign({}, state, {
text: action.payload
});
case TOGGLE_CLIP:
return Object.assign({}, state, {
clip: !state.clip
});
case TOGGLE_GEMS:
return Object.assign({}, state, {
gems: !state.gems
});
case COMPLETE:
return Object.assign({}, state, {
complete: action.payload
});
case RESET:
return Object.assign({}, state, initialTag);
default:
return state;
}
}
首先從ngrx/store導入Action。同時也需要PetTag數據模型以及它的初始狀態initialTag。還有上一步中創建的行為類型也需要導入。
然后創建petTagReducer()函數,該函數接收兩個參數:上一個狀態state和被派發的行為action。注意它是輸入決定輸出的純函數,函數不會改變全局的狀態。這就是說,從歸約器返回的數據要么是新對象,要么是未修改的輸入,比如default情況。
通常,我們可以借用Object.assign()從輸入數據中得到全新的對象。輸入數據是上一個狀態以及包含行為載體(payload)的對象。
TOGGLE_CLIP和TOGGLE_GEMS切換initialTag狀態中的布爾值,所以當我們派發這兩種行為的時候,不需要行為載體,我們只需要簡單取反即可。
COMPLETE行為需要一個載體,因為我們明確要將其設置為true,而且每個標簽只能操作一次。我們也可以切換布爾值,但明確起見,我們還是會派發一個具體的值作為行為載體。
注意:注意RESET行為用到導入的initialTag。因為它是個不變量,所以在這里使用並不會違背歸約函數的純性。
根模塊導入Store
完成了行為和歸約函數的定義之后,我們要告訴應用程序有這些的存在。打開app.module.ts文件,更新如下:
// src/app/app.module.ts
...
import { StoreModule } from '@ngrx/store';
import { petTagReducer } from './core/pet-tag.reducer';
@NgModule({
...,
imports: [
...,
StoreModule.provideStore({ petTag: petTagReducer })
],
...
現在,我們可以用Store來實現狀態管理了。
創建“Create”頁面
之前創建的CreateComponent是個智能組件(Smart Component),它會有幾個木偶子組件(Dumb Component)。
智能組件/木偶組件
智能組件也稱容器組件,通常作為根級組件,包含業務邏輯,狀態管理,訂閱,處理事件。在這個例子中,就是那些可路由的頁面組件。CreateComponent是智能組件,它將為標簽生成器制定業務邏輯。同時,它會處理木偶子組件觸發的事件,而這些子組件是標簽生成器的一部分。
木偶組件又名展示組件,它只決定於父組件傳遞的數據。它可以觸發事件,然后在父組件中處理,但它不會直接影響訂閱或者store。木偶組件是可復用的模塊化組件。比如,我們會同時在Create頁面和Complete頁面使用標簽預覽這個木偶組件(CreateComponent和CompleteComponent是智能組件)。
“Create”頁面功能點
Create頁面將會有以下幾個功能:
-
標簽形狀選擇
-
標簽字體選擇和文案輸入
-
是否添加clip和gems
-
標簽形狀和文案的預覽
-
結束操作的完成按鈕
“Create”組件腳本
我們先從CreateComponent開始,打開文件create.component.ts:
// src/app/pages/create/create.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Subscription } from 'rxjs/Subscription';
import { Store } from '@ngrx/store';
import { SELECT_SHAPE, SELECT_FONT, ADD_TEXT, TOGGLE_CLIP, TOGGLE_GEMS, COMPLETE } from './../../core/pet-tag.actions';
import { PetTag } from './../../core/pet-tag.model';
@Component({
selector: 'app-create',
templateUrl: './create.component.html'
})
export class CreateComponent implements OnInit, OnDestroy {
tagState$: Observable<PetTag>;
private tagStateSubscription: Subscription;
petTag: PetTag;
done = false;
constructor(private store: Store<PetTag>) {
this.tagState$ = store.select('petTag');
}
ngOnInit() {
this.tagStateSubscription = this.tagState$.subscribe((state) => {
this.petTag = state;
this.done = !!(this.petTag.shape && this.petTag.text);
});
}
ngOnDestroy() {
this.tagStateSubscription.unsubscribe();
}
selectShapeHandler(shape: string) {
this.store.dispatch({
type: SELECT_SHAPE,
payload: shape
});
}
selectFontHandler(fontType: string) {
this.store.dispatch({
type: SELECT_FONT,
payload: fontType
});
}
addTextHandler(text: string) {
this.store.dispatch({
type: ADD_TEXT,
payload: text
});
}
toggleClipHandler() {
this.store.dispatch({
type: TOGGLE_CLIP
});
}
toggleGemsHandler() {
this.store.dispatch({
type: TOGGLE_GEMS
});
}
submit() {
this.store.dispatch({
type: COMPLETE,
payload: true
});
}
}
這個智能組件主要作用為自定義寵物標簽。
引入OnInit以及OnDestroy,分別用於初始化和銷毀訂閱。同時,需要從RxJS中引入Observable和Subscription,從ngrx/store引入Store對象。由於行為基本都在這個組件中派發,所以需要引入之前定義好的所有行為(除RESET外)。最后,引入PetTag數據模型。
該組件不需要額外的樣式,所以刪除CSS文件以及對它的引用。
該類中,tagState$定義為PetTag數據類型的可觀察對象,通過構造器中用store的select()方法賦值實現。
在ngOnInit()鈎子函數中,將subscription(訂閱)設置為對tagState$可觀察對象的訂閱。每當有新狀態生成時,訂閱就會把petTag設置為可觀察對象流返回的新狀態state。done屬性用來檢查shape和text是否已經填寫。這兩個屬性是標簽完成的必填項。當組件銷毀的時候,ngOnDestroy()鈎子函數被觸發,執行銷毀訂閱。
最后,創建派發行為至store的事件處理函數。當子木偶組件觸發事件來更新狀態時,這些事件處理函數就會執行。每個函數都用store.dispatch()派發期望的行為類型type和行為載體payload至歸約函數。
注意: 在更復雜的應用中,你可能希望在單獨的服務中派發行為,然后注入到組件中。不過,現在我們這個僅僅為學習而創建的小應用,沒有必要這么做。直接在智能組價中派發行為就行了。
形狀組件
開始創建我們的第一個展示組件:TagShapeComponent。當該組件完成時,創建頁面預期效果如下:

用Angular CLI命令一鍵生成這個子組件的腳手架:
$ ng g component pages/create/tag-shape
標簽形狀組件將會展示四種不同的形狀圖片:骨頭形,方形,圓形,心形。用戶可以從中選擇喜歡的形狀。
從git倉庫下載圖片,放置在pet-tags-ngrx/src/assets/images目錄下。
形狀組件腳本
打開tag-shape.component.ts 文件:
// src/app/pages/create/tag-shape/tag-shape.component.ts
import { Component, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'app-tag-shape',
templateUrl: './tag-shape.component.html',
styleUrls: ['./tag-shape.component.css']
})
export class TagShapeComponent {
tagShape: string;
@Output() selectShapeEvent = new EventEmitter();
constructor() { }
selectShape(shape: string) {
this.selectShapeEvent.emit(shape);
}
}
從@angular/core引入Output和EventEmitter。
形狀選擇用radio按鈕表示,所以需要一個屬性來儲存形狀。由於形狀是用字符串來描述的,我們將tagShape的類型設置為string。
當用戶選擇某個形狀之后,我們需要裝飾器@Output來觸發事件。並且發送信息至父組件CreateComponent。selectShape(shape)函數會觸發攜帶形狀信息的事件,然后父組件用先前在CreateComponent定義的selectShapeHandler()去處理。稍后我們就可以看到父子組件共同工作的效果。
形狀組件模版
在那之前,先讓我們來修改一下TagShapeComponent的模版內容。打開文件tag-shape.component.html,修改如下。
<!-- src/app/pages/create/tag-shape/tag-shape.component.html -->
<div class="row">
<div class="col-sm-12 text-center">
<h3>Shape</h3>
<p class="form-text text-muted">Choose a tag shape to get started!</p>
</div>
</div>
<div class="row">
<label class="tagShape col-sm-3">
<img src="/assets/images/bone.svg">
<input
type="radio"
name="shape"
[(ngModel)]="tagShape"
(change)="selectShape(tagShape)"
value="bone">
</label>
<label class="tagShape col-sm-3">
<img src="/assets/images/rectangle.svg">
<input
type="radio"
name="shape"
[(ngModel)]="tagShape"
(change)="selectShape(tagShape)"
value="rectangle">
</label>
<label class="tagShape col-sm-3">
<img src="/assets/images/circle.svg">
<input
type="radio"
name="shape"
[(ngModel)]="tagShape"
(change)="selectShape(tagShape)"
value="circle">
</label>
<label class="tagShape col-sm-3">
<img src="/assets/images/heart.svg">
<input
type="radio"
name="shape"
[(ngModel)]="tagShape"
(change)="selectShape(tagShape)"
value="heart">
</label>
</div>
創建四個radio按鈕分別對應四個形狀的圖片。無論選擇哪個,都會觸發(change)事件,然后觸發攜帶tagShape參數的selectShapeEvent事件。
形狀組件樣式
打開tag-shape.component.css文件, 添加樣式如下:
/* src/app/pages/create/tag-shape/tag-shape.component.css */
:host {
display: block;
margin: 20px 0;
}
.tagShape {
padding: 10px;
text-align: center;
}
img {
display: block;
height: auto;
margin: 0 auto;
max-height: 50px;
max-width: 100%;
width: auto;
}
注意::host偽類選擇器是用來獲取組件的宿主元素,也就是<app-tag-shape>。
添加形狀組件至Create頁面
最后,將TagShapeComponent添加至智能組件CreateComponent模版中,我們就算完成了。打開create.component.html文件,替換如下:
<!-- src/app/pages/create/create.component.html -->
<p class="col-sm-12 text-center lead">
Hello! Create a customized tag for your pet.
</p>
<app-tag-shape
(selectShapeEvent)="selectShapeHandler($event)"></app-tag-shape>
父組件現在能監聽到來自子組件的selectShapeEvent事件,同時通過執行之前在CreateComponent中定義的selectShapeHandler()函數來處理該事件。回憶一下,這個函數派發了SELECT_SHAPE行為至store:
selectShapeHandler(shape: string) {
this.store.dispatch({
type: SELECT_SHAPE,
payload: shape
});
}
現在,應用可以在用戶選擇形狀的時候更新狀態了。
文字組件
現在我們來創建用戶輸入字體和文字的組件。完成后,頁面期待效果如下:

用命令行來創建該組件的手腳架:
$ ng g component pages/create/tag-text
文字組件腳本
打開tag-text.component.ts文件,修改如下:
// src/app/pages/create/tag-text/tag-text.component.ts
import { Component, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'app-tag-text',
templateUrl: './tag-text.component.html',
styleUrls: ['./tag-text.component.css']
})
export class TagTextComponent {
tagTextInput = '';
fontType = 'sans-serif';
@Output() selectFontEvent = new EventEmitter;
@Output() addTextEvent = new EventEmitter;
constructor() { }
selectFont(fontType: string) {
this.selectFontEvent.emit(fontType);
}
addText(text: string) {
this.addTextEvent.emit(text);
}
}
該組件跟上一個組件TagShapeComponent工作方式相同,所以代碼也差不多。引入Output和EventEmitter,並且創建tagTextInput和fontType屬性來記錄用戶的輸入。
注意:這里我們不需要聲明string類型,因為初始值默認定義了變量的類型。
當用戶修改字體或者文字時,組件就會觸發事件讓父組件捕獲。
文字組件模板
標簽文字組件模板tag-text.component.html代碼如下:
<!-- src/app/pages/create/tag-text/tag-text.component.html -->
<div class="row">
<div class="col-sm-12 text-center">
<h3>Text</h3>
<p class="form-text text-muted">
Select your desired font style and enter your pet's name.<br>
You can see what your tag will look like in the preview below.
</p>
</div>
</div>
<div class="form-group row">
<label for="font" class="col-sm-2 offset-sm-2 col-form-label">Font:</label>
<select
id="font"
name="font"
class="form-control col-sm-6"
[(ngModel)]="fontType"
(change)="selectFont(fontType)">
<option value="sans-serif">Sans-serif</option>
<option value="serif">Serif</option>
</select>
</div>
<div class="form-group row">
<label for="tagText" class="col-sm-2 offset-sm-2 col-form-label">Text:</label>
<input
id="tagText"
type="text"
class="form-control col-sm-6"
[(ngModel)]="tagTextInput"
(input)="addText(tagTextInput)"
maxlength="8" />
</div>
我們用<select>元素和文本輸入框讓用戶操作選擇自己喜歡的效果。用戶輸入時,ngModel保證了數據的雙向一致性,(change)使得該組件觸發事件到父組件。
文字組件樣式
我們只需在tag-text.component.css中添加一個樣式:
/* src/app/pages/create/tag-text/tag-text.component.css */
:host {
display: block;
margin: 20px 0;
}
添加文字組件至Create頁面
最后將TagTextComponent組件添加到Create 頁面:
<!-- src/app/pages/create/create.component.html -->
...
<app-tag-text
*ngIf="petTag.shape"
(selectFontEvent)="selectFontHandler($event)"
(addTextEvent)="addTextHandler($event)"></app-tag-text>
注意我們在<app-tag-text>元素上加了*ngIf結構指令,我們希望用戶在選擇了標簽形狀之后才顯示該組件。我們即將創建標簽預覽組件,沒有標簽形狀的預覽沒有什么意義,*ngIf保證了這點。
在父組件中監聽TagTextComponent的selectFontEvent和addTextEvent事件,然后用之前在CreateComponent定義好的方法去處理。處理方式分別為派發SELECT_FONT和ADD_TEXT以及對應的行為載體(payload)至歸約器(reducer):
selectFontHandler(fontType: string) {
this.store.dispatch({
type: SELECT_FONT,
payload: fontType
});
}
addTextHandler(text: string) {
this.store.dispatch({
type: ADD_TEXT,
payload: text
});
}
附加特性組件
現在,我們來添加能讓用戶額外選擇的幾個特性。完成之后,創建頁面預期效果如下:

同樣用命令來創建TagExtrasComponent組件的手腳架:
$ ng g component pages/create/tag-extras
附加特性組件腳本
打開tag-extras.component.ts,修改如下:
// src/app/pages/create/tag-extras/tag-extras.component.ts
import { Component, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'app-tag-extras',
templateUrl: './tag-extras.component.html',
styleUrls: ['./tag-extras.component.css']
})
export class TagExtrasComponent {
tagClip: boolean;
gems: boolean;
@Output() toggleClipEvent = new EventEmitter;
@Output() toggleGemsEvent = new EventEmitter;
constructor() { }
toggleClip() {
this.toggleClipEvent.emit();
}
toggleGems() {
this.toggleGemsEvent.emit();
}
}
代碼看起來應該很熟悉了吧?! 額外選項包括是否添加clip和gems,所以這兩個參數是boolean類型。
附加特性組件模板
打開tag-extras.component.html,添加代碼如下:
<!-- src/app/pages/create/tag-extras/tag-extras.component.html -->
<div class="row">
<div class="col-sm-12 text-center">
<h3>Extras</h3>
<p class="form-text text-muted">Select any extras you would like to add.</p>
</div>
</div>
<div class="row">
<div class="col-sm-4 offset-sm-2">
<label>
<input
type="checkbox"
[(ngModel)]="tagClip"
(change)="toggleClip()"> Include tag clip
</label>
</div>
<div class="col-sm-4">
<label>
<input
type="checkbox"
[(ngModel)]="gems"
(change)="toggleGems()"> Add gems
</label>
</div>
</div>
用checkbox讓用戶來勾選是否要添加額外特性。
附加特性組件樣式
由於該組件是最后一個標簽編輯組件,我們希望在底部添加邊界線,打開tag-extras.component.css文件,添加如下:
/* src/app/pages/create/tag-extras/tag-extras.component.css */
:host {
border-bottom: 1px solid #ccc;
display: block;
margin: 20px 0;
padding-bottom: 20px;
}
添加附加特性組件至Create頁面
在create.component.html文件中添加如下代碼:
<!-- src/app/pages/create/create.component.html -->
...
<app-tag-extras
*ngIf="petTag.shape"
(toggleClipEvent)="toggleClipHandler()"
(toggleGemsEvent)="toggleGemsHandler()"></app-tag-extras>
跟標簽文字組件一樣,我們只在用戶選擇了標簽形狀之后才顯示附加特性選項。toggleClipEvent和toggleGemsEvent事件被之前在CreateComponent定義好的方法處理,處理方式分別為派發TOGGLE_CLIP和TOGGLE_GEMS行為至歸約器:
toggleClipHandler() {
this.store.dispatch({
type: TOGGLE_CLIP
});
}
toggleGemsHandler() {
this.store.dispatch({
type: TOGGLE_GEMS
});
}
由於選擇的切換是布爾值,不需要行為載體。這種情況,我們只需要在歸約器中使用上一個狀態來決定下一個狀態。
預覽組件
現在讓我們來創建標簽預覽組件,預期效果如下:

用命令行來創建TagPreviewComponent組件的手腳架,它將是Create頁面和Complete頁面的子組件,所以把這個組件放在app文件夾下:
$ ng g component tag-preview
預覽組件腳本
打開tag-preview.component.ts文件,修改如下:
// src/app/tag-preview/tag-preview.component.ts
import { Component, OnChanges, Input } from '@angular/core';
import { PetTag } from './../core/pet-tag.model';
@Component({
selector: 'app-tag-preview',
templateUrl: './tag-preview.component.html',
styleUrls: ['./tag-preview.component.css']
})
export class TagPreviewComponent implements OnChanges {
@Input() petTag: PetTag;
imgSrc = '';
tagClipText: string;
gemsText: string;
constructor() { }
ngOnChanges() {
this.imgSrc = `/assets/images/${this.petTag.shape}.svg`;
this.tagClipText = this.boolToText(this.petTag.clip);
this.gemsText = this.boolToText(this.petTag.gems);
}
private boolToText(bool: boolean) {
return bool ? 'Yes' : 'No';
}
}
TagPreviewComponent是接受父組件CreateComponent輸入的木偶子組件,它並不向父組件傳遞數據。引入Input裝飾器以及OnChanges生命周期函數鈎子,同時需要引入PetTag表明輸入的類型。
TagPreviewComponent類需要實現OnChanges接口,也就是在該類中調用ngOnChanges方法。每當組件的輸入參數發生變化時,ngOnChanges就會執行。這樣才能實現在用戶編輯修改的時候實時預覽效果。
從父組件中接受的數據@Input() petTag是個狀態對象,數據類型是之前定義的PetTag。比如,一個petTag對象可能是這樣的:
{
shape: 'bone',
font: 'serif',
text: 'Fawkes',
clip: true,
gems: false,
complete: false
}
我們希望友好直觀地展示數據,所以我們將會顯示標簽的形狀圖片,文案,以及是否包含了clip和gems。
當用戶進行操作時,需要專門設置一下圖片的路徑以及clip和gems的文案(Yes或者No)。輸入是由CreateComponent對tagState$這個可觀察對象的訂閱提供。
預覽組件模版
打開tag-preview.component.html文件,添加代碼如下:
<!-- src/app/tag-preview/tag-preview.component.html -->
<div *ngIf="petTag.shape" class="row tagView-wrapper">
<div class="col-sm-12">
<div class="tagView {{petTag.shape}}">
<img [src]="imgSrc" />
<div class="text {{petTag.font}}">
{{petTag.text}}
</div>
</div>
<p class="text-center">
<strong>Tag clip:</strong> {{tagClipText}}<br>
<strong>Gems:</strong> {{gemsText}}
</p>
</div>
</div>
預覽將會在用戶選擇標簽形狀之后顯示。用一個shape樣式類來控制顯示正確的形狀圖片,同時也會顯示標簽的文字,字體是用font樣式類來控制的。最后,直接標注出用戶是否選擇了包含clip,gems兩個特性。
預覽組件樣式
之前我們定義4個標簽形狀:骨頭形,方形,圓形,心形。為了優雅地展示預覽效果,我們需要額外的樣式。打開tag-preview.component.css樣式,添加如下:
/* src/app/tag-preview/tag-preview.component.css */
.tagView-wrapper {
padding-top: 20px;
}
.tagView {
height: 284px;
position: relative;
width: 100%;
}
img {
display: block;
height: 100%;
margin: 0 auto;
width: auto;
}
.text {
font-size: 48px;
position: absolute;
text-align: center;
text-shadow: 1px 1px 0 rgba(255,255,255,.8);
top: 99px;
width: 100%;
}
.bone .text,
.rectangle .text {
font-size: 74px;
top: 85px;
}
.sans-serif {
font-family: Arial, Helvetica, sans-serif;
}
.serif {
font-family: Georgia, 'Times New Roman', Times, serif;
}
除了一些定位之外,我們根據不同形狀設置了不同的字體大小,根據用戶選擇設置不同字體。
現在,我們可以添加<app-tag-preview>到Create頁面了。
將預覽組件添加至Create頁面
打開create.component.htm,將該組件添加至最底部:
<!-- src/app/pages/create/create.component.html -->
...
<app-tag-preview
[petTag]="petTag"></app-tag-preview>
方括號[...]單向綁定屬性,我們在CreateComponen組件的訂閱tagStateSubscription中已經創建了petTag,現在將其傳遞給預覽組件。
現在,我們應該可以看到標簽的實時預覽效果了:

提交完成標簽
有了標簽生成器和標簽預覽之后,添加一個Done按鈕來提交創建好的標簽到Complete頁面。完成之后,頁面如下:

我們已經在CreateComponent中創建了submit()方法,該方法派發帶有載體的COMPLETE行為至歸約器。我們只需要在create.component.html頁面創建一個調用該方法的按鈕即可:
<!-- src/app/pages/create/create.component.html -->
...
<div class="row">
<div class="col-sm-12 text-center">
<p class="form-text text-muted" *ngIf="petTag.shape">
Preview your customized tag above.<br>
If you're happy with the results,<br>
click the button below to finish!
</p>
<p>
<button
class="btn btn-success btn-lg"
*ngIf="petTag.shape"
[disabled]="!done"
(click)="submit()"
routerLink="/complete">Done</button>
</p>
</div>
</div>
我們之前在CreateComponent的tagStateSubscription對象中定義了done屬性。 當done屬性是false的時候,我們禁用提交按鈕。
this.done = !!(this.petTag.shape && this.petTag.text);
當標簽有形狀和文字時,我們就認為該標簽已經可以提交了。如果用戶已經添加了這些,他們就可以點擊提交按鈕完成標簽的創建。同時,我們將用戶導航至Complete頁面。
“Complete”頁面組件
在設置路由的時候,我們就搭建好了Complete頁面的手腳架。當這個頁面創建好之后,頁面效果如下(前提是用戶創建了一個標簽):

“Complete”頁面組件腳本
打開智能組件complete.component.ts,添加代碼如下:
// src/app/pages/complete/complete.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Subscription } from 'rxjs/Subscription';
import { Store } from '@ngrx/store';
import { RESET } from './../../core/pet-tag.actions';
import { PetTag } from './../../core/pet-tag.model';
@Component({
selector: 'app-complete',
templateUrl: './complete.component.html'
})
export class CompleteComponent implements OnInit, OnDestroy {
tagState$: Observable<PetTag>;
private tagStateSubscription: Subscription;
petTag: PetTag;
constructor(private store: Store<PetTag>) {
this.tagState$ = store.select('petTag');
}
ngOnInit() {
this.tagStateSubscription = this.tagState$.subscribe((state) => {
this.petTag = state;
});
}
ngOnDestroy() {
this.tagStateSubscription.unsubscribe();
}
newTag() {
this.store.dispatch({
type: RESET
});
}
}
CompleteComponent組件是可路由的容器組件。需要導入OnInit, OnDestroy, Observable, Subscription以及Store管理store訂閱。同時,有個重置按鈕,用戶點擊之后可以重新創建標簽。store中的狀態也會跟着重置,所以需要導入RESET行為, PetTag數據類型以及默認初始值initialTag。
這個組件不需要Bootstrap以外的樣式,所以我們可以把樣式文件complete.component.css以及相應的引用刪掉。
類似CreateComponent組件,我們需要創建tagState$的可觀察對象tagStateSubscription,以及局部變量petTag。同時,我們需要創建PetTag數據類型的emptyTag變量,然后將其賦值為initialTag。
在構造函數中,將tagState$設為store可觀察對象。然后在ngOnInit()函數中,訂閱這個可觀察對象,並且設置petTag屬性。在ngOnDestroy()函數中,通過退訂來銷毀訂閱。最后,newTag()函數派發RESET行為,使得應用的狀態重置,用戶可以繼續定制他們的下一個標簽了。
“Complete”頁面組件模板
CompleteComponent組件的模板代碼如下:
<!-- src/app/pages/complete/complete.component.html -->
<div *ngIf="petTag.complete">
<div class="row">
<p class="col-sm-12 alert alert-success">
<strong>Congratulations!</strong> You've completed a pet ID tag for <strong>{{petTag.text}}</strong>. Would you like to <a (click)="newTag()" routerLink="/create" class="alert-link">create another?</a>
</p>
</div>
<app-tag-preview [petTag]="petTag"></app-tag-preview>
</div>
<div *ngIf="!petTag.complete" class="row">
<p class="col-sm-12 alert alert-danger">
<strong>Oops!</strong> You haven't customized a tag yet. <a routerLink="/create" class="alert-link">Click here to create one now.</a>
</p>
</div>
首先展示恭喜用戶成功為他們的寵物創建個性標簽的提示,名字也會從petTag狀態對象中取過來。同時,提供一個能重新創建新標簽的鏈接,點擊之后執行newTag()方法,該方法會將路由導航到創建頁面重新開始。
接着,展示帶有petTag的標簽預覽組件: <app-tag-preview [petTag]="petTag">。
最后,如果用戶在沒有完成標簽的定制下手動導航到/complete頁面的話,我們就會顯示一個錯誤信息。同樣有個鏈接,可以讓用戶回到創建頁面。錯誤頁面效果如下:

至此,我們簡單的Angular + ngrx/store應用完成了。
題外話:你可能不需要ngrx/store
狀態管理庫很棒,但請你確保在正式投入實際項目前你已經讀過了這篇文章You Might Not Need Redux。
這個例子很簡單,因為我們是用ngrx/store來教學。當你想用來投入實際項目時,你需要權衡一下必要性以及它的利弊。Angular(吸納了RxJS)已經可以很方便地用service管理全局狀態。因此,小型簡單應用用局部變量就能很好地維護了。這種場景下,如果引入非必要的ngrx/store,可能會帶來困擾和麻煩。
而在管理大型復雜項目的狀態時,ngrx/store及其同類庫是非常出色的工具。希望你現在已經有能力判斷Redux和ngrx/store使用的原理。這樣你就能知道如何以及何時應該使用狀態管理庫了。
附狀態管理相關資源
附上一些學習狀態管理的好資源:
-
ngrx/store on GitHub
-
@ngrx/store in 10 minutes
-
Comprehensive Introduction to @ngrx/store
-
ng-conf: Reactive Angular 2 with ngrx - Rob Womald
-
Angular 2 Service Layers: Redux, RxJS and Ngrx Store - When to Use a Store and Why?
-
Getting Started with Redux - Dan Abramov on Egghead.io
Angular的服務和組件通信在小型應用中的數據傳遞變得相當簡單,但在復雜應用中仍是個棘手的問題。類似ngrx/store的全局store在組織狀態管理中起到了很大的輔助作用。希望你現在已經准備好用ngrx/store構建自己的Angular 應用了!
