這是有關Angular應用架構設計系列文章中的一篇,在這個系列當中,我會結合這近兩年中對Angular、Ionic、甚至Vuejs等框架的使用經驗,總結在應用設計和開發過程中遇到的問題、和總結的經驗,來說一下Angular應用的架構設計相關的一些問題,包括像組件設計、組件之間的數據交互與通信、Ngrx Store的使用、Rxjs的使用與響應式編程思想。這些設計思想和方法,不僅適用於Angular,也適用於Vuejs、React等前端框架。
當然,應用架構設計沒有一個放之四海皆准的標准,他只能是根據具體情況具體分析。如果大家有更好的想法,歡迎交流。
上一部分介紹 使用Data Service模式,來實現單向數據流、事件流。這實際上就是Redux模式,在React中,有Redux和Flux,在Angular中,就有Ngrx。我們先來結合之前的單向數據、事件流,看一下Ngrx的組成部分及其功能:
使用Ngrx后,所有的數據都放在Ngrx的store里,並通過select
的方式使用,select
出來的數據是一個可訂閱的Observable
數據對象;所有對數據的修改,都通過分發一個action
,由reducer
來響應這個事件,事件處理的結果要更新store里面的數據的話,就通過commit
更新數據,更新的數據會通知訂閱者去更新。
在Angular中使用Store,組件和store的關系,以及數據和事件如何交互,就是如上圖所示。我們就來看一下我們怎樣才能用好Ngrx。
模塊化、樹狀的state
在Ngrx中數據保存在store中,保存的數據叫state
,這個state可以是一個樹狀結構,我們可以將樹狀結構的第一級作為模塊,然后將每個模塊里面的數據對象也盡量的按照數據本身的關系,以樹狀方式組織。
我們來看一個簡單的實例,一個用戶中心頁,頁面的設計大致如下:
頁面上包含一些用戶信息,用戶所擁有的錢包的余額、優惠券的余額等信息,還有優惠券的列表等。
相應的,我們的store里面的數據結構,大致設計如下:
在這個結構下,我們將整個app的state分成幾個模塊,用戶信息、訂單、購物車、商品等,然后在user模塊里,包含的數據有用戶信息、用戶消息、用戶地址、用戶的優惠券、錢包等等信息。
在這個例子當中,我們把用戶的優惠券信息、錢包等信息放在用戶信息里面,這些組件使用這些數據的方式和關系如下:
用戶的state是這樣設計:
export interface UserAccount {
username: string
other_fields: string
vouchers: Array<any>
wallet: any
}
export interface UserState {
authenticated: boolean
account: UserAccount
messages: Array<any>
addresses: Array<any>
}
const initialState: UserState = {
authenticated: false,
account: null,
messages: [],
addresses: []
}
我們的select是這樣:
export const account = (state: State) => state.user.account
export const userVouchers = (state: State) => state.user.account.vouchers
export const userWallet = (state: State) => state.user.account.wallet
從這個select中我們可以看出,所有的select都是從整個store的根開始的,也就是AppState。然后根據樹狀結構一級一級的往下select,比如用戶信息就是state.user.account
。當store里面的數據發生修改時,我們是這樣修改的:
export function reducer(state = initialState, action: user.Actions): UserState {
switch (action.type) {
case user_account.GET_WALLET_SUCCESS: {
const wallet = action.wallet // 從action中得到更新的數據
return Object.assign({}, state, {
wallet: wallet
})
}
...
}
}
從這個reducer的這個方法我們可以看出,Ngrx更新store里的數據的時候,在原有的state(user模塊的state)的基礎上,更新要更新的那個對象的引用,把這個state對象里面的所有引用復制到一個新的對象里。通過這種更新方式,我們就可以:
- 更新用戶state的引用值。
- 將原先所有數據(除了被更改的)的引用復制到新的state中,這樣就能保證沒有被更改的數據的引用值沒有修改。
- 被修改的數據,它的引用也會被修改。
通過這樣的修改方式,再加上我們從store里select的數據是Observable
類型的,所以,只有被修改的數據的訂閱會被觸發,那么我們就可以通過合理的設計我們的state的數據結構和與相應的組件之間的數據關系,來更合理的處理我們的數據的交互和處理。
在我們上面的用戶信息的組件中,用戶state的每個數據被修改,整個用戶的state的引用值就會被更新,但是,它里面沒有被修改的那部分數據的引用值也不會被修改,從而它們的訂閱器也不會被觸發。
在這個實例中,我們將用戶的優惠券、錢包數據放在了用戶基本信息的對象里。實際上只是為了演示這種樹狀的數據結構,並不是說在這個例子中有什么特別的用處。
一個數據的多個響應
有時候,我們需要在一個數據被修改的時候,更新頁面上兩個地方。比如說很多應用中都會有"我的消息"頁面,用列表的方式顯示消息,在頁面的右上角也有一個用戶的未讀消息數。用戶可以點一個消息,然后這個消息直接在頁面上展開閱讀,再點一下就收縮這條消息。當一個消息被閱讀的時候,右上角的消息數會減少1。
這個例子中,用戶的state中有一個messages:
export interface UserState {
account: Account
messages: Array<any>
...
}
const initialState: UserState = {
account: null,
messages: [],
...
}
在我們的reducer中,閱讀消息的時候,可以更改這一條消息的是否已讀狀態,把所有的消息放到新的列表里(因為到更新消息的引用值),或者直接從服務器重新獲得消息列表。但是無論如何,消息列表的引用值會被修改。我們為了在頁面中2個地方更新消息數據,可以使用2種方式:
- 可以使用2個select,分別用於獲取消息列表,和統計消息列表中的未讀數。
- 使用1個獲取消息列表,然后在組件中訂閱的地方統計未讀消息數。
我推薦是第一種方式,因為這樣我們的組件就可以盡量的簡單,把有關數據和對數據的查詢操作放在select里。所以這兩個select可以這樣:
export const messages = (state: State) => state.user.messages
export const messageCount = (state: State) => {
// 過濾未讀的消息並統計數量
return _.filter(state.user.messages, msg => !msg.read).count()
}
通過這個實例,我們可以將Ngrx的select看作是從數據模型到頁面組件里數據模型的映射。所以這個select不是簡單的將store里面的數據簡單的暴露給組件,而是應該承擔數據映射的功能。
數據模型和視圖模型
在上面的例子中,我們從數據模型messages
中,通過select得到了一個新數據,也就是新消息數量,綁定到某個頁面的顯示組件中。這個state的messages數據是我們的數據模型,而這個顯示在右上角的新消息數,就是一個視圖模型,也就是在顯示組件(也可能是功能組件)中顯示的數據。下面我們就討論一下這個數據模型和視圖模型。
數據模型和視圖模型之間的關系,其實就很像我們的數據庫,其中數據模型就是數據庫中的一個個表,而視圖模型就是針對這個數據模型做的查詢操作。查詢可能是把幾個表關聯到一起展示,也可能是針對一個表根據一些條件做查詢,也可能再針對這個結果做一個統計等。
例如在一個表中,保存的是消息,里面存的發信人、收信人都是存的用戶的id,但是我們需要的數據是用戶的昵稱。那我們就可以關聯消息表和用戶表,根據用戶的id關聯,在返回的結果中包含消息和收信人、發信人的昵稱。
而在Ngrx中的select就可以當做是數據庫的SQL查詢語句,它根據store里面的數據,根據一些條件查詢,或做某一些統計,結果就是一個包含結果的Observable
對象。每當state里面的數據更新的時候,最新的數據也會通過這些select查詢被更新,並綁定到顯示組件上。
所以,我們的數據從服務上獲取,到最終顯示到頁面上經歷幾個狀態:
- 從服務器獲取的數據。
- 保存到store里面的數據,也就是數據模型。
- select以后要顯示到頁面上的數據,也就是視圖模型。
然后,會有兩個對數據的操作:
- 從服務器獲取的數據,可能會經過一些簡單的修改、合並、轉換,保存到store中,保存的時候,要從業務和數據的角度出發,更好的設計數據結構,能夠將這個數據更好的與最終的顯示組件結合。
- 我們使用select,通過對數據做一些查詢、合並、統計,得到一個最終用於展示到顯示組件的數據。
通過這種方式,我們就能讓我們的模型,和我們的展示的視圖之間更好的解耦,把對數據的查詢和轉換留在store的select里面,讓顯示組件無需為了顯示而處理數據。
視圖模型的注意點
有一點有關視圖模型需要特別說明的是,每當數據模型里面的數據修改時,所有跟這個數據有關的視圖模型的訂閱也會被觸發。
舉個例子,還是上面的用戶消息的例子。假設在我們的消息數據中有一個屬性是“是否回復”,也就是用戶回復了一條消息后,標記為true
。那么,如果用戶打開一條之前已經讀過的消息,然后進行回復。這時,用戶的messages
數據發生修改,那么上面的2個select的訂閱器都會被觸發。但是,這時候,有關未讀消息數的這個數據其實是沒有改變的,但還是被重新計算了一次。如果我們select的結果是一個對象,這時候對象的引用值發生改變,那么在頁面上的相應組件也會被刷新。
所以,在使用視圖模型的時候一定要注意,你的select使用的數據一定要經過仔細設計,不能為了頁面顯示方便,就一股腦的從根的state獲取好多數據並生成一個對象返回。這樣會嚴重影響性能。
模型state和UI state
我們保存在store中的數據,除了業務數據,其實我們也可以把頁面狀態的數據保存到store中,也就是UI state。比如說一個典型的場景就是一個比較復雜的買票頁,我可能需要輸入購買數量,選擇購買票的座位,有一些演唱會或項目還要求按照購買數量輸入購買人的身份證號。如果我們把這些數據也作為一個UI state模塊,保存在store中,那么當用戶由於一些原因跳到了其他頁面,然后再回來這個購買頁的時候,之前輸入的信息都還在。這樣對用戶的交互體驗可能會更好,特別是在手機上。
使用UI state還有一個好處就是,我們的store里面的數據完全能夠確定頁面的狀態,不管是用戶買票輸入的內容,還是支付的時候選擇的支付方式等,都保存在store中。然后當我們使用Ngrx的開發工具(chrome的DevTool插件)的時候,我們可以選擇任何一個歷史的store的狀態,這樣頁面就會按照這個時候的state來展示。這樣,當我們進行了一些操作以后,通過選擇某一個時間點的state,就能重現當時那個時間的頁面狀態,這就是Ngrx里面所說的 Time Travel。
那么,哪些數據需要保存在store中?可以使用下面兩個簡單的標准:
- 需要保存頁面的狀態。例如用戶輸入一些內容后,跳到其他頁面,再回到之前頁面,需要顯示之前輸入的內容。
- 需要頻繁
進一步解耦組件跟數據模型
剛才我們把數據的展示過程中對數據的處理,和組件直接做了解耦,也就是不在組件中轉換數據,而是在select中轉換好。但是,即便這樣,我們的store和我們的組件直接的關聯還是太緊密了,我們看一個例子:
export class UserComponent { users$ = this.store.select(state => state.users); foo$ = this.store.select(state => state.foo); bar$ = this.store.select(state => state.bar); constructor(private store: Store<ApplicationState>){} addUser(user: User): void { this.store.dispatch({type: ADD_USER, payload: {user}} } removeUser(userId: string): void { this.store.dispatch({type: REMOVE_USER, payload: {userId}} } }
根據我們上面的說法,這樣用似乎沒什么問題,數據從store中select得來,綁定到模板中,數據的更新發送到store中處理。但是,這個組件和store的關聯還是太緊密,我們的組件需要知道store中保存的數據的結構,store里面能夠處理的action,以及它需要的參數是什么樣的。
而我們在設計應用架構的時候,一直都在說解耦解耦,顯然這樣的關聯是違背了我們的解耦原則。一般我們說解耦的時候,大多數情況是要把展示邏輯和業務邏輯解耦,也就是頁面上觸發一個事件的時候不需要知道業務處理模塊里面的具體情況。在Ngrx中,就是盡量把dispatch action的部分封裝到一個Service當中,不要讓顯示組件直接去使用store內部的action。而對於數據獲取,我們還是需要知道store里面的數據結構,才能在頁面顯示。
所以,對於上面的代碼,我們可以創建一個如下的Service類:
export class UserService { // 只將state里面的用戶模塊暴露出來,組件就從該服務中通過這個user$來訪問內部數據 users$ = this.store.select(state => state.users); constructor(private store: Store<ApplicationState>, private http: Http){ } addUser(user: User): void { this.store.dispatch({type: ADD_USER, payload: {user}} } removeUser(userId: string): void { this.store.dispatch({type: REMOVE_USER, payload: {userId}} } fetchUsers(): void{ this.store.dispatch({type: GET_USER, payload: null} } }
這樣我們的這個UserService
作為store和組件直接的橋梁,將store的action隱藏起來,只給組件暴露出了很友好的事件方法。