Angular应用架构设计-2:Data Service模式

这是有关Angular应用架构设计系列文章中的一篇,在这个系列当中,我会结合这近两年中对Angular、Ionic、甚至Vuejs等框架的使用经验,总结在应用设计和开发过程中遇到的问题、和总结的经验,来说一下Angular应用的架构设计相关的一些问题,包括像组件设计、组件之间的数据交互与通信、Ngrx Store的使用、Rxjs的使用与响应式编程思想。这些设计思想和方法,不仅适用于Angular,也适用于Vuejs、React等前端框架。当然,应用架构设计没有一个放之四海皆准的标准,他只能是根据具体情况具体分析。如果大家有更好的想法,欢迎交流。

  1. Angular应用架构设计-1:显示组件和功能组件
  2. Angular应用架构设计-2:Data Service模式
  3. Angular应用架构设计-3:Ngrx Store
  4. Angular应用架构设计-4:响应式编程
  5. Angular应用架构设计-5:设计原则

上一部分介绍显示组件和功能组件,我们提到不同的组件直接传递数据和事件,我们可以用一个数据service来简化数据和事件的传递。那么, 如果我们有很多的业务Service,也有很多数据Service,那我们的组件和service的依赖关系会变成生么样呢?会这样:
components-services.png

看到这个图估计很多人就已经晕了,如果要维护这种设计下的应用,也是一件极其困难的事,哪个数据由谁维护,由谁获取?一处的数据更新后,有哪些组件的数据需要更新?某个组件的一个数据改变了,到底是谁改的数据。形象一点说就是,这是谁的奶酪?我动了谁的奶酪?我的奶酪在哪儿?谁动了我的奶酪?

单向的数据流和事件流

要解决上面的复杂依赖的问题,我们先来看一下答案,然后再一步步分析为何这样做以及如何实现。这个答案就是:单向的数据流和事件处理。这个答案其实也回答了哲学上的三个终极问题:

  1. 我是谁?
    如果我是显示组件?那我只能从我从上级那里获得数据,展示,如果需要执行什么操作,就把要操作的事件发送某个地方。我不能随意篡改数据,也不能执行操作。
    如果我是功能组件?那我就负责获取数据,把数据传递给我下边的显示组件;如果要执行操作,那就由我来调用。
  2. 从哪里来?
    显示组件的数据都来自上级;功能组件的数据都来自业务Service。
  3. 到哪里去?
    显示组件会把要做的事情,也就是事件发给功能组件;功能组件通过调用业务Service来处理这个事件。

根据这个原则,我们可以试着把组件、service之间的关系定成这样:
data-action-flow.png

但是这样的话,我们的显示组件就会依赖业务服务,从业务服务获取数据,这违背了之前说的显示组件的规范。虽然说这种实现方式在某些情况下可能会比较方便,但是这样就很难实现显示组件的重用,而且很多列表显示的组件,它的数据都是从父组件中获得,我们不可能再在显示组件里再重新获取数据。而且,当组件和服务之间的依赖越来越密切的时候,就违背了松耦合的开发原则,这会导致可维护性越来越差。

所以,我们稍微改进一下,用这样方式实现:
data-action-flow1.png

在这种实现方式下,我们的显示组件只依赖数据Service,而功能组件依赖数据Service去获取更新事件,然后再依赖业务Service去处理事件、获取数据。那么上面的杂乱的组件图就可以优化成这样:
split-services-role.png

显然,service和组件之间的耦合度还是太高,我们可以在data service里面去掉用业务service去读写数据,这样就能进一步减少组件和服务之间的耦合度。那么组件和服务之间的数据、事件流就是这样:
data-action-flow2.png

最终,我们的数据是从上往下的,也就是从根组件、功能组件一级一级传递到显示组件。而事件的处理是自下而上的,显示组件将事件以Data Service为通道发给功能组件。这就是单向的数据流,和单向的事件流。

可订阅的数据服务

我们已经定义了我们的数据服务(data service)的功能,和它跟显示组件、功能组件的交互方式,那么我们怎么保证这个数据流是单向的呢?在Angular中,组件中的数据绑定,可以使用单向绑定,也可以使用双向绑定,我们为了实现数据的单向的流,就不能使用双向绑定。单向绑定有很多好处,最大的好处就是减少数据的异常修改,从而也减少数据的修改检查而得到性能提升。所以,我们不但要从组件、服务的设计上保证数据流的单向,也要用Angular的单向绑定。这样,我们的数据的修改就只能由data service 调用业务service来修改,数据一旦完成,那么页面的状态也确定了。

既然这个数据是单向的,我的功能组件怎么知道有新事件呢?处理完事件以后,怎么知道这个数据已经更新了呢?这就要使用Rxjs了。在Angular中,大量使用了Rxjs,例如Http服务返回的结果是Observable的,Angular中触发事件的EventEmitter是一个Subject。所有这些都是可订阅的,订阅以后,就可以在有新数据的时候触发订阅方法。例如在上一篇文章中使用的简单的Data Service:

1
2
3
4
5
6
7
8
@Injectable()
export class ProductSelectedService {
private _selected: BehaviorSubject<Product> = new BehaviorSubject(null);
public selected$ = this._selected.asObservable();
select(product: Product) {
this._selected.next(product);
}
}

用户每次选了一个商品的时候,就调用这个service的select()方法,它会往里面的Subject对象写一个新数据,然后在功能组件里面订阅这个对象:

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

每当用户选了一个商品后,这个subscribe里面的方法会被触发。

所以,通过这种可订阅的数据对象,我们的Data Service不需要反向的去检查显示组件的数据是否更改,功能组件也不需要回头去Data Service去拿数据。因为所有的数据都是订阅的。

不可变数据

有关单向事件流还有一个需要注意的就是,数据的可变性问题。举个例子,还是京东的购物车,用户在页面上选了一个商品,如果在商品对象里有一个字段是selected,代表是否勾选,如果我们在业务service里直接修改了这个值,那么在页面上就会直接显示相应的状态。但是我们一直强调,数据的修改应该是在业务service修改了以后,由功能组件订阅得到更新的数据,再传递给显示组件。如果我们使用可变的数据对象,就会破坏单向事件流的规定,导致我们的数据没法统一管理。

使用不可变数据,能够规范我们的事件处理,就不会出现同一个数据在多个地方被使用和修改,从而能避免很多潜在的bug。更重要的是,使用不可变数据可以极大的改善应用的性能。因为,一个数据对象,它的内部数据不会被修改,如果要修改,只能新建一个对象,把原先的数据(或把原先的对象指针)拷贝过去,那么Angular在检查绑定的数据是否更改的时候,是需要看这个引用值是否变了,而不用检查里面的数据。

如果我们使用的都是不可变数据,那我们就可以在定义组件的时候,添加一个OnPush配置:

1
2
3
4
@Component({
selector: 'CartItemComponent',
changeDetection: ChangeDetectionStrategy.OnPush,
template: ...

这样就能减少很多检查数据修改所带来的开销,从而提升性能。特别是数据对象越大,它带来的性能提升越明显。还有在ngfor这样的循环里,也能减少很多循环遍历的次数。如果使用了OnPush,就只会遍历一次,来显示循环里面的内容。如果没有使用OnPush,除了第一次遍历显示以外,还会再遍历2,3次,来判断里面的数据是否修改。

可订阅数据要注意的问题

当我们在Angular中使用Rxjs的Observable的订阅类型数据时,在设计上也有一些需要注意的地方。

模板中的重复订阅

我们可以直接在模板中使用Observable的数据,Angular框架会帮我们创建一个对这个数据的订阅,并在页面上绑定这个订阅的数据。假设有一个订单页面,我们这样使用:

1
2
3
4
5
6
<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服务从服务器段返回订单详情的结果的订阅。

1
orderDetail$ = this.orderService.getDetail(theId)

| async是一个管道,他会对一个ObservablePromise对象进行订阅,并返回最新的值,如果Observable有新的值,就会更新改值,并在这个组件被销毁的时候取消订阅。但是,这个模板里面多次使用| async就会对这个可订阅对象进行多次订阅,而每次订阅就会调用一下它的sunscribe()方法。那么对于上面的用法, getDetail方法会被调用多次。

如果因为某些原因无法避免重复订阅造成的重复调用,我们可以使用shareReplay操作符,他就像一个cache一样,第二次调用的时候就会从cache中返回值。

组件中订阅时的取消订阅问题

为了解决上面的问题,我们可以在组建中自行订阅,并将订阅后的值复制到组件中的变量中,并在模板中绑定这个变量进行显示:

1
2
3
4
5
6
7
8
@Component({...})
export class OrderDetailComponent implements OnInit {
orderDetail: OrderDetail;
ngOnInit() {
this.orderService.getDetail(theId).subscribe(data => this.orderDetail = data)
}
}

但是,我们就必须在组件的ngOnDestroy方法里面去取消订阅,Angular不会帮我们自动取消订阅。这样在组件销毁的时候,由于这个订阅还在,就会发生内存泄漏。也就是因为组件被销毁,但是里面的订阅的引用还在被使用,就不会被销毁。而且订阅方法也会在有新数据的时候执行。

所以在使用这种方式的时候,一定要自己在销毁方法里面取消订阅。

使用async as简化

针对上述两个问题,我们可以通过通过async as来解决:

1
2
3
4
5
6
7
8
9
<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. 使用可订阅的数据,也可以有多个订阅者,就很容易实现针对一个数据的多个响应和更新,或者是多个地方修改同一个数据。这样就能很方便的实现复杂的页面交互情况下的数据响应和更新。
坚持原创技术分享,您的支持将鼓励我继续创作!