Parent and children communicate via a service in AngularJs2.

通俗易懂的父组件和多个子组件之间通讯之服务通讯

Posted by Zhenzhen Lin on 2017-02-16

前言

目前在接手公司的一个开发相册的任务, 在编码的过程中, 发现一个会阻碍之后开发而且非常棘手的问题, 先了解一下现在的环境:

目前的结构组织大概是这样:

1
2
3
4
- photoComponent // 父组件
- headerComponent // 子组件
- contentComponent // 子组件
- footerComponent // 子组件

目前的组件输出结构大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div class="wrapper">
<!-- header 组件 -->
<header></header>
<div class="main" [class.full-width]="fullWidth">
<div ng-view autoscroll="true">
<!-- content 组件 会通过 router-outlet 输出布局 -->
<router-outlet></router-outlet>
</div>
</div>
<!-- footer 组件 -->
<footer></footer>
</div>

那么问题来了:

header 中有个菜单是来改变 content 中相册的布局的, 布局有分: 年/月/周, 当我在 header 子组件中选择某个布局的下拉值时, 需要告诉 content 子组件这个值是什么, 看过 Angular2 组件通讯的大家应该知道, 通讯大部分通过属性绑定来传递和接收, 但是这种情况只能是, 直系亲属父与子的传递, 因为在上述结构视图中, 很明显父组件中直接引入了子组件的布局, 我们现在的问题是 content 子组件虽然也是在 photo 父组件中来展示, 但是很不幸的是下面这行:

1
<router-outlet></router-outlet>

这个路由输出阻断了传递, 它不能帮你传递任何绑定的属性值, 就是不能在这行代码是想办法, 绑定属性会报错, 而且不管加什么, 子组件 content 也不会接收到值.

但是我想着AngularJs2肯定有轻松解决的办法, 然后就通读Angular2-组件通讯, 篇幅很长, 第一遍不懂, 第二遍第三遍尝试, 终于明白点原理了, 这里的组件通讯有很多方法, 但是能解决现在问题的方法, 就是父与子组件通过服务来通讯

AngularJs2 官网的资料有时候写的不容易理解, 在这里我来简单阐述一下怎么运用服务来通讯.

先了解一下服务通讯的基本机制

抛开父与子的关系, 简单理解就是: 一个负责传递, 一个负责订阅接收

单项服务传递

我来先介绍一个单项传递, 就是由 header 点击某个菜单时, 把这个值传递给 content 来接收.

  1. 先写一个 headerService 服务:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs/Subject';
@Injectable()
export class HeaderService {
// 1. Observable Subjects
private viewType = new Subject<string>();
// 2. Observable Streams
view$ = this.viewType.asObservable();
// 3. Service Announcements
announceViewType(view: string): void {
this.viewType.next(view);
}
}

1 部分代码: 声明私有资源 Subject 的对象, string 是传递值的类型, 可以不写, 如:

1
2
3
4
5
6
7
private viewType = new Subject();
// 或者
private viewType = new Subject<number>();
// 或者
private viewType = new Subject<boolean>();
// 或者
private viewType = new Subject<any>();

2 部分代码: 声明对外公共的可观察对象, 关键词是: asObservable - 作为可观察对象
3 部分代码: 创建通讯广播的方法, 括号内是你要广播的值, 在这里我们是 header 点击的菜单, next 很重要, 是把这个 可观察对象 向订阅它的组件中扩散信息, 订阅之后会讲就是我们说的接收.

2.把服务注入到供应商中, 这个随你爱好, 注入在父组件的 providers 中也行, 注入在总入口模块 moduleproviders 中也行, 注入在这两个总入口中的话, 子组件就不需要单独注入了, 只负责在构造函数 constructor 实例化该服务即可.

1
2
3
providers: [
HeaderService
]

3.我们开始在 HeaderComponent 传递了, 情景是点击 header 中的菜单时, 广播发散信息到订阅该值的地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { Component } from '@angular/core';
import { HeaderService } from "../../../services/components/common/header/header.service";
@Component({
moduleId: module.id,
selector: 'header',
templateUrl: 'header.component.html',
styleUrls: ['header.component.css']
})
export class HeaderComponent {
constructor(private headerService: HeaderService) {}
private viewStyles = [
{typeTitle: '默认视图', viewType: 'years'},
{typeTitle: '周视图', viewType: 'weeks'},
{typeTitle: '月视图', viewType: 'months'}
];
private viewStylesSelected = this.viewStyles[0];
/* 点击: 选择某个菜单 */
private onSelect(view: any): void {
this.viewStylesSelected = view;
// 开始广播
this.headerService.announceViewType(this.viewStylesSelected.viewType);
}
}

上述代码中, 做了两件事情, 一是实例化了 headerService 服务, 二是在 onSelect 点击菜单时, 调用 headerService 中广播的方法 announceViewType, 让它利用 next 的性能去传播这个值, 哪里订阅这个值就传播在哪里.

4.传播好了, 那我们就开始在 ContentComponent 中接收, 在这里是用订阅来完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { Component, AfterViewInit} from '@angular/core';
import { HeaderService } from "../../services/components/common/header/header.service";
@Component({
moduleId: module.id,
selector: 'my-content',
templateUrl: 'content.component.html',
styleUrls: ['content.component.css']
})
export class ContentComponent implements AfterViewInit {
constructor(private headerService: HeaderService) {}
ngAfterViewInit(): void {
// 订阅
_that.headerService.view$.subscribe((view: string) => {
// 变量 view 为 header 选择的菜单
// 已经接收到值了, 可以改变布局了
});
}
}

ContentComponent 组件中, 做了这么几件事情:
一是, 我们在构造函数内声明headerService的实例;
二是, 订阅, 关键字是subscribe, 调用headerService的对外的可观察变量, 订阅它即可, 因为我们在服务中已声明该对象为Subject的一个’asObservable’.
三是, 在哪里订阅呢, 在这还涉及一个知识点, 生命周期钩子, 看下图总结:

指令和组件的变更检测与生命周期钩子 (作为类方法实现)
constructor(myService: MyService, …) { … } 类的构造函数会在所有其它生命周期钩子之前调用。使用它来注入依赖,但是要避免用它做较重的工作。
ngOnChanges(changeRecord) { … } 在输入属性每次变化了之后、开始处理内容或子视图之前被调用。
ngOnInit() { … } 在执行构造函数、初始化输入属性、第一次调用完ngOnChanges之后调用。
ngDoCheck() { … } 每当检查组件或指令的输入属性是否变化时调用。通过它,可以用自定义的检查方式来扩展变更检测逻辑。
ngAfterContentInit() { … } 当组件或指令的内容已经初始化、ngOnInit完成之后调用。
ngAfterContentChecked() { … } 在每次检查完组件或指令的内容之后调用。
ngAfterViewInit() { … } 当组件的视图已经初始化完毕,每次ngAfterContentInit之后被调用。只适用于组件。
ngAfterViewChecked() { … } 每次检查完组件的视图之后调用。只适用于组件。
ngOnDestroy() { … } 在所属实例被销毁前,只调用一次。

从上述表格可以看出
执行一次的有: constructor(), ngOnInit(), ngAfterContentInit(), ngAfterViewInit(), ngOnDestroy()
检查属性输入或变化的有: ngOnChanges()
检查组件视图变化的有, 执行多次: ngAfterContentChecked(), ngAfterViewChecked(), ngDoCheck()

在这里我选用的是 ngAfterViewInit() 的钩子. 除了执行多次的几个和 constructor(), ngOnDestroy() 不建议使用, 其他都可以用来订阅信息, 另外 ngOnChanges() 是监控属性的, 现在的问题不是用属性传递的, 所以在这里也不适用.

以上我们就完成了脱离父与子, 子与子之间用服务来广播值的方法, 为单项传递.

那双向通讯是什么呢?

双向通讯服务传递

其实懂了单项通讯, 双向通讯就是让它们的位置换了一下, 直接改良一下上述三个文件的代码, 看一下就可以理解了

1.headerService 服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs/Subject';
@Injectable()
export class HeaderService {
// 1. Observable Subjects
private viewType = new Subject<string>();
private viewTypeCurrent = new Subject<string>();
// 2. Observable Streams
view$ = this.viewType.asObservable();
viewCurrent$ = this.viewTypeCurrent.asObservable();
// 3. Service Announcements
announceViewType(view: string): void {
this.viewType.next(view);
}
announceViewTypeCurrent(view: string): void {
this.viewTypeCurrent.next(view);
}
}

上述服务又生成一个 viewCurrent$ 可订阅的对象

2.ContentComponent 广播信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { Component, OnInit, AfterViewInit} from '@angular/core';
import { HeaderService } from "../../services/components/common/header/header.service";
@Component({
moduleId: module.id,
selector: 'my-content',
templateUrl: 'content.component.html',
styleUrls: ['content.component.css']
})
export class ContentComponent implements OnInit, AfterViewInit {
constructor(private headerService: HeaderService) {}
private currentView: string = 'years';
ngOnInit(): void {
let _that = this;
// 开始广播
_that.headerService.announceViewTypeCurrent(_that.currentView);
}
ngAfterViewInit(): void {
// 订阅
_that.headerService.view$.subscribe((view: string) => {
// 变量 view 为 header 选择的菜单
// 已经接收到值了, 可以改变布局了
});
}
}

上述组件是在 ngOnInit() 的钩子中做了服务的广播, 因为我们进入该页面的时候, header 的菜单始终是以默认视图展示, 避免其他页面会影响该默认值, 所以我选择了在 ngOnInit() 初始页面时传递给 header 默认的值. 大家可根据个人情景而定.

3.HeaderComponent 订阅接收信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import { Component, OnInit } from '@angular/core';
import { HeaderService } from "../../../services/components/common/header/header.service";
@Component({
moduleId: module.id,
selector: 'header',
templateUrl: 'header.component.html',
styleUrls: ['header.component.css']
})
export class HeaderComponent implements OnInit {
constructor(private headerService: HeaderService) {}
private viewStyles = [
{typeTitle: '默认视图', viewType: 'years'},
{typeTitle: '周视图', viewType: 'weeks'},
{typeTitle: '月视图', viewType: 'months'}
];
private viewStylesSelected = this.viewStyles[0];
ngOnInit(): void {
let _that = this;
// 订阅
_that.headerService.viewCurrent$.subscribe((view: string) => {
if (view === 'years') {
_that.viewStylesSelected = _that.viewStyles[0];
}
});
}
/* 点击: 选择某个菜单 */
private onSelect(view: any): void {
this.viewStylesSelected = view;
// 开始广播
this.headerService.announceViewType(this.viewStylesSelected.viewType);
}
}

上述代码中, 我也是选择用 ngOnInit() 的钩子去订阅 ContentComponent 传送来的值.

至此, 问题已经完美解决了, 大家可按照自己的情景去按照这个步骤去传递:

1.在服务声明可观察对象, 用到的是 `Subject`的 `asObservable()`
2.一个负责广播讯息, 用到的是 `next()`
3.一个负责订阅, 用到的是 `subscribe()`

其他服务通讯

这里解决了由 <router-outlet></router-outlet> 截断的组件通讯, 用服务通讯传递完成了.

其他如果是建立在父与子的通讯之间, 可尝试运用属性绑定, 或者 EventEmitter 由子组件向上弹射(即父组件接收).

AngularJs2 服务通讯的方法有:

使用输入型绑定,把数据从父组件传到子组件
通过setter拦截输入属性值的变化
使用ngOnChanges拦截输入属性值的变化
父组件监听子组件的事件
父组件与子组件通过本地变量local variable互动
父组件调用ViewChild
父组件和子组件通过服务来通讯, 这个是这篇文章阐述的.

肯定会出现的问题提前知悉与解决

一般只要你订阅了, 就不能退订了, 而且它会记录你多次订阅的信息, 如果一个组件重复被使用, 接收同样的订阅信息, 就会被叠加, 并不是这个组件内的问题, 而是服务的问题, 服务是单独的服务, 它会记录在 SPA 中所有的轨迹.

所以, 在这里 photoComponent 这个页面可能被其他路由使用, 所以我要保证这个组件始终是最新初始化, 而且没有被记录的新组件,

接下来, 我要做当离开这个页面时, 即当我们路由被转换后, 取消订阅.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { Component, OnInit, AfterViewInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs/Subscription';
import { HeaderService } from "../../services/components/common/header/header.service";
@Component({
moduleId: module.id,
selector: 'my-content',
templateUrl: 'content.component.html',
styleUrls: ['content.component.css']
})
export class ContentComponent implements OnInit, AfterViewInit, OnDestroy {
constructor(private headerService: HeaderService) {}
subscriptionForHeaderService: Subscription;
private currentView: string = 'years';
ngOnInit(): void {
let _that = this;
// 开始广播
_that.headerService.announceViewTypeCurrent(_that.currentView);
}
ngAfterViewInit(): void {
// 订阅 并记录订阅
_that.subscriptionForHeaderService = _that.headerService.view$.subscribe((view: string) => {
// 变量 view 为 header 选择的菜单
// 已经接收到值了, 可以改变布局了
});
}
ngOnDestroy(): void {
// 取消订阅
this.subscriptionForHeaderService.unsubscribe();
}
}

在上述代码中注意的几点是:
1.我用的是 ngOnDestroy() 生命周期钩子, 它代表页面离开时做的操作
2.引入 import { Subscription } from 'rxjs/Subscription';
3.声明 subscriptionForHeaderService: Subscription;
4.赋值 _that.subscriptionForHeaderService = _that.headerService.view$.subscribe((view: string) => {});
5.最后 ngOnDestroy() 取消订阅 this.subscriptionForHeaderService.unsubscribe();

reported on June 7 2017

Communication between two sibling components in angular 2 #2663
Look at the demo by @chennamouli posted

Well Done ~