文章摘要: 可訂閱資料要注意的問題 當我們在Angular中使用Rxjs的 Observable 的訂閱型別資料時使用可訂閱的資料
這是有關Angular應用架構設計系列文章中的一篇,在這個系列當中,我會結合這近兩年中對Angular、Ionic、甚至Vuejs等框架的使用經驗,總結在應用設計和開發過程中遇到的問題、或總結的經驗,來說一下Angular應用的架構設計相關的一些問題,包括像元件設計、元件直接的資料互動與通訊、Ngrx Store的使用、Rxjs的使用與響應式程式設計思想。這些設計思想和方法,不僅適用於Angular,也適用於Vuejs、React等前端框架。當然,應用架構設計沒有一個放之四海皆準的標準,他只能是根據具體情況具體分析,得出一個儘可能有點設計方案。如果大家有更好的想法,歡迎交流。
上一部分介紹顯示元件和功能元件,我們提到不同的元件直接傳遞資料和事件,我們可以用一個數據service來簡化資料和事件的傳遞。那麼, 如果我們有很多的業務Service,也有很多資料Service,那我們的元件和service的依賴關係會變成生麼樣呢?會這樣:
看到這個圖估計很多人就已經暈了,如果要維護這種設計下的應用,也是一件極其困難的事,哪個資料由誰維護,由誰獲取?一處的資料更新後,有哪些元件的資料需要更新?某個元件的一個數據改變了,到底是誰改的資料。形象一點說就是,這是誰的乳酪?我動了誰的乳酪?我的乳酪在哪兒?誰動了我的乳酪?
單向的數據流和事件流
要解決上面的複雜依賴的問題,我們先來看一下答案,然後再一步步分析為何這樣做以及如何實現。這個答案就是:單向的數據流和事件處理。這個答案其實也回答了哲學上的三個終極問題:
- 我是誰?
我是顯示元件?那我只能從我從上級那裏獲得資料,展示,如果需要執行什麼操作,就把要操作的事件傳送某個地方。我不能隨意篡改資料,也不能執行操作。
我是給你元件?那我就負責獲取資料,把資料傳遞給我下邊的顯示元件;如果要執行操作,那就由我來呼叫。 - 從哪裏來?
我的資料都來自上級。 - 到哪裏去?
我會把要做的事情傳送給指定的物件處理。
根據這個原則,我們可以試著把元件、service之間的關係定成這樣:
但是這樣的話,我們的顯示元件就會依賴業務服務,從業務服務獲取資料,這違背了之前說的顯示元件的規範。雖然說這種實現方式在某些情況下可能會比較方便,但是這樣就很難實現顯示元件的重用,而且很多列表顯示的元件,它的資料都是從父元件中獲得,我們不可能再在顯示元件裡再重新獲取資料。而且,當元件和服務之間的依賴越來越密切的時候,就違背了鬆耦合的開發原則,這會導致可維護性越來越差。
所以,我們稍微改進一下,用這樣方式實現:
在這種實現方式下,我們的顯示元件只依賴資料Service,而功能元件依賴資料Service去獲取更新事件,然後再依賴業務Service去處理事件、獲取資料。那麼上面的雜亂的元件圖就可以優化成這樣:
顯然,service和元件之間的耦合度還是太高,我們可以在data service裏面去掉用業務service去讀寫資料,這樣就能進一步減少元件和服務之間的耦合度。那麼元件和服務之間的資料、事件流就是這樣:
最終,我們的資料是從上往下的,也就是從根元件、功能元件一級一級傳遞到顯示元件。而事件的處理是自下而上的,顯示元件將事件以Data Service為通道發給功能元件。這就是單向的數據流,和單向的事件流。
可訂閱的資料服務
我們已經定義了我們的資料服務(data service)的功能,和它跟顯示元件、功能元件的互動方式,那麼我們怎麼保證這個數據流是單向的呢?在Angular中,元件中的資料繫結,可以使用單向繫結,也可以使用雙向繫結,我們爲了實現資料的單向的流,就不能使用雙向繫結。單向繫結有很多好處,最大的好處就是減少資料的異常修改,從而也減少資料的修改檢查而得到效能提升。所以,我們不但要從元件、服務的設計上保證數據流的單向,也要用Angular的單向繫結。這樣,我們的資料的修改就只能由data service 呼叫業務service來修改,資料一旦完成,那麼頁面的狀態也確定了。
既然這個資料是單向的,我的功能元件怎麼知道有新事件呢?處理完事件以後,怎麼知道這個資料已經更新了呢?這就要使用Rxjs了。在Angular中,大量使用了Rxjs,例如Http服務返回的結果是 Observable
的,Angular中觸發事件的 EventEmitter
是一個 Subject
。所有這些都是可訂閱的,訂閱以後,就可以在有新資料的時候觸發訂閱方法。例如在上一篇文章中使用的簡單的Data Service:
@Injectable() export class ProductSelectedService{ private _selected: BehaviorSubject= 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
,就只會遍歷一次,來顯示迴圈裏面的內容。如果沒有,除了第一次遍歷顯示以外,還會再遍歷2,3次,來判斷裏面的資料是否修改。
總結
所以,在這種模式下,我們使用可訂閱的、不可修改的資料物件,實現單向的數據流和事件流,它有諸多好處:
- 實現元件之間、元件和服務之間的解耦,讓系統容易維護、容易擴充套件。當我們的應用越來越大、越來越複雜,這個好處就會越發明顯。
- 使得應用更容易測試。由於頁面展示完全由data service裏面的資料確定,我們要測試各種業務邏輯,只需要測試我們的data service,也就是呼叫方法、檢查結果。由於不牽扯到頁面,測試用例就很容易編寫,執行效率也高。
- data service還能用做cache,這樣可以根據情況來判斷是要重新獲取資料,還是直接使用cache的資料,這樣就能減少很多無謂的資料請求。
- 使用可訂閱的資料,也可以有多個訂閱者,就很容易實現針對一個數據的多個響應和更新,或者是多個地方修改同一個資料。這樣就能很方便的實現複雜的頁面互動情況下的資料響應和更新。
可訂閱資料要注意的問題
當我們在Angular中使用Rxjs的 Observable
的訂閱型別資料時,在設計上也有一些需要注意的地方。
模板中的重複訂閱
我們可以直接在模板中使用 Observable
的資料,Angular框架會幫我們建立一個對這個資料的訂閱,並在頁面上繫結這個訂閱的資料。假設有一個訂單頁面,我們這樣使用:
{{ (orderDetail$ | async)?.createdDate}}{{ (orderDetail$ | async)?.status}}{{ (orderDetail$ | async)?.product}}商品列表
在這個頁面對應的component裡,有一個變數orderDetail$,是一個 Observable
的資料,是用 http
服務從伺服器段返回訂單詳情的結果的訂閱。
orderDetail$ = this.orderService.getDetail(theId)
| async
是一個管道,他會對一個 Observable
或 Promise
物件進行訂閱,並返回最新的值,如果 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
來解決:
{{ orderDetail?.createdDate}}{{ orderDetail?.status}}{{ orderDetail?.product}}商品列表:{{prod.name}} 正在載入...
這樣既能解決訂閱的問題,也能解決自動取消訂閱的問題,而且還能在這個 Observable
正在非同步獲取資料的時候,在模板上顯示正在載入的提示。