閱讀1005 返回首頁    go 阿裏雲 go 技術社區[雲棲]


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_mvvm.png
在這種情況下,counter隻能被當前component修改,一切工作正常。

現在我們增加一下需求,要求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數據

  1. 會被當前counter component中的函數修改
  2. 也可能被initCounter修改
  3. 如果涉及到服務端數據,counter也可以被Service修改
  4. 在複雜的應用中,還可能在父component通過@ViewChild等方式獲取後被修改

框架本身對此並沒有進行限製,如果開發者對數據的修改沒有進行合理的規劃時,很容易導致數據的變更難以被追蹤。

與AngularJs 1.x版本中在特定函數執行時進行髒值檢查不同,Angular 2+使用了zone.js對所有的常用操作進行了monkey patch,有了zone.js的存在,Angular不再像之前一樣需要使用特定的封裝函數才能對數據的修改進行感知,例如ng-click或者$timeout等,隻需要正常使用(click)或者setTimeout就可以了。

與此同時,數據在任意的地方可以被修改給使用者帶來了便利的同時也帶來了性能的降低,由於無法預判髒值產生的時機,Angular需要在每個瀏覽器事件後去檢查更新template中綁定數值的變化,雖然Angular做了大量的優化來保證性能,並且成果顯著(目前主流前端框架的跑分對比),但是Angular也提供了另一種開發方式。

Immutable & ChangeDetection

在Angular開發中,可以通過將component的changeDetection定義為ChangeDetectionStrategy.OnPush從而改變Angular的髒值檢查策略,在使用OnPush模式時,Angular從時刻進行髒值檢查的狀態改變為僅在兩種情況下進行髒值檢查,分別是

  1. 當前component的@Input輸入值發生更換
  2. 當前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>

counter_immutable.png

這個時候點擊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 poweredinspired by Redux,靠譜。

如果你對RxJS有進一步了解的興趣,請訪問https://rxjs-cn.github.io/rxjs5-ultimate-cn/

redux

基本概念

和Redux一樣,ngrx也有著相同View、Action、Middleware、Dispatcher、Store、Reducer、State的概念。使用ngrx構建Angular應用需要舍棄Angular官方提供的@Input和@Output的數據雙向流動的概念。改用Component->Action->Reducer->Store->Component的單向數據流動。

ngrx.png

以下部分代碼來源於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方法傳入的內容包括typepayload兩部分, reducer會根據typepayload生成不同的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其實提供了不同的開發模式

  1. Angular默認的數據流和髒值檢查方式其實適用於絕大部分的開發場景。
  2. 當性能遇到瓶頸時(基本不會遇到),可以更改ChangeDetection,保證傳入數據Immutable來提升性能。
  3. 當MVVM不再能滿足程序開發的要求時,可以嚐試使用Ngrx進行函數式編程。

這篇文章總結了很多Ngrx優缺點,其中我覺得比較Ngrx顯著的優點是

  1. 數據層不僅相對於component獨立,也相對於框架獨立,便於移植到其他框架
  2. 數據單向流動,便於追蹤

Ngrx的缺點也很明顯

  1. 實現同樣功能,代碼量更大,對於簡單程序而言使用Immutable過度設計,降低開發效率
  2. FP思維和OOP思維不同,開發難度更高

參考資料

  1. Immutability vs Encapsulation in Angular Applications
  2. whats-the-difference-between-markforcheck-and-detectchanges
  3. Angular 也走 Redux 風 (使用 Ngrx)
  4. Building a Redux application with Angular 2

最後更新:2017-06-30 11:02:58

  上一篇:go  搞一搞Main Thread Checker
  下一篇:go  阿裏雲前端周刊 - 第 13 期