RxJS系列05:操作符 Operators(下)

320 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第5天,点击查看活动详情

RxJS(Reactive Extensions for JavaScript) 是一个非常强大的 JS 库,我们可以使用它轻松编写异步代码。

在本系列文章中,我将带领你学习 RxJS 的最新版本,我们会重点关注如何使用响应式编程范式来解决你在日常工作中碰到的问题。所以这是一个偏实战的系列文章。

在本系列文章中,你将学会 RxJS 中的核心组件是如何使用和运作的。

通过学习这个系列文章,你将亲自使用 RxJS 完成一个完整的项目开发,在这个项目中,你将了解如何处理 DOM 事件、如何构建响应式本地数据库等内容。

转换操作符

在学习过能够在管道中执行某种数据清理工作的过滤操作符后,我们通常还需要对数据流进行更复杂的操作,比如将数据从一种类型转换为另一种类型。相信你应该熟悉 Array.map 方法,它可以根据你定给的的函数将数组中的每个元素映射到另一种你想要的类型或者数据格式。在 RxJS 中,有很多和 map 在思路上一致的操作符。

map 类操作符

map 类型的操作符主要是将数据从一种类型映射到另一种类型。

map

map 是最基本的操作符,它能够根据传递的回调函数将值转为另一种类型。

不过 map 操作符的执行时机是在每次 Observable 发出值时执行的,和 Array 的 map 不一样。

下面是一个计算数字平方的代码示例:

import { from } from 'rxjs'
import { map } from 'rxjs/operators'

const numbers$ = from([1, 2, 3, 4])

numbers$.pipe(
  map(num => "平方值: " + (num * num)))
  .subscribe(console.log)

switchMap

switchMap 的作用是将订阅的 Observable 切换到另一个 Observable。原来的 Observable 我们可以理解成外部的 Observable,switchMap 中的 Observable 我们理解成内部的 Observable。当外部的 Observable 发送值时,switchMap 就会将内部的 Observable 完成,并且重新订阅它。通常在我们希望完成内部功能时,使用这个操作符。

听上去好像有点抽象,下面我列举一个现实的例子。我们要制作一个可控的计时器程序。当用户打开计时器,我们开始计时,当用户关闭计时器,我们要重置计时器。通过 switchMap 可以很好的实现这个功能。下面是实现代码:

import { Subject, interval } from 'rxjs'
import { switchMap, filter } from 'rxjs/operators'

// 每秒发送一次数据的计时器主题
const timer$ = interval(1000)
// 控制开关的主题
const switch$ = new Subject();
// 只有在开关打开时定时器才可以发送数据
const subscription = switch$.pipe(
    filter(toggle => toggle),
    switchMap(toggle => timer$)
).subscribe(time => console.log(time + '秒'))

// 模拟用户操作
setTimeout(() => switch$.next(true), 1000);
setTimeout(() => switch$.next(false), 4000);

setTimeout(() => console.log('-------'), 5500);

setTimeout(() => switch$.next(true), 6000);
setTimeout(() => switch$.next(false), 7000);

setTimeout(() => subscription.unsubscribe(), 10000);

它的工作原理如下图所示:

image.png

concatMap

concatMap 和 switchMap 很像。不同的是 concatMap 会在外部 Observable 第一次发送值后,开始订阅内部的 Observable,并且在之后,无论外部的 Observable 是否发送新的值,都不会影响内部的 Observable 的订阅状态,直到外部的 Observable 取消订阅,内部的 Observable 才会一起取消订阅。

import { Subject } from 'rxjs'
import { concatMap, filter } from 'rxjs/operators'

// 发布数据的主题
const dataStream$ = new Subject()
// 控制开关的主题
const switch$ = new Subject();

const subscription = switch$.pipe(
    filter(toggle => toggle),
    concatMap(toggle => dataStream$)
).subscribe(time => console.log('接收到的数据是: ' + time))

setTimeout(() => switch$.next(true), 1000);
setTimeout(() => dataStream$.next(1), 2000);
setTimeout(() => switch$.next(false), 4000);

setTimeout(() => {
    dataStream$.next(2);
    dataStream$.complete()
}, 8000);

setTimeout(() => subscription.unsubscribe(), 10000);

mergeMap

我们已经了解过 switchMap 的用法了,它在内部订阅的 Observable 会在外部的 Observable 发送完数据后终止订阅,这也就意味着只能同时订阅一个 Observable。当我们需要在内部同时订阅多个 Observable 时,就需要 mergeMap 了。

import { Subject, of } from 'rxjs'
import { mergeMap, delay } from 'rxjs/operators'

// 发送数据到服务的主题
const publishToServer$ = of("演示数据").pipe(delay(100));
// 控制开关的主题
const switch$ = new Subject()

// 内部可以有多个订阅者
const subscription = switch$.pipe(
    mergeMap(toggle => publishToServer$)
).subscribe(data => console.log('接收到的数据: ' + data))

// 模拟操作
setTimeout(() => switch$.next(true), 1000);
setTimeout(() => switch$.next(false), 2000);
setTimeout(() => switch$.next(true), 3000);

setTimeout(() => subscription.unsubscribe(), 4000);

buffer 类操作符

缓冲是数据转换中一个非常有用的概念。随着时间的推移,数据不断地发出,我们可以使用 buffer 在数据管道中设置一个时间间隔或者指定一个阈值。只有当数据达到了时间间隔或者阈值,数据才会发送给订阅者。

buffer

buffer 操作符能够缓冲发出的值,直到作为参数的 Observable 发出数据为止。

import { Subject } from 'rxjs';
import { buffer } from 'rxjs/operators';

// buffer 主题,当这个主题有值发出,buffer 才会终止。
const bufferStop$ = new Subject();
// 数据源主题
const source$ = new Subject();

const subscription = source$
  .pipe(buffer(bufferStop$))
  .subscribe(val => console.log('缓存数据:', val));

// 模拟操作
setTimeout(() => source$.next(1), 1000);
setTimeout(() => source$.next(2), 2000);
setTimeout(() => bufferStop$.next("终止缓存"), 3000);
setTimeout(() => source$.next(3), 4000);

setTimeout(() => subscription.unsubscribe(), 5000);

bufferCount

bufferCount 在 buffer 的基础之上添加了阈值的概念。在使用 bufferCount 时,我们可以指定一个阈值。直到从 Observable 中发送的数据的数量达到这个阈值之后,才会将数据发送到订阅者。

import { Subject } from 'rxjs'
import { bufferCount } from 'rxjs/operators'

const source$ = new Subject()

const subscription = source$.pipe(bufferCount(3)).subscribe(value => console.log('接收到的值是:', value))

setTimeout(() => { source$.next(1) }, 1000)
setTimeout(() => { source$.next(2) }, 2000)
setTimeout(() => { source$.next(3) }, 3000)
setTimeout(() => { source$.next(4) }, 4000)
setTimeout(() => { source$.next(5) }, 5000)
setTimeout(() => { subscription.unsubscribe() }, 6000)

bufferToggle

bufferToggle 可以让我们控制什么时候开始缓存和结束缓存。这在一些要根据某些用户操作才产生值的场景下很有用处。比如一个画布程序,用户按下鼠标开始绘制时,我们开始缓存,用户松开鼠标,绘制结束,我们结束缓存。

它的工作流程图如下:

image.png

下面是示例代码:

const { Subject } = require('rxjs');
const { bufferToggle } = require('rxjs/operators');

// 2 Subjects to toggle the buffer
const toggleOn$ = new Subject();
const toggleOff$ = new Subject();

const toggleOffClosing$ = () => toggleOff$;

// The data source
const source$ = new Subject();

const subscription = source$
.pipe(bufferToggle(toggleOn$, toggleOffClosing$))
.subscribe(val => console.log('buffered data:', val));

// The simulation
setTimeout(() => source$.next(1), 2000);
setTimeout(() => source$.next(2), 3000);
setTimeout(() => toggleOn$.next('on'), 3400);
setTimeout(() => source$.next(3), 4000);

setTimeout(() => source$.next(4), 5000);
setTimeout(() => source$.next(5), 6000);
setTimeout(() => toggleOff$.next('off'), 6200);
setTimeout(() => source$.next(6), 7000);

setTimeout(() => subscription.unsubscribe(), 10000);

需要注意的是,bufferToggle 的第二个参数必须是一个返回 Observable 的函数。

reduce

如果你对 JavaScript 比较了解的话,肯定用过 Array 的 reduce 函数。它在快速计算的场景下非常有用,比如它不需要迭代就可以计算出数组中所有元素的总和。

在 RxJS 中,也提供了类似作用的 reduce 操作符,它可以将 Observable 中的多个值减少为一个值,这个值将会在 Observable 完成时发送。

演示代码如下:

import { from } from 'rxjs'
import { reduce } from 'rxjs/operators'

const source$ = from([1, 2, 3, 4, 5])

const subscription = source$
  .pipe(reduce((acc, val) => acc + val))
  .subscribe((val) => console.log('总和:', val))

subscription.unsubscribe()

scan

scan 和 reduce 有些类似,但它会随着时间的推移来减少值,而不是发出最后一个值。

如果我们需要知道随着时间推移而发生的完整的计算过程,那 scan 就会很有用。

import { from } from 'rxjs'
import { scan } from 'rxjs/operators'

const source$ = from([1, 2, 3, 4, 5])

const subscription = source$
  .pipe(scan((acc, val) => acc + val))
  .subscribe((val) => console.log('和:', val))

subscription.unsubscribe()

pluck

假设我们有一个需求,是监听键盘点击事件,然后只需要根据 keyCode 属性执行某些逻辑。这时你首先想到的应该会是使用 map 操作符。但是还有一种更加简洁的方法,就是使用 pluck 操作符。它可以从对象中选择某个属性作为值继续发送。

下面是示例代码:

import { Subject } from 'rxjs'
import { pluck } from 'rxjs/operators'

const keyboard$ = new Subject();

const subscription = keyboard$.pipe(
    pluck('keyCode')
).subscribe(code => console.log('当前的 keyCode: ' + code))

setTimeout(() => keyboard$.next({data: "1", keyCode: "y"}), 1000);
setTimeout(() => keyboard$.next({data: "2", keyCode: "m"}), 2000);
setTimeout(() => keyboard$.next({data: "3", keyCode: "Ctrl"}), 3000);
setTimeout(() => keyboard$.next({data: "4", keyCode: "Shift"}), 4000);

setTimeout(() => subscription.unsubscribe(), 5000);

组合操作符

前面学习的所有操作符都是针对单个 Observable 的。如果我们想创建一个数据管道,在需要的时候提供给两个或者多个 Observable 使用,这时就需要组合操作符了。

combineLatest

combineLatest 操作符支持将多个 Observable 进行组合。它具有两个特点:

  1. 每次这些 Observable 中的任何一个发出数据时,按照参数顺序发送每个 Observable 最后发出的那个值,并组合成一个数组。
  2. 在每个 Observable 至少发出一个值之前,它不会发出任何初始值。

下面是示例代码:

import { combineLatest, Subject } from 'rxjs';

const subject1 = new Subject();
const subject2 = new Subject();
const subject3 = new Subject();

combineLatest(subject1, subject2, subject3).subscribe(console.log)

setTimeout(() => {
    subject1.next(1);
}, 500)

setTimeout(() => {
    subject2.next(2);
}, 500)

setTimeout(() => {
    subject3.next(3);
}, 700)


setTimeout(() => {
    subject3.next('3 again');
}, 1200)

当订阅者收到数据时,并不意味着是所有数据都发生变化了。它接收到的值是所有 Observable 的最新值。

concat

我们经常会碰到一类场景,一个事件取决于上一个事件。比如具有依赖关系的 HTTP 请求,必须等待上一个请求成功后才会发出下一个请求。在 RxJS 中,这种问题可以描述为:当第一个 Observable 的值发出时,订阅者才可以订阅第二个 Observable。concat 操作符就是专门处理这类情况的。

下面是一个点击按钮后发送 HTTP 请求的代码示例:

import { concat, of, Subject } from 'rxjs'

const btnClick$ = of('按钮单击')
const httpRequest$ = new Subject();

concat(btnClick$, httpRequest$).subscribe(console.log)

setTimeout(() => {
    httpRequest$.next('服务器响应');
}, 1000)

merge

如果我们需要在一个集中的地方处理多个 Observable 发出的值,就需要使用 merge 操作符来合并多个 Observable。

下面是一个模拟统一处理鼠标点击和键盘点击的代码示例:

import { merge, of, Subject } from 'rxjs'

const keyboard$ = new Subject();
const mouse$ = new Subject();

merge(keyboard$, mouse$).subscribe(console.log)

setTimeout(() => {
    keyboard$.next('Key Pressed Event: Ctrl');
}, 1000)

setTimeout(() => {
    keyboard$.next('Key Pressed Event: C');
}, 2000)

setTimeout(() => {
    mouse$.next('Mouse Click Event: Left Click');
}, 2000)

zip

zip 的作用于 merge 很像,但是 zip 会等待所有 Observable 都发出值之后,将这些值组合成一个数组,然后一起发送个订阅者。zip 同时具有和 combineLatest 同样的特性。

import { zip, Subject } from 'rxjs'

const keyboard$ = new Subject();
const mouse$ = new Subject();

zip(keyboard$, mouse$).subscribe(console.log)

setTimeout(() => {
    keyboard$.next('Key Pressed Event: Ctrl');
}, 1000)

setTimeout(() => {
    keyboard$.next('Key Pressed Event: C');
}, 2000)

setTimeout(() => {
    mouse$.next('Mouse Click Event: Left Click');
}, 2000

自定义操作符

现在我们已经了解了大多数 RxJS 操作符,如果你有认真思考,可能想知道,我们能否自己创建自定义的操作符呢?

当然可以,而且很容易。

下面我们来学习如何创建自定义的操作符。

发布和修改对象

我们现在有一个场景,我们需要对用户提交的表单数据进行修改。该数据是一个对象,对象中包含 username 属性,我们要生成一个随机字符串拼接到原始的 username 后面,然后将这个对象提交到服务器。

下面是实现代码,但是其中存在一个问题,就是在 subscribe 的回调函数中修改了用户名,会导致原始对象一起被修改。

import { of } from 'rxjs'

const formData = {
    username: "小明",
    name: "xiaoming",
    age: 24
}

of(formData).subscribe(data => {
    data.username = data.username + "_random";
    console.log("新的用户名: " + data.username);
})

setTimeout(() => console.log("老的用户名: " + formData.username))

使用映射运算符将对象引用映射到另一个对象

为了解决这个问题,我们可以使用 map 操作符,它可以将对象的引用映射到另一个对象上。map 当然可以解决这个问题,但是如果我们的程序中存在多个位置都需要使用这种相同的逻辑,我们希望能够重用这些代码。推荐的做法是使用 Object 的 assign 来处理。

import { of } from 'rxjs'
import { map } from 'rxjs/operators'

const formData = {
    username: "小明",
    name: "xiaoming",
    age: 24
}

of(formData)
  .pipe(map(data => Object.assign({}, data)))
  .subscribe(data => {
      data.username = data.username + "_random";
      console.log("新的用户名: " + data.username);
  })

setTimeout(() => console.log("老的用户名: " + formData.username))

自定义运算符-clone

操作符是一个输出 Observable 的 Observable。用代码翻译的话,如下所示:

function clone() {
  return function(source) {
    return source
  }
}

我们可以将对原始 Observable 的数据转换放在返回的函数中。

function clone() {
  return function(source) {
    return source.pipe(map(data => Object.assign({}, data)))
  }
}

然后将它应用在上面的场景中。

import { of } from 'rxjs'
import { map } from 'rxjs/operators'

function clone() {
  return function(source) {
    return source.pipe(map(data => Object.assign({}, data)))
  }
}

const formData = {
    username: "小明",
    name: "xiaoming",
    age: 24
}

of(formData)
  .pipe(clone())
  .subscribe(data => {
      data.username = data.username + "_random";
      console.log("新的用户名: " + data.username);
  })

setTimeout(() => console.log("老的用户名: " + formData.username))

但是还有一点需要注意,clone 操作符没有产生一个新的 Observable。我们还需要改造 clone 操作符,让它返回一个新的 Observable。

import { of, Observable } from 'rxjs'
import { map } from 'rxjs/operators'

function clone() {
  return function(source) {
    return new Observable(subscriber => {
      const subscription = source.subscribe({
        next(value) {
          subscriber.next(Object.assign({}, value))
        },
        error(error) {
          subscriber.error(error);
        },
        complete() {
          subscriber.complete();
        }
      })
      return () => subscription.unsubscribe()
    })
  }
}

const formData = {
    username: "小明",
    name: "xiaoming",
    age: 24
}

of(formData)
  .pipe(clone())
  .subscribe(data => {
      data.username = data.username + "_random";
      console.log("新的用户名: " + data.username);
  })

setTimeout(() => console.log("老的用户名: " + formData.username))

在 clone 函数中,我们返回了一个新的 Observable,它会为每个从原始 Observable 传入的对象生成一个一摸一样但引用不同的克隆对象。为了防止内存泄漏,我们提供了取消订阅这个内部 Observable 的方法。

现在我们已经学会了如何自定义操作符。灵活的创造自己的操作符,可以最大限度的重用代码。