本篇博客是RxJS进阶系列中关于响应式编程,冷、暖、热Observable的介绍。这个介绍并不是对一些专业名词和Api的照搬,而是把一些大家认为难理解的概念通俗化、同时结合例子和实际业务场景,让大家真正掌握它是如何运用到我们日常开发中的。至于在文章中遇到自己不熟悉的Api,可自行查看RxJS官方文档。
在Angular技术栈中,RxJS一直是让大家比较头疼的东西。很多新人表示看完RxJS Api文档后依然一头雾水,一堆生涩的专业名词和难懂的概念,在学习和使用的过程中脑子里也是一堆疑问:
- 为什么要使用RxJS?它有什么优势?
- 为什么Subject要提前订阅?Http请求又不用?
- 如何实现页面间通信?
- 如何实现全局接口复用,避免重复请求?
- ...
今天我们就带着这些疑惑一起来深入学习一下RxJS。
响应式编程
RxJS的入门门槛比较高,原因之一就是需要从平时熟悉的命令式编程向响应式编程思维的转换。所以我们需要先理解什么是响应式编程。我们先看看响应式和命令式编程的定义:
响应式编程或反应式编程(Reactive programming)是一种面向数据流和变化传播的声明式编程范式
命令式编程(Imperative programming),是一种描述计算机所需作出的行为的编程典范
看完定义后大家可能还是没法理解,我们以下面这些平时开发中经常写的赋值操作为例:
a = b + c;
m = (b + c) / n;
a = b + c - d + e + f + .... + n;
第一个表达式,a的值等于b加上c。在b和c的值是一个固定值的时候,这样写没有任何问题。但如果b和c是一个变值,a的值怎么实现同步更新?
第二个表达式,m的值也和b和c值关联,b、c值更新后,不仅a的值需要更新,m也需要同步更新,怎么办?
第三个表达式,a的值由一系列可变值来共同决定,a的值怎么同步更新?
在普通的命令式编程中,我们要实现上述需求只能在每一个变值改变后手动执行一遍赋值操作。比如上述例子:b值一变,为了实现两边值同步,我得重新执行 a = b + c,再重新执行 m = (b + c)/ n,当c值改变时重复上述过程,当n值改变时...,这样做的后果就是一大堆重复的代码,状态和依赖维护变得非常困难。
那我们能不能去掉这个手动更新的过程,实现a的自动更新呢?答案是可以,使用响应式编程。
在响应式编程中,我们把数据的改变理解成由一个一个数据组成的数据流,上述例子我们只需订阅b、c的数据流,然后通过计算得到a或者m的数据流。后续b、c等数据再发生变更,都会自动调用计算逻辑并更新a或m的值。
我们平时使用的Excel公式功能也是响应式编程。下图演示了使用Excel来统计多个格子数据之和的功能,在Excel表格中,选中B10这个格子,在公式部分输入=,然后用鼠标选中B1到B9的格子,就完成了一次响应式编程。之后,无论我在B1到B9中填写什么数字,B10这个格子里的数值都会自动变为B1到B9所有格子的数值之和,换句话说,B10能够对这些格子的数值变化作出“响应”。
我们可以自己实现一个简单的Excel自动计算功能,根据输入值会自动计算总和和平均值,如下所示:
为了更直观的对比命令式编程与RxJS的区别和优缺点,我们两种方式都实现一下: 第一种方式:命令式编程
<h2>命令式编程-Excel表格</h2>
<table>
<tr>
<td>值1</td>
<td>
<input type="number" #input1 [ngModel]="number1" (ngModelChange)="onValue1Change($event)">
</td>
</tr>
<tr>
<td>值2</td>
<td>
<input type="number" #input2 [ngModel]="number2" (ngModelChange)="onValue2Change($event)">
</td>
</tr>
<tr>
<td>值3</td>
<td>
<input type="number" #input3 [ngModel]="number3" (ngModelChange)="onValue3Change($event)">
</td>
</tr>
<tr>
<td>总计</td>
<td>
<input type="number" [value]="sum">
</td>
</tr>
<tr>
<td>均值</td>
<td>
<input type="number" [value]="average">
</td>
</tr>
<tr>
<td>操作</td>
<td>
<button (click)="reset()">清空</button>
</td>
</tr>
</table>
export class ExcelTableComponent {
public number1: number;
public number2: number;
public number3: number;
public sum: number;
public average: number;
public onValue1Change(value: number): void {
this.number1 = value;
this.onValueChange();
}
public onValue2Change(value: number): void {
this.number2 = value;
this.onValueChange();
}
public onValue3Change(value: number): void {
this.number3 = value;
this.onValueChange();
}
public reset(): void {
this.number1 = null;
this.number2 = null;
this.number3 = null;
this.onValueChange();
}
private onValueChange(): void {
this.updateSum();
this.updateAverage();
}
private updateSum(): void {
if (!this.isNullOrUndefined(this.number1)
&& !this.isNullOrUndefined(this.number2)
&& !this.isNullOrUndefined(this.number3)) {
this.sum = this.number1 + this.number2 + this.number3;
} else {
this.sum = null;
}
}
private updateAverage(): void {
if (!this.isNullOrUndefined(this.sum)) {
this.average = this.sum / 3;
} else {
this.average = null;
}
}
private isNullOrUndefined(val: number | undefined | null): boolean {
return typeof val === 'undefined' || val === null;
}
}
第二种方式:RxJS实现
<h2>响应式编程-Excel表格</h2>
<table>
<tr>
<td>值1</td>
<td>
<input type="number" #input1 [ngModel]="number1$ | async" (ngModelChange)="number1$.next($event)">
</td>
</tr>
<tr>
<td>值2</td>
<td>
<input type="number" #input2 [ngModel]="number2$ | async" (ngModelChange)="number2$.next($event)">
</td>
</tr>
<tr>
<td>值3</td>
<td>
<input type="number" #input3 [ngModel]="number3$ | async" (ngModelChange)="number3$.next($event)">
</td>
</tr>
<tr>
<td>总计</td>
<td>
<input type="number" [value]="sum$ | async">
</td>
</tr>
<tr>
<td>均值</td>
<td>
<input type="number" [value]="average$ | async">
</td>
</tr>
<tr>
<td>操作</td>
<td>
<button (click)="reset()">清空</button>
</td>
</tr>
</table>
@Component({
selector: 'app-excel-table-stream',
templateUrl: './excel-table-stream.component.html',
styleUrls: ['./excel-table-stream.component.scss']
})
export class ExcelTableStreamComponent implements OnInit {
public number1$ = new Subject<number>();
public number2$ = new Subject<number>();
public number3$ = new Subject<number>();
public sum$: Observable<number>;
public average$: Observable<number>;
public ngOnInit() {
this.sum$ = combineLatest(
this.number1$.asObservable(),
this.number2$.asObservable(),
this.number3$.asObservable()
)
.pipe(
map(([number1, number2, number3]: number[]) => number1 + number2 + number3)
);
this.average$ = this.sum$
.pipe(
map((sum: number) => sum / 3)
);
}
public reset(): void {
this.number2$.next();
this.number1$.next();
this.number3$.next();
}
}
通过上面例子的对比可以明显看到,在平常使用的命令式编程中,每次输入框值的改变,我都需要手动重新计算相关的依赖。而在响应式编程中,我们则只需要发送改变后的值就可以了。至于值改变后的相关计算和更新由订阅者自动来执行。这样生产者和订阅者的逻辑实现了分离,逻辑清晰。
看完以上例子,我们来回答文章开头的第一个问题:为什么要使用RxJS?它有什么优势?
RxJS采用的思想就是响应式编程,它把网页上所有可变化的东西,比如DOM操作、用户输入、Http请求、数据更新等都看作是数据流。它很适合处理这些变化的数据流的场景,因为它可以把这些抽象出来的数据流像数组一样进行操作,同时把生产值和订阅处理值的过程解耦,实现数据变化后的依赖自动更新。现在你明白我们为什么要使用RxJS了吗?
冷、暖、热Observable
在RxJS中,我们可以根据Observable的不同表现特征把它们归类为三种Observable类型:冷、暖、热observable,理解这三种类型对于初学者来说比较困难但是却又至关重要。所以接下来我们一起来深入学习它们,首先我们先把这三种Observable的特点列举一下。
| 冷Observable | 暖Observable | 热Observable |
|---|---|---|
| 单播、多个实例 | 多播、共享实例 | 多播、共享实例 |
| 订阅后才产生值 | 从第一个订阅者订阅后开始产生值 | 创建后立即产生值 |
| 订阅者拿到的都是相同的值 | 订阅者拿到的值取决于订阅时机 | 订阅者拿到的值取决于订阅时机 |
| of、from... | share、shareReplay... | subject、fromEvent、publish... |
| 类比:看腾讯视频 | 类比:参加线下培训 | 类比:看新闻联播 |
大家看完上述表格后可能还是无法理解和记忆这三种Observable的特点,没关系,我们可以看到表格的最后一行我写了几个类比,就是为了方便大家理解这几种Observable类型的特点。接下来我们一个个分析。
热Observable就像我们平时看的新闻联播。因为新闻联播是准时开始的,可以理解为创建后立即产生值。所以如果想从头看到尾,我们要提前或者准时收看,否则就会错过部分内容。而且新闻联播它就是一对多的,所有人都是共享一个实例,不会有进度条这个概念,播放都是实时的。
冷Observable就像我们经常会看的视频软件里的电视剧集。是需要你点击播放它才会播放,也就是订阅后才会产生值。而且无论你什么时候去看,你都可以从头开始看,不会错过,订阅者拿到的都是相同的值。因为这个平台是有多个账号的,每个账号只为一个实体服务,每个账号都保存着各自的进度条,互不干扰。也就对应冷Observable的多个实例,单播的概念。
暖Observable介于冷、热Observable之间,它是采用计数的方式来决定自己的行为。就像我们参加的线下课程培训。在学员一个都没有来的时候,虽然已经到了开课时间,它还是会选择继续等待。直到有了第一个学员来到培训地点,培训就开始了。开始了之后,在课程结束前,后续进来的学员会因为来晚了错过部分课程。但如果有部分学员在课程结束后才到,是可以安排参加下一轮培训的,它还是可以学到全部的内容。所以类比:当暖的Observable发送值还未结束时,晚订阅的只能拿到未错过的那部分值。但如果等发送值已经结束后,此时订阅的订阅者也是可以拿到全部值的。
了解了冷、暖、热这三种Observable的特征后,我们再结合代码示例分别来加深一下对它们的理解。
const source$ = interval(100)
.pipe(
take(3)
);
setTimeout(() => {
source$.subscribe(res => {
console.log('observer1:', res);
});
}, 500);
source$.subscribe(res => {
console.log('observer2:', res);
});
上述这段代码就是普通的冷Observable,RxJS中绝大多数的操作符产生的Observable都是冷的。对于这个冷Observable有两个观察者,第一个观察者是500ms后进行订阅,第二个观察者是马上进行订阅,根据冷Observable特点,所有订阅者拿到的值无关时机,都是一样的,所以很容易推断出它的打印值是这样的:
const source$ = (timer(0, 100)
.pipe(
take(3),
publish()
) as ConnectableObservable<number>);
source$.connect();
setTimeout(() => {
source$.subscribe(res => {
console.log('observer1:', res);
});
}, 100);
setTimeout(() => {
source$.subscribe(res => {
console.log('observer2:', res);
});
}, 1000);
上述这段代码则是利用RxJS里的多播publish操作符,将冷Observable转换成了热Observable。对于这个热Observable有两个观察者,第一个观察者是100ms后进行订阅,第二个观察者是1000ms后进行订阅,根据热Observable特点,订阅者拿到的值取决于订阅时机。显示结果如下:
const source$ = interval(100)
.pipe(
take(3),
share()
);
setTimeout(() => {
source$.subscribe(res => {
console.log('observer1:', res);
});
setTimeout(() => {
source$.subscribe(res => {
console.log('observer2:', res);
});
}, 200);
}, 200);
setTimeout(() => {
source$.subscribe(res => {
console.log('observer3:', res);
});
}, 1000);
对于暖Observable则理解起来稍微复杂些,我们来看上面这段代码,它利用RxJS里的多播share操作符,将冷Observable转换成了暖Observable。对于这个暖Observable有三个观察者,第一个观察者是200ms后进行订阅,第二个观察者是再过200ms后进行订阅,第三个观察者是1000ms进行订阅,此时生产者发送值已经结束。根据暖Observable特点,生产者从第一个订阅者订阅开始发送值,同时对于已经结束发送值后的订阅者也可以拿到完整的值。所以第一个和第三个订阅者是可以拿到全部值的,而第二个订阅者错过了前两个值,所以它的最终输出是这样的:
学完上述三种Observable类型,我们再来解答一下文章开头的第二个问题:为什么Subject要提前订阅?Http请求又不用? 因为Subject是热Observable,Http请求是冷Observable。对于热Observable需要提前订阅,不然会错过发出的值。
由于篇幅问题,这篇博文暂时只讲了响应式编程和冷、暖、热三种Observable类型,并回答了文章开头的前两个问题。下篇博客我们会接着介绍Subject和它的一些子类,以及涉及到Observable类型和Subject使用的一些实际业务场景,并回答文章开头提出的后两个问题。