Angular應用架構設計-2:Data Service模式

deeply發表於2021-09-09

這是有關Angular應用架構設計系列文章中的一篇,在這個系列當中,我會結合這近兩年中對Angular、Ionic、甚至Vuejs等框架的使用經驗,總結在應用設計和開發過程中遇到的問題、和總結的經驗,來說一下Angular應用的架構設計相關的一些問題,包括像元件設計、元件之間的資料互動與通訊、Ngrx Store的使用、Rxjs的使用與響應式程式設計思想。這些設計思想和方法,不僅適用於Angular,也適用於Vuejs、React等前端框架。
當然,設計沒有一個放之四海皆準的標準,他只能是根據具體情況具體分析。如果大家有更好的想法,歡迎交流。

,我們提到不同的元件直接傳遞資料和事件,我們可以用一個資料service來簡化資料和事件的傳遞。那麼, 如果我們有很多的業務Service,也有很多資料Service,那我們的元件和service的依賴關係會變成生麼樣呢?會這樣:

圖片描述

看到這個圖估計很多人就已經暈了,如果要維護這種設計下的應用,也是一件極其困難的事,哪個資料由誰維護,由誰獲取?一處的資料更新後,有哪些元件的資料需要更新?某個元件的一個資料改變了,到底是誰改的資料。形象一點說就是,這是誰的乳酪?我動了誰的乳酪?我的乳酪在哪兒?誰動了我的乳酪?

單向的資料流和事件流

要解決上面的複雜依賴的問題,我們先來看一下答案,然後再一步步分析為何這樣做以及如何實現。這個答案就是:單向的資料流和事件處理。這個答案其實也回答了哲學上的三個終極問題:

  1. 我是誰?
    如果我是顯示元件?那我只能從我從上級那裡獲得資料,展示,如果需要執行什麼操作,就把要操作的事件傳送某個地方。我不能隨意篡改資料,也不能執行操作。
    如果我是功能元件?那我就負責獲取資料,把資料傳遞給我下邊的顯示元件;如果要執行操作,那就由我來呼叫。
  2. 從哪裡來?
    顯示元件的資料都來自上級;功能元件的資料都來自業務Service。
  3. 到哪裡去?
    顯示元件會把要做的事情,也就是事件發給功能元件;功能元件透過呼叫業務Service來處理這個事件。

根據這個原則,我們可以試著把元件、service之間的關係定成這樣:
圖片描述

但是這樣的話,我們的顯示元件就會依賴業務服務,從業務服務獲取資料,這違背了之前說的顯示元件的規範。雖然說這種實現方式在某些情況下可能會比較方便,但是這樣就很難實現顯示元件的重用,而且很多列表顯示的元件,它的資料都是從父元件中獲得,我們不可能再在顯示元件裡再重新獲取資料。而且,當元件和服務之間的依賴越來越密切的時候,就違背了松耦合的開發原則,這會導致可維護性越來越差。

所以,我們稍微改進一下,用這樣方式實現:
圖片描述

在這種實現方式下,我們的顯示元件只依賴資料Service,而功能元件依賴資料Service去獲取更新事件,然後再依賴業務Service去處理事件、獲取資料。那麼上面的雜亂的元件圖就可以最佳化成這樣:
圖片描述

顯然,service和元件之間的耦合度還是太高,我們可以在data service裡面去掉用業務service去讀寫資料,這樣就能進一步減少元件和服務之間的耦合度。那麼元件和服務之間的資料、事件流就是這樣:
圖片描述

最終,我們的資料是從上往下的,也就是從根元件、功能元件一級一級傳遞到顯示元件。而事件的處理是自下而上的,顯示元件將事件以Data Service為通道發給功能元件。這就是單向的資料流,和單向的事件流。

可訂閱的資料服務

我們已經定義了我們的資料服務(data service)的功能,和它跟顯示元件、功能元件的互動方式,那麼我們怎麼保證這個資料流是單向的呢?在Angular中,元件中的資料繫結,可以使用單向繫結,也可以使用雙向繫結,我們為了實現資料的單向的流,就不能使用雙向繫結。單向繫結有很多好處,最大的好處就是減少資料的異常修改,從而也減少資料的修改檢查而得到效能提升。所以,我們不但要從元件、服務的設計上保證資料流的單向,也要用。這樣,我們的資料的修改就只能由data service 呼叫業務service來修改,資料一旦完成,那麼頁面的狀態也確定了。

既然這個資料是單向的,我的功能元件怎麼知道有新事件呢?處理完事件以後,怎麼知道這個資料已經更新了呢?這就要使用Rxjs了。在Angular中,大量使用了Rxjs,例如Http服務返回的結果是Observable的,Angular中觸發事件的EventEmitter是一個Subject。所有這些都是可訂閱的,訂閱以後,就可以在有新資料的時候觸發訂閱方法。例如在上一篇文章中使用的簡單的Data Service:

@Injectable()
export class ProductSelectedService {
    private _selected: BehaviorSubjectProduct> = new BehaviorSubject(null);
    public selected$ = this._selected.asObservable();
    select(product: Product) {
         this._selected.next(product);
    }
}

使用者每次選了一個商品的時候,就呼叫這個service的select()方法,它會往裡面的Subject物件寫一個新資料,然後在功能元件裡面訂閱這個物件:

this.productSelectedService.selected$.subscribe(product => this.selectProduct(product));

每當使用者選了一個商品後,這個subscribe裡面的方法會被觸發。

所以,透過這種可訂閱的資料物件,我們的Data Service不需要反向的去檢查顯示元件的資料是否更改,功能元件也不需要回頭去Data Service去拿資料。因為所有的資料都是訂閱的。

不可變資料

有關單向事件流還有一個需要注意的就是,。舉個例子,還是京東的購物車,使用者在頁面上選了一個商品,如果在商品物件裡有一個欄位是selected,代表是否勾選,如果我們在業務service裡直接修改了這個值,那麼在頁面上就會直接顯示相應的狀態。但是我們一直強調,資料的修改應該是在業務service修改了以後,由功能元件訂閱得到更新的資料,再傳遞給顯示元件。如果我們使用可變的資料物件,就會破壞單向事件流的規定,導致我們的資料沒法統一管理。

使用不可變資料,能夠規範我們的事件處理,就不會出現同一個資料在多個地方被使用和修改,從而能避免很多潛在的bug。更重要的是,使用不可變資料可以極大的改善應用的效能。因為,一個資料物件,它的內部資料不會被修改,如果要修改,只能新建一個物件,把原先的資料(或把原先的物件指標)複製過去,那麼Angular在檢查繫結的資料是否更改的時候,是需要看這個引用值是否變了,而不用檢查裡面的資料。

如果我們使用的都是不可變資料,那我們就可以在定義元件的時候,新增一個OnPush配置:

@Component({
    selector: 'CartItemComponent',
    changeDetection: ChangeDetectionStrategy.OnPush,
    template: ...

這樣就能減少很多檢查資料修改所帶來的開銷,從而提升效能。特別是資料物件越大,它帶來的效能提升越明顯。還有在ngfor這樣的迴圈裡,也能減少很多迴圈遍歷的次數。如果使用了OnPush,就只會遍歷一次,來顯示迴圈裡面的內容。如果沒有使用OnPush,除了第一次遍歷顯示以外,還會再遍歷2,3次,來判斷裡面的資料是否修改。

可訂閱資料要注意的問題

當我們在Angular中使用Rxjs的Observable的訂閱型別資料時,在設計上也有一些需要注意的地方。

模板中的重複訂閱

我們可以直接在模板中使用Observable的資料,Angular框架會幫我們建立一個對這個資料的訂閱,並在頁面上繫結這個訂閱的資料。假設有一個訂單頁面,我們這樣使用:

  div class="order-detail">
    div>{{ (orderDetail$ | async)?.createdDate}}div>
    div>{{ (orderDetail$ | async)?.status}}div>
    div>{{ (orderDetail$ | async)?.product}}div>
    div *ngFor="let prod of (orderDetail$ | async)?.products">商品列表div>
  div>

在這個頁面對應的component裡,有一個變數orderDetail$,是一個Observable的資料,是用http服務從伺服器段返回訂單詳情的結果的訂閱。

  orderDetail$ = this.orderService.getDetail(theId)

| async是一個管道,他會對一個ObservablePromise物件進行訂閱,並返回最新的值,如果Observable有新的值,就會更新改值,並在這個元件被銷燬的時候取消訂閱。但是,這個模板裡面多次使用| async就會對這個可訂閱物件進行多次訂閱,而每次訂閱就會呼叫一下它的sunscribe()方法。那麼對於上面的用法, getDetail方法會被呼叫多次。

如果因為某些原因無法避免重複訂閱造成的重複呼叫,我們可以使用shareReplay運算子,他就像一個cache一樣,第二次呼叫的時候就會從cache中返回值。

元件中訂閱時的取消訂閱問題

為了解決上面的問題,我們可以在組建中自行訂閱,並將訂閱後的值複製到元件中的變數中,並在模板中繫結這個變數進行顯示:

@Component({...})
export class OrderDetailComponent implements OnInit {

  orderDetail: OrderDetail;
  ngOnInit() {
    this.orderService.getDetail(theId).subscribe(data => this.orderDetail = data)
  }
}

但是,我們就必須在元件的ngOnDestroy方法裡面去取消訂閱,Angular不會幫我們自動取消訂閱。這樣在元件銷燬的時候,由於這個訂閱還在,就會發生記憶體洩漏。也就是因為元件被銷燬,但是裡面的訂閱的引用還在被使用,就不會被銷燬。而且訂閱方法也會在有新資料的時候執行。

所以在使用這種方式的時候,一定要自己在銷燬方法裡面取消訂閱。

使用async as簡化

針對上述兩個問題,我們可以透過透過async as來解決:

  div *ngIf="orderDetail$ | async as orderDetail; else isLoading" class="order-detail">
    div>{{ orderDetail?.createdDate}}div>
    div>{{ orderDetail?.status}}div>
    div>{{ orderDetail?.product}}div>
    div *ngFor="let prod of orderDetail?.products">商品列表:{{prod.name}}div>
  div>
  ng-template #isLoading>
    div>正在載入...div>
  ng-template>

這樣既能解決訂閱的問題,也能解決自動取消訂閱的問題,而且還能在這個Observable正在非同步獲取資料的時候,在模板上顯示正在載入的提示。

總結

所以,在這種模式下,我們使用可訂閱的、不可修改的資料物件,實現單向的資料流和事件流,它有諸多好處:

  1. 實現元件之間、元件和服務之間的解耦,讓系統容易維護、容易擴充套件。當我們的應用越來越大、越來越複雜,這個好處就會越發明顯。
  2. 使得應用更容易測試。由於頁面展示完全由data service裡面的資料確定,我們要測試各種業務邏輯,只需要測試我們的data service,也就是呼叫方法、檢查結果。由於不牽扯到頁面,測試用例就很容易編寫,執行效率也高。
  3. data service還能用做cache,這樣可以根據情況來判斷是要重新獲取資料,還是直接使用cache的資料,這樣就能減少很多無謂的資料請求。
  4. 使用可訂閱的資料,也可以有多個訂閱者,就很容易實現針對一個資料的多個響應和更新,或者是多個地方修改同一個資料。這樣就能很方便的實現複雜的頁面互動情況下的資料響應和更新。

歡迎關注課程

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/1978/viewspace-2814072/,如需轉載,請註明出處,否則將追究法律責任。

相關文章