Angular应用架构设计-3:Ngrx Store
这是有关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隐藏起来,只给组件暴露出了很友好的事件方法。