原文出处:https://zhuanlan.zhihu.com/p/25119245
【ngMiracle】Angular 中的通信方式
软件工程中,随着应用规模的不断扩大,必然需要进行逻辑功能的划分。在 Web 开发中,组件化和模块化的观念已经被越来越多的人所熟知,从而编写出更高质量的代码。
同时,随着实体职责的分离,我们也就会不可避免地需要进行实体间的相互通信,因为我们的应用仍然需要作为一个整体存在。因此,在本文中,将对 Angular 中的实体间通信方式进行简要介绍,以帮助读者编写更易于维护的代码。
术语表
- 输入/Input:组件中的外部输入,通常由 @Input() 属性装饰器或 @Component() 类装饰器参数中的 inputs 属性指定。
- 数据/Data(Datum):信息本身或其直接载体,后者通常为基本类型或其他直接携带信息的实体类型的实例。为可数名词,通常使用其复数形式。
- 材料/Material:所有由 Provider 所产生的具体内容,如通过 useClass 注册并生成的 Service 的实例等。将 useClass 之外的东西叫做 Service 本质上并不合理。
- 提供商/Provider:用于产生某种 Material 的配置对象。使用 useClass 时,Provider 提供的方式通常为 Material 的 class;使用 useFactory 时,Provider 提供的方式通常为返回 Material 的函数;使用 useValue 时,Provider 提供的方式通常为 Material 本身。其中,通过 useClass 方式注册的 Provider 通常使用 @Injectable() 装饰器所修饰。
输入:数据
- 通信方:父组件 与 子组件/指令
- 数据方向:父组件 => 子组件/指令
- 信号方向:父组件 => 子组件/指令
同时在父组件模版中使用属性绑定语法,使用:
<element [prop]="expression"></element>
绑定到表达式,或使用:
<element prop="literal"></element>
绑定到字面值,从而指定绑定源。
随后,于子组件/指令的构造函数与 OnInit hook 之间,子组件/指令的输入属性绑定完成。使用我们可以在 OnInit hook 中正常使用输入绑定的数据。每当绑定源发生变化时,子组件/指令的输入属性也会发生对应变化。
参考代码:
@Component({
selector: 'my-app',
template: `<child [propOne]="1 + 1" propTwo="1 + 1"></child>`
})
class Parent { }
@Component({
selector: 'child',
template: ''
})
class Child implements OnInit {
@Input() propOne: number
@Input() propTwo: string
ngOnInit(): void {
console.log(this.propOne) // 2
console.log(this.propTwo) // "1 + 1"
}
}
@Directive({
selector: 'child'
})
class Spy implements OnInit {
@Input() propOne: number
@Input() propTwo: string
@Input() propThree: any
ngOnInit(): void {
console.log(this.propOne) // 2
console.log(this.propTwo) // "1 + 1"
console.log(this.propThree) // undefined
}
}
在线示例请点击此处。
这里可以看到,每个组件/指令都可以定义自己所需的输入,对于同宿主的若干个指令(或一个组件和若干指令,同一个宿主不可以出现多个组件)如果有同名的输入会被共享。
另外,Angular 中的输入(属性绑定)在某种意义上来说是 "强类型" 的,拥有严格的检查机制,如果使用了一个不存在的输入属性会被视为语法错误(如果同时使用了原生的 Custom Elements 或其他类库来扩展 HTML 则需要在 模块 中配置 schemas 属性)。
同时,在使用默认的 变化监测策略 并且没有主动调用 ChangeDetectorRef 的相关状态方法修改组件的 变化监测状态 时,输入是动态绑定的,即一旦数据源发生变化就会对目标组件/指令的对应属性重新赋值。
通过自 ES6 引入的 Getter/Setter 语法(ES5 中 Object#defineProperty 的语法糖),我们可以很方便地在每次输入变化时得到通知:
@Component({
selector: 'child',
template: ''
})
class Child implements OnInit {
@Input() set propOne(value: number) {
console.log(`Property one changed to ${value}`)
}
}
在线示例请点击此处。
但是这里有一个可能的问题,如果某个输入属性是有状态/非幂等的,由于 Angular 采用的是事后检测变化的方式,我们就无法正确处理中间的变化过程。另一方面,这种方式也无法定义一个 Void Input(只有信号而没有数据)。
输入:事件流
- 通信方:父组件 与 子组件/指令
- 数据方向(可选):父组件 => 子组件/指令
- 信号方向:父组件 => 子组件/指令
我们已经知道(后文中也会提到),输出属性(事件绑定)使用了 Observable 这个事件流来实现下级到上级的信号传递。和输入属性不同,输出属性的 "变化" 不依赖于脏检测,而是基于主动的事件通知(但仍可能触发后续的脏检测)。
@Component({
selector: 'child',
template: `<p>{{ propOne }}</p>`
})
class Child implements OnInit, OnDestroy {
private _propOne: number
private propOneSubscription: Subscription<number>
@Input() set propOne(value: Observable<number>) {
if (this.propOneSubscription) {
this.propOneSubscription.unsubscribe()
}
this.propOneSubscription = value.subscribe((value: number) => {
this._propOne = value
}))
}
get propOne(): number {
return this._propOne
}
ngOnDestroy(): void {
this.propOneSubscription.unsubscribe()
}
}
在线示例请点击此处。
当然,由于不像输出属性那样由 Angular 自动管理,因此我们需要自行管理订阅,以免产生内存泄漏。但是这样做似乎过于复杂。
于是,我们可以借助 Async Pipe,其中已经封装好了对 Observable 的生命周期管理,并且也封装了对 ChangeDetectorRef 的控制,能够应对 OnPush 的 变化监测策略。
参考代码:
@Component({
selector: 'child',
template: `<p>{{ propOne | async }}</p>`
})
class Child {
@Input() propOne: Observable<number>
}
在线示例请点击此处。
实例访问:向下
- 通信方:父组件/指令 与 子组件/指令
- 数据方向(可选):父组件/指令 <=> 子组件/指令
- 信号方向:父组件/指令 => 子组件/指令
但是有些时候,当组件/指令间有明确的固定关系,并且我们需要细粒度操作的时候,我们也可以选择提升耦合性来简化通信过程。
AngularJS 中,我们可以在 Directive Definition Object 中指定 require 属性来获取同宿主或祖先元素上指令(的控制器)实例。而在 Angular 中我们还可以获取子组件的实例,并且配置更为简单,只需要借助 @ViewChild()/@ViewChildren() 或 @ContentChild()/@ContentChildren() 声明属性即可:
@Component({
selector: 'my-app',
template: `<child></child>`
})
class Parent implements AfterViewInit {
@ViewChild(Child) child: Child
ngAfterViewInit(): void {
const someChildProp = this.child.someProp
const result = this.child.someMethod('abc')
console.log(someChildProp)
console.log(result)
}
}
@Component({
selector: 'child',
template: ''
})
class Child implements OnInit {
someProp: number = 123
someMethod(input: string): string {
return `${input} operated by child`
}
}
在线示例请点击此处。
上面的代码中,我们在父组件中获取到了子组件的实例,并且直接访问子组件的公开属性和方法(TypeScript 中不加可访问性修饰符即默认为 public)。之所以需要在 AfterViewInit hook 这个生命周期后才能操作(如果是 Content 的部分就需要在 AfterContentInit hook 之后),是由于父组件的初始化过程在子组件之前,因此在父组件的构造函数或 OnInit 阶段子组件还未实例化,当然也就无从获取。
这样可以较为方便的实现复杂操作,例如同时输入或输出多项数据(如果使用过多的输入和输出属性会影响代码的可维护性),还能够进行实时反馈(即双向数据传输)。
一个常见的例子是我们基于 NgModel 封装自己的组合控件,其中往往会需要对 NgModel 的 API 进行细粒度操作。对于这样的复杂操作而言,基于数据绑定和事件绑定会让代码过于复杂。
事实上 @ViewChild (以及其它三个装饰器工厂函数)并不止接受一个参数,第二个参数为配置对象,一般场景不会用到,其中有一个 read 属性。如果我们不需要直接获取子组件/指令本身,而是从子组件/指令的某些特定其它相关内容,就可以使用:
@ViewChild(Child, { read: SOME_KEY })
的方式来获取子组件/指令所具有的某些相关内容:
@Component({
selector: 'my-app',
template: `<child></child>`
})
class Parent implements AfterViewInit {
@ViewChild(Child, { read: ViewContainerRef }) childVcr: ViewContainerRef
ngAfterViewInit(): void {
console.log(this.childVcr.element.nativeElement)
}
}
@Component({
selector: 'child',
template: ''
})
class Child { }
在线示例请点击此处。
实例访问:向上
- 通信方:父组件/指令 与 子组件/指令
- 数据方向(可选):父组件/指令 <=> 子组件/指令
- 信号方向:父组件/指令 <= 子组件/指令
Or
- 通信方:同宿主组件与指令/同宿主指令与指令
- 数据方向(可选):任一组件或指令 <=> 任一组件或指令
- 信号方向:任一组件或指令 => 任一组件或指令
与上面的实例获取相对应,我们也能够从子组件/指令获取父组件/指令(或同宿主组件/指令)实例,具体的方式对于大家来说既熟悉又陌生,那就是依赖注入:
@Component({
selector: 'my-app',
template: `<child></child>`
})
class Parent implements AfterViewInit {
children: Child[] = []
register(child: Child) {
this.children.push(child)
}
}
@Component({
selector: 'child',
template: ''
})
class Child implements OnInit {
constructor(private parent: Parent) {}
ngOnInit(): void {
this.parent.register(this)
console.log(this.parent.children)
}
}
在线示例请点击此处。
上面的代码中,我们在子组件的构造函数中注入了父组件的实例,如果有需要我们还可以通过@Self(),@SkipSelf() 和 @Host() 来限制该实例的来源,比 AngularJS 中的 ^ 符号组合显然清晰的多。
由于子组件/指令构造时父组件/指令早已构造完成,因此可以无需等待直接获取到父组件/指令的实例。
这里我们使用了一个子组件自行向父组件登记自身存在的例子,相比于父组件一次性获取所有子组件实例,这样的优势是能够动态增删子组件列表。一个应用实例就是 NgForm 与NgControl 之间的交互,由于表单内容可能在使用过程中动态变化,所以无法在表单初始化时一次性获取所有控件实例,而需要支持使用中动态注册与注销控件的功能。
实例访问:服务
- 通信方:组件/指令与服务
- 数据方向(可选):组件/指令 <=> 服务
- 信号方向:组件/指令 <=> 服务
Or
- 通信方:服务与服务
- 数据方向(可选):服务 <=> 服务
- 信号方向:服务 <=> 服务
事实上,实例访问这种方式我们一直都在使用,例如组件对服务的访问:
@Component({
selector: 'my-app'
template: ''
})
class Parent implements OnInit {
constructor(private someService: SomeService) { }
ngOnInit(): void {
this.someService.someMethod()
}
}
@Injectable()
class SomeService {
someMethod(): void { console.log(123) }
}
在线示例请点击此处。
此外,服务也一样能够配合 Observable 使用,例如 Location 和 ActivatedRoute 就提供了持续的事件流,因此也能够实现服务到组件/指令的信号传递。
输出:事件
- 通信方:父组件 与 子组件/指令
- 数据方向(可选):父组件 <= 子组件/指令
- 信号方向:父组件 <= 子组件/指令
@Component({
selector: 'my-app',
template: `<child (output)="onOutput($event)"></child>`
})
class Parent {
onOutput(event: number): void {
console.log(event)
}
}
@Component({
selector: 'child'
})
class Child implements OnInit {
@Output() output = new EventEmitter<number>()
onInit(): void {
this.output.emit(123)
}
}
在线示例请点击此处。
由于这里的 Subject 由 Angular 进行管理,我们无需关心 subscribe 和 unsubscribe 的调用,只需要简单应对事件侦听即可。
提供商:单值
- 通信方:父组件 与 子组件/指令/服务
- 数据方向:父组件 => 子组件/指令/服务
- 信号方向:父组件 <= 子组件/指令/服务
Or
- 通信方:模块/平台 与 组件/指令/服务
- 数据方向:模块/平台 => 组件/指令/服务
- 信号方向:模块/平台 <= 组件/指令/服务
归功于 Angular 引入的 Hierarchical Injector 机制,每个组件/指令都可以有独立(并继承)的 注入器。相比于 AngularJS 中的全局唯一的注入器而言,在 Angular 中我们可以对提供商进行细粒度控制。
而当我们不提供服务而直接直接提供数据实体时,也就构成了一种通信方式:
@Component({
selector: 'my-app',
template: `<child></child>`,
providers: [
{ provide: 'token1', useValue: 1 }
]
})
class Parent { }
@Component({
selector: 'child',
template: '',
providers: [
{ provide: 'token0', useValue: 0 }
]
})
class Child implements OnInit {
constructor(
@Inject('token0') private value0: number
) { }
}
@NgModule({
imports: [BrowserModule],
declarations: [Parent, Child],
bootstrap: [Parent],
providers: [
{ provide: 'token2', useValue: 2 }
]
})
AppModule {}
platformBrowserDynamic([
{ provide: 'token3', useValue: 3 }
]).bootstrapModule(AppModule)
在线示例请点击此处。
我们可以在不同层次上提供任何需要的数据,其中 Component、Module 中的 providers 往往都是静态配置的,而 Platform 中的 providers 一般是通过 JavaScript 代码获取,用于与服务端模版或其它 JavaScript 部分交互。
通过提供商(默认为单值)进行通信的一个特点是静态性,即所需传输的内容一经确定就不可再更改(我们这里使用了 useValue 提供常量,实际上还能通过 useFactory 在需要时生成内容),并且具有明确的层次性,上层能够对所有下层提供内容,并且中间层能够覆盖上层内容。
一个很常见的例子就是用于制作开关(或其他辅助标识),或者应用策略模式。
提供商:多值
- 通信方:父组件 与 子组件/指令/服务
- 数据方向:父组件 => 子组件/指令/服务
- 信号方向:父组件 <= 子组件/指令/服务
Or
- 通信方:模块/平台 与 组件/指令/服务
- 数据方向:模块/平台 => 组件/指令/服务
- 信号方向:模块/平台 <= 组件/指令/服务
上面我们已经知道了 @ViewChildren() ,可以一次性获取到全体某个类型的子组件/指令列表。同时也知道了依赖注入可以得到同宿主的组件/指令实例。
但还有一个场景解决不了,就是我们需要得到同宿主的多个同 "类型" 的全体指令。(当然,这里的类型并不是真的 JavaScript 类型,因为一个指令在一个元素上至多只会被应用一次,可以理解为具备相同标识)
在 Angular 中,有一个黑魔法可以解决这个问题,就是设置了 multi: true 的提供商,这类提供商可以被多次注册,并且不会被覆盖,而是会进行汇总:
@Component({
selector: 'my-app',
template: `<child></child>`
})
class Parent { }
@Component({
selector: 'child',
template: '',
providers: [
{ provide: 'token', useValue: 0, multi: true }
]
})
class Child implements OnInit {
constructor(
@Inject('token') private values: number[],
) { }
ngOnInit() {
console.log(this.values)
}
}
@Directive({
selector: 'child',
providers: [
{ provide: 'token', useValue: 1, multi: true }
]
})
class Spy1 { }
@Directive({
selector: 'child',
providers: [
{ provide: 'token', useValue: 2, multi: true }
]
})
class Spy2 { }
在线示例请点击此处。
这样,通过某个共同的 Token,每个组件/指令都可以得到所有其他组件/指令给出的材料,而无需知晓其他组件/指令的具体存在。
一个应用实例是 FormControlName 与 Validator 以及 AsyncValidator 之间的交互,所有 Validator 指令都直接应用在 FormControl 所在的元素上,而 FormControl 无需知道每个 Validator 指令的具体形式(无论是内置的还是自定义的),只需要收集每个 Validator 指令所提供的验证器即可。
当然,这并不是 multi: true 的唯一作用,还有一个重要的功能就是用来 “注册”,比如我们通过APP_BOOTSTRAP_LISTENER 来注册应用启动的回调,通过 NG_VALUE_ACCESSOR 来注册能被应用于 NgModel 的组件/指令等。
速查表
说了这么多,那么我们在应用中应该如何选择这些通信方式呢?这里提供了简单的决策树,以帮助读者快速进行查阅。(仅仅提供参考,并不一定是具体场景下最优选择,实际项目请以自身实际情况为准)1.是否为组件/指令间通信?
|
|- T
| |- 2. 是否有位置关系?
| |
| |- T
| | |- 3. 是否有明确的行为关联(固定搭配)?
| | |
| | |- T
| | | |- 4. 是否具有固定的上下级关系
| | | |
| | | |- T
| | | | |- 5. 是否仅需由上至下提供不可变内容?
| | | | |
| | | | |- T
| | | | | |- (提供商:单值)
| | | | |
| | | | |- F
| | | | |- 6. 子组件/指令是否会动态变化?
| | | | |
| | | | |- T
| | | | | |- (实例访问:向上)
| | | | |
| | | | |- F
| | | | |- (实例访问:向下)
| | | |- F
| | | |- 7. 是否明确处于同一宿主内?
| | | |
| | | |- T
| | | | |- 8. 是否有多个组件/指令同时作为数据源?
| | | | |
| | | | |- T
| | | | | |- 9. 是否仅需提供不可变内容?
| | | | | |
| | | | | |- T
| | | | | | |- (提供商:多值)
| | | | | |
| | | | | |- F
| | | | | |- (/*借助父组件/指令通信*/)
| | | | |- F
| | | | |- 10. 是否仅需提供不可变内容?
| | | | |
| | | | |- T
| | | | | |- (提供商:单值)
| | | | |
| | | | |- F
| | | | |- (实例访问:向上)
| | | |- F
| | | |- 11. 是否明确为兄弟关系?
| | | |- T
| | | | |- (/*借助父组件/指令通信*/)
| | | |
| | | |- F
| | | |- 那还叫什么固定搭配!
| | |- F
| | |- 12. 方向是否为由父向子?
| | |
| | |- T
| | | |- 13. 输入是否影响自身以外的其他子组件内部状态?
| | | |
| | | |- T
| | | | |- (输入:事件)
| | | |
| | | |- F
| | | |- (输入:数据)
| | |- F
| | |- 方向是否为由子向父?
| | |
| | |- T
| | | |- (输出:事件)
| | |
| | |- F
| | |- (/*借助父组件/指令通信*/)
| |- F
| |- (/*借助服务通信*/)
|
|- F
|- 是否为组件与服务间通信
|- T
| |- (实例访问:服务)
|
|- F
|- 并不确定你要做什么~
其实我们很多时候往往用的并不是单一的通信方式,而是多种方式的组合。
另外,有的童鞋可能会有疑问,在已经使用了统一的状态管理方案的情况下,还是否需要底层的通信方式呢?答案是肯定的,状态管理方案应当仅仅应用于容器组件,而功能组件为了保持复用性应当避免和任何具体的状态管理方案挂钩。
## 总结
1. ng2 应用结构基于组件树;
2. 组件/指令相互之间,组件/指令与服务之间需要相互通信;
3. 通信方式有很多种,选择合适的通信方式对应用实现会有很大帮助。