这是有关Angular应用架构设计系列文章中的一篇,在这个系列当中,我会结合这近两年中对Angular、Ionic、甚至Vuejs等框架的使用经验,总结在应用设计和开发过程中遇到的问题、和总结的经验,来说一下Angular应用的架构设计相关的一些问题,包括像组件设计、组件之间的数据交互与通信、Ngrx Store的使用、Rxjs的使用与响应式编程思想。这些设计思想和方法,不仅适用于Angular,也适用于Vuejs、React等前端框架。
当然,应用架构设计没有一个放之四海皆准的标准,他只能是根据具体情况具体分析。如果大家有更好的想法,欢迎交流。
在我们开始开发Angular应用的时候,一开始想的应该是如何设计应用(或某个功能页面)的结构,首先需要考虑的就是如何设计组件结构。在Angular应用中,页面都是按照一个个组件去设计,整个页面就是一个组件树,而我们的一些组件,都要尽量的重用。比如在一个商城类的app中,一般都会有一个我的地址列表的组件,而这个组件会在几个地方使用,如下单的时候选快递地址,还有在维护我的地址的时候编辑、设置默认地址等。那么我们应该怎么设计我们的组件,以及组件的功能,才能更好地实现这些组件的重用性、可用性,我们需要回答几个问题:
- 页面的结构是什么样的,组件结构是什么样的,我们应该怎样在多细的粒度上设计我们的组件?
- 数据是如何获取的,哪些数据是要在这个组件中获取,哪些数据是从父组件中传递过来?
- 事件处理该如何处理,是在当前组件中处理事件,发送请求给服务器,然后把结果更新到当前组件?还是把事件传到父组件中,由某个父组件统一处理事件?
- 我们的组件怎样才能实现可重用,而且不会为了可重用,而导致组件的设计过于复杂?
如果我们仔细想想这些问题,就会发现,我们的组件基本上可以被分成2种类型:显示组件和功能组件。
显示组件和功能组件
显示组件
显示组件,顾名思义就是用于展示的组件,回答上面的问题的话,显示组件就是:
- 页面上一块相对独立的区域。
- 自己不产生数据,数据是从父组件通过
Input
获得,或者从某个数据存储对象获得。 - 自己不处理事件,组件内产生的事件,通过
emit
到某个专门的父组件来处理。 - 由于这种组件只用于展示,不处理功能,所以能最大限度的重用。
显示组件,英文叫Presentation Components,其实也可以叫它视图组件,我叫他显示组件,只是想强调他只用于显示,不处理业务,可重用。
功能组件
与显示组件相对应的,我叫它功能组件,它是:
- 页面上包含一些显示组件的组件,也有可能在某个功能组件包含另一个功能组件。
- 获取数据后,传给显示组件来进行展示,或者获取数据后保存到某个数据存储对象中,例如之后会说到的Ngrx 当中。
- 响应显示组件产生的事件,处理业务并更新数据,将更新后的数据再更新到显示组件。
- 一般没办法重用
一般情况某个功能页面就是一个大的功能组件,它里面包含其他的功能组件和显示组件,一个显示组件又包含其他的一些显示组件。
那么,什么时候该用那种组件去设计页面结构呢?一个简单的做法是:
- 每个页面是一个功能组件
- 页面中每个组件重复显示、或列表显示的部分,就是一个显示组件。
- 如果在多个功能页面中,都有一样(或类似)的展示部分,就把这部分设计成显示组件。
除了上面说的简单原则以外,还有一个原则是从数据角度出发。一般情况下,我们的数据是从服务器获得,这个数据对象在页面展示的时候,会在几个组件中展示,然后这些展示的组件也可能要做一些业务操作。这些情况下,这些组件也可以设计成显示组件,然后把里面的业务处理的部分,用事件的方式传递给外层的功能组件来处理。然后功能组件处理完以后再更新数据(可能从服务器重新获取),再更新给显示组件。
举个例子来说,在一个订单详情页,包含订单基本信息组件,地址组件和其他详情组件,如下所示:
其中,订单基本信息组件包含一些按钮,根据订单状态,分别显示取消订单、支付、确认收货、评价等按钮。但是我们的订单的详细数据是通过一个接口一次性获得,那么在这种情况下,比较合理的设计应该是在订单详情的这个功能组件里实现对订单的各种操作,而不是在那个显示组件里。
数据传递与事件处理
当初提到显示组件,跟功能组件之间数据传递的问题,一般都是功能组件获得数据,传递给显示组件。这样传递数据有一个问题就是,当我们的组件树比较复杂的时候,一个功能组件包含另一个功能组件,然后那个功能组件又包含了一层一层的显示组件。那么我们最里层的显示组件要用一个数据,就需要从外面的功能组件里面一级一级的用Input的方式传递,一直传到最里层,而中间那些组件很可能根本不需要用这个数据,而仅仅只是做了一个传递。
跟数据传递类似,如果我们的事件都是通过emit
的方式提交到功能组件去处理,那么,当我们要处理某个事件的时候,也很可能要从最里面的显示组件,把事件一级一级的传上去,传到功能组件再处理。这显然是不可接受的。
为了更清楚的说明这个问题,我们来看一个实际的例子,是京东的购物车的例子:
在这个页面中,如果我们要用Angular来设计组件,那么整个购物车页面就是一个功能组件,然后每个店铺是一个显示组件,最后每个购物车里的商品又是一个显示组件。他们就是一个简单的3层的树形结构。
这个页面当中比较麻烦的就是事件处理,当在最里面的商品组件里选一个商品的时候,店铺组件会更新店铺的总金额,最外层功能组件的总金额也会更新;当一个店铺的所有商品都被选择了,那么店铺前面(也就是左上角)的复选框会被选择;如果所有商品都被选择,最上面全选的复选框也会被选择。也就是说一个显示组件的数据更新,会导致其他几个显示组件和功能组件的数据的更新。
对于这个例子,最好的方式当然是在最外面的功能组件里处理事件并更新数据,然后每个显示组件只需要及时更新这些数据即可。当然我们也不想把数据和事件在整个树中来回传递。对于这种情况,我们可以使用一种数据服务组件。
我们定一个service:
@Injectable()
export class ProductSelectedService {
private _selected: BehaviorSubject<Product> = new BehaviorSubject(null);
public selected$ = this._selected.asObservable();
select(product: Product) {
this._selected.next(product);
}
}
在这个sevice里,我们只是把用户选择、取消选择商品的事件,加到一个Rxjs的Subject里,可以把它理解成一个事件管道,在商品组件里,我们需要注入这个服务,当用户选商品的时候,调用select方法:
@Component({...})
export class CartProductComponent implements OnInit {
@Input product: Product;
constructor(
private productSelectedService: ProductSelectedService) {
}
ngOnInit() {
....
}
selectProduct(product) {
this.productSelectedService.select(product);
}
}
然后在我们最外面的功能组件里面,订阅这个消息,处理这个事件:
@Component({...})
export class CartComponent implements OnInit {
products: Product[];
constructor(
private productsService: ProductsService,
private productSelectedService: ProductSelectedService) {}
ngOnInit() {
//....
this.productSelectedService.selected$.subscribe(product => this.selectProduct(product));
}
selectProduct(product) {
//遍历所有购物车的商品,找到那个商品,改变它是否selected的值,并修改店铺的购物车商品金额,和总金额。
}
}
这样,我们就能把我们数据的处理,通过一个单独的service来实现,避免了事件传递的麻烦。当然我们也可以把数据传递也通过这种方式实现,也就是把所有购物车商品数据放在类似的service里,然后过滤每个店铺的商品显示到店铺组件。
当然,也不是所有的数据传递都应该使用数据service。在这个例子当中,我们在最外面的功能组件中活动所有商品,然后按店铺分组,分别传给店铺组件,店铺组件再把每个商品传给每个商品组件,这样可能更加简洁。
如何保证显示组件
在上面的例子当中,我们创建了一个ProductSelectedService
,在购物车商品的显示组件当中,依赖了这个service,来触发商品选择的事件。那么,我们是否应该在显示组件当中依赖其他service组件?如果依赖了,那他还算是显示组件吗?
如果以纯粹的显示组件来说,我们的显示组件不应该依赖其他的service,虽然外面只是把事件传给了依赖的service,并没有处理什么业务,但是,既然我们依赖了这个service,调用了他的某一个方法,那我们就没有一个严格规范说,他就不能再调用别的service的别的方法。一个纯粹的显示组件,应该是不依赖任何service的,这样才能严格保证它不会调用任何servcie的业务方法。
在之后的一篇文章中,我们会介绍使用Ngrx Store来存储数据,这样就能更加清晰的明确显示组件和功能组件的区别。
区分显示组件和功能组件的意义
很多时候,其实很难区分在某种情况下使用显示组件,还是功能组件。或者某一个组件一开始是按照显示组件设计的,但是随着业务的修改,它可能就需要变成功能组件。但是,无论如何,当我们在设计页面结构的时候,还是要首先考虑页面组件如何设计:
- 一个页面可以被拆分成那些组件
- 每个组件之间的耦合性怎么样,耦合性过高很可能是组件过细。
- 一个组件是否可以重用,是否可以继续拆成更小的组件,拆成更小的组件,是不是可以增加这个组件的可重用性。
- 这个页面用到的数据结构是什么样的,页面各个组件之间的数据传递该如何传递,事件处理该如何处理。
当我们把这些问题都想过之后,组件结构,以及使用那种组件自然就比较清晰了。只要一开始我们把这些都想清楚了,在上手写代码,那么我们的产品不管维护多长时间,也能够保证它的可维护性。
欢迎关注课程《分布式事务实践解决数据一致性》