Immutable & Redux in Angular Way
寫在前面
AngularJS 1.x版本作為上一代MVVM的架構取得了巨大的成功,現在一提到Angular,哪怕是已經和1.x版本完全不相容的Angular 2.x(目前最新的版本号為4.2.2),大家還是把其作為典型的MVVM架構,MVVM的優點Angular自然有,MVVM的缺點也變成了Angular的缺點一直被人诟病。
其實,從Angular 2開始,Angular的資料流動完全可以由開發者自由控制,是以無論是快速便捷的雙向綁定,還是現在風頭正盛的
Redux
,在Angular架構中其實都可以得到很好的支援。
Mutable
我們以最簡單的計數器應用舉例,在這個例子中,counter的數值可以由按鈕進行加減控制。
counter.component.ts代碼
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
@Component({
selector : 'app-counter',
templateUrl : './counter.component.html',
styleUrls : []
})
export class CounterComponent {
@Input()
counter = {
payload: 1
};
increment() {
this.counter.payload++;
}
decrement() {
this.counter.payload--;
}
reset() {
this.counter.payload = 1;
}
}
counter.component.html代碼
<p>Counter: {{ counter.payload }}</p>
<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
<button (click)="reset()">Reset</button>
現在我們增加一下需求,要求counter的初始值可以被修改,并且将修改後的counter值傳出。在Angular中,資料的流入和流出分别由@Input和@Output來控制,我們分别定義counter component的輸入和輸出,将counter.component.ts修改為
import { Component, Input, Output, EventEmitter } from '@angular/core';
@Component({
selector : 'app-counter',
templateUrl: './counter.component.html',
styleUrls : []
})
export class CounterComponent {
@Input() counter = {
payload: 1
};
@Output() onCounterChange = new EventEmitter<any>();
increment() {
this.counter.payload++;
this.onCounterChange.emit(this.counter);
}
decrement() {
this.counter.payload--;
this.onCounterChange.emit(this.counter);
}
reset() {
this.counter.payload = 1;
this.onCounterChange.emit(this.counter);
}
}
當其他component需要使用counter時,app.component.html代碼
<counter [counter]="initCounter" (onCounterChange)="onCounterChange($event)"></counter>
app.component.ts代碼
import { Component } from '@angular/core';
@Component({
selector : 'app-root',
templateUrl: './app.component.html',
styleUrls : [ './app.component.less' ]
})
export class AppComponent {
initCounter = {
payload: 1000
}
onCounterChange(counter) {
console.log(counter);
}
}
在這種情況下counter資料
- 會被目前counter component中的函數修改
- 也可能被initCounter修改
- 如果涉及到服務端資料,counter也可以被Service修改
- 在複雜的應用中,還可能在父component通過
等方式擷取後被修改@ViewChild
架構本身對此并沒有進行限制,如果開發者對資料的修改沒有進行合理的規劃時,很容易導緻資料的變更難以被追蹤。
與AngularJs 1.x版本中在特定函數執行時進行髒值檢查不同,Angular 2+使用了zone.js對所有的常用操作進行了,有了zone.js的存在,Angular不再像之前一樣需要使用特定的封裝函數才能對資料的修改進行感覺,例如
monkey patch
或者
ng-click
等,隻需要正常使用
$timeout
或者
(click)
就可以了。
setTimeout
與此同時,資料在任意的地方可以被修改給使用者帶來了便利的同時也帶來了性能的降低,由于無法預判髒值産生的時機,Angular需要在每個浏覽器事件後去檢查更新template中綁定數值的變化,雖然Angular做了大量的優化來保證性能,并且成果顯著(目前主流前端架構的跑分對比),但是Angular也提供了另一種開發方式。
Immutable & ChangeDetection
在Angular開發中,可以通過将component的
changeDetection
定義為
ChangeDetectionStrategy.OnPush
進而改變Angular的髒值檢查政策,在使用
OnPush
模式時,Angular從時刻進行髒值檢查的狀态改變為僅在兩種情況下進行髒值檢查,分别是
- 目前component的@Input輸入值發生更換
- 目前component或子component産生事件
反過來說就是當@Input對象mutate時,Angular将不再進行自動髒值檢測,這個時候需要保證@Input的資料為Immutable
将counter.component.ts修改為
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector : 'app-counter',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl : './counter.component.html',
styleUrls : []
})
export class CounterComponent {
@Input() counter = {
payload: 1
};
@Output() onCounterChange = new EventEmitter<any>();
increment() {
this.counter.payload++;
this.onCounterChange.emit(this.counter);
}
decrement() {
this.counter.payload--;
this.onCounterChange.emit(this.counter);
}
reset() {
this.counter.payload = 1;
this.onCounterChange.emit(this.counter);
}
}
将app.component.ts修改為
import { Component } from '@angular/core';
@Component({
selector : 'app-root',
templateUrl: './app.component.html',
styleUrls : [ './app.component.less' ]
})
export class AppComponent {
initCounter = {
payload: 1000
}
onCounterChange(counter) {
console.log(counter);
}
changeData() {
this.initCounter.payload = 1;
}
}
将app.component.html修改為
<app-counter [counter]="initCounter" (onCounterChange)="onCounterChange($event)"></app-counter>
<button (click)="changeData()">change</button>
這個時候點選change發現counter的值不會發生變化。
将app.component.ts中changeData修改為
changeData() {
this.initCounter = {
...this.initCounter,
payload: 1
}
}
counter值的變化一切正常,以上的代碼使用了Typescript 2.1開始支援的 Object Spread,和以下代碼是等價的
changeData() {
this.initCounter = Object.assign({}, this.initCounter, { payload: 1 });
}
在ChangeDetectionStrategy.OnPush時,可以通過ChangeDetectorRef.markForCheck()進行髒值檢查,官網範點選此處,手動markForCheck可以減少Angular進行髒值檢查的次數,但是不僅繁瑣,而且也不能解決資料變更難以被追蹤的問題。
通過保證@Input的輸入Immutable可以提升Angular的性能,但是counter資料在counter component中并不是Immutable,資料的修改同樣難以被追蹤,下一節我們來介紹使用Redux思想來建構Angular應用。
Redux & Ngrx Way
Redux來源于React社群,時至今日已經基本成為React的标配了。Angular社群實作Redux思想最流行的第三方庫是ngrx,借用官方的話來說
RxJS powered
,
inspired by Redux
,靠譜。
如果你對RxJS有進一步了解的興趣,請通路https://rxjs-cn.github.io/rxj...
基本概念
和Redux一樣,ngrx也有着相同View、Action、Middleware、Dispatcher、Store、Reducer、State的概念。使用ngrx建構Angular應用需要舍棄Angular官方提供的@Input和@Output的資料雙向流動的概念。改用Component->Action->Reducer->Store->Component的單向資料流動。
以下部分代碼來源于CounterNgrx和這篇文章
我們使用ngrx建構同樣的counter應用,與之前不同的是這次需要依賴
@ngrx/core
和
@ngrx/store
Component
app.module.ts代碼,将counterReducer通過StoreModule import
import {BrowserModule} from '@angular/platform-browser';
import {NgModule} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {HttpModule} from '@angular/http';
import {AppComponent} from './app.component';
import {StoreModule} from '@ngrx/store';
import {counterReducer} from './stores/counter/counter.reducer';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
FormsModule,
HttpModule,
StoreModule.provideStore(counterReducer),
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {
}
在NgModule中使用ngrx提供的StoreModule将我們的counterReducer傳入
app.component.html
<p>Counter: {{ counter | async }}</p>
<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
<button (click)="reset()">Reset</button>
注意多出來的async的pipe,async管道将自動subscribe Observable或Promise的最新資料,當Component銷毀時,async管道會自動unsubscribe。
app.component.ts
import {Component} from '@angular/core';
import {CounterState} from './stores/counter/counter.store';
import {Observable} from 'rxjs/observable';
import {Store} from '@ngrx/store';
import {DECREMENT, INCREMENT, RESET} from './stores/counter/counter.action';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
counter: Observable<number>;
constructor(private store: Store<CounterState>) {
this.counter = store.select('counter');
}
increment() {
this.store.dispatch({
type: INCREMENT,
payload: {
value: 1
}
});
}
decrement() {
this.store.dispatch({
type: DECREMENT,
payload: {
value: 1
}
});
}
reset() {
this.store.dispatch({type: RESET});
}
}
在Component中可以通過依賴注入ngrx的Store,通過Store select擷取到的counter是一個Observable的對象,自然可以通過async pipe顯示在template中。
dispatch方法傳入的内容包括
type
和
payload
兩部分, reducer會根據
type
和
payload
生成不同的
state
,注意這裡的store其實也是個Observable對象,如果你熟悉Subject,你可以暫時按照Subject的概念來了解它,store也有一個next方法,和dispatch的作用完全相同。
Action
counter.action.ts
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const RESET = 'RESET';
Action部分很簡單,reducer要根據dispath傳入的action執行不同的操作。
Reducer
counter.reducer.ts
import {CounterState, INITIAL_COUNTER_STATE} from './counter.store';
import {DECREMENT, INCREMENT, RESET} from './counter.action';
import {Action} from '@ngrx/store';
export function counterReducer(state: CounterState = INITIAL_COUNTER_STATE, action: Action): CounterState {
const {type, payload} = action;
switch (type) {
case INCREMENT:
return {...state, counter: state.counter + payload.value};
case DECREMENT:
return {...state, counter: state.counter - payload.value};
case RESET:
return INITIAL_COUNTER_STATE;
default:
return state;
}
}
Reducer函數接收兩個參數,分别是state和action,根據Redux的思想,reducer必須為純函數(Pure Function),注意這裡再次用到了上文提到的Object Spread。
Store
counter.store.ts
export interface CounterState {
counter: number;
}
export const INITIAL_COUNTER_STATE: CounterState = {
counter: 0
};
Store部分其實也很簡單,定義了couter的Interface和初始化state。
以上就完成了Component->Action->Reducer->Store->Component的單向資料流動,當counter發生變更的時候,component會根據counter數值的變化自動變更。
總結
同樣一個計數器應用,Angular其實提供了不同的開發模式
- Angular預設的資料流和髒值檢查方式其實适用于絕大部分的開發場景。
- 當性能遇到瓶頸時(基本不會遇到),可以更改ChangeDetection,保證傳入資料Immutable來提升性能。
- 當MVVM不再能滿足程式開發的要求時,可以嘗試使用Ngrx進行函數式程式設計。
這篇文章總結了很多Ngrx優缺點,其中我覺得比較Ngrx顯著的優點是
- 資料層不僅相對于component獨立,也相對于架構獨立,便于移植到其他架構
- 資料單向流動,便于追蹤
Ngrx的缺點也很明顯
- 實作同樣功能,代碼量更大,對于簡單程式而言使用Immutable過度設計,降低開發效率
- FP思維和OOP思維不同,開發難度更高
參考資料
- Immutability vs Encapsulation in Angular Applications
- whats-the-difference-between-markforcheck-and-detectchanges
- Angular 也走 Redux 風 (使用 Ngrx)
- Building a Redux application with Angular 2