【前端】rxjs从入门到灵活使用

1,392 阅读10分钟

前言

  1. 相关文章很多了,不非常仔细探讨“流”这一概念了,我只是个api caller,从实用角度出发
  2. 虽然很多人建议不要把流当数组,但我个人习惯上还是喜欢将流比作数组,只是这个数组不能用下标访问、不知大小、顺序不一定固定
  3. 全程typescript,nodejs环境
  4. 省略以下导入:
import {
  Observable,
  Subject,
  Subscribable,
  of,
  interval,
  timer,
  pipe,
  Subscription,
  from,
  Subscriber,
  concat,
  merge,
} from "rxjs";
import { map, take, switchAll, mergeAll } from "rxjs/operators";

入门

基础模式

考虑以下代码:

function observable(observer) {
  observer.next(1);
  setTimeout(() => {
    observer.next(2);
  }, 1000);
}
const observer = {
  next: (x) => console.log(x),
};
observable(observer);

observable函数调用传入的observer对象方法,把值传递出去,有无异步代码都无所谓,逻辑和流程是显然易见的。

用rxjs包装下,他们做同样的事情:

const observable = new Observable(function (observer) {
  observer.next(1);
  setTimeout(() => {
    observer.next(2);
  }, 1000);
});
const observer = {
  next: (x) => console.log(x),
};
observable.subscribe(observer);

rxjs托管你传入的函数,封装为Observable对象,当你调用subscribe方法时,实际上就是在调用你一开始传入的那个函数,并无什么太高级的操作

功能上有扩展,subscribe方法会有返回值:

const subscription: Subscription = observable.subscribe(observer);

Subscription有个unsubscribe方法,调用后取消订阅。即上面代码追加这一行后,只会打印出 “1”

值得一提,一个标准的observer对象应该长这个样子:

const observer = {
  next: (x) => console.log(x),
  complete: () => console.log("complete"),
  error: () => console.log("error"),
};

增加了两个维度:“完成”和“错误”的概念,他们也是rxjs的基础部分。

不加他们也没问题,就像写不写try和catch一样,不写可能会导致大问题。但不写真的很爽,因为subscribe有几个重载函数形态,可以直接传一个函数当作对象的next函数。

灵活创建Observable对象

先假定以下代码到开头:

var g = 0; // 全部变量
const log = (x) => console.log(x); // 打印函数
function getPromise() { // 获取一个resolve全局变量g的Promise
  return new Promise<number>((res) => {
    setTimeout(() => res(++g), 1000); // 令全局g自增
  });
}

of、from

of 接收多个参数,这些参数就是流的值,下面代码会依次打印1,2,3:

const observable = of(1,2,3);
observable.subscribe(log);

from 有数组flat打平的那个味道,它可接受迭代器/数组/Promise/Observable 类型的一个参数,把它们的值一个一个的放进去流里,如:

const observable = from([1,2,3]);
observable.subscribe(log);

from和Promise相关的组合比较好玩,比较以下两种方式:

  1. 每次subscribe都是全新的Promise
const observable = new Observable<number>((observer) => {
  const promise = getPromise(); // 看前面的getPromise函数
  promise.then((x) => observer.next(x));
});
observable.subscribe(log); // 打印1
observable.subscribe(log); // 打印2

这里又强调一遍,subscribe实际上最终会调用你传入的那个函数,subscribe调用一次则你的函数被调用一次。

显然这里两次调用,打印的结果就是 1,2

  1. 通过闭包处理Promise
function createFunction(p: Promise<number>) {
  return function (observer) {
    p.then((x) => observer.next(x));
  };
}
const p = getPromise();
const observable = new Observable(createFunction(p));
observable.subscribe(log); // 打印1
observable.subscribe(log); // 打印1

显然,我们传入的那个函数是一个闭包函数,它每次从p这个Promise中取值,因此每次subscribe得到的都是同一个值。

from 传入一个Promise时就是采用的方式2(毕竟是直接传值进去,它不可能知道怎么生成这个值)

多播

正如上面所说,subscribe最终只是把传入的方法/对象传给原始的那个函数,相当于做了一个简单的转发。

如果你想让每次subscribe都得到同一份数据,或者说让数据源里每一次的next都能通知所有的observer,那么像上面方式2那样,搞个闭包就行了。

当然,js胜在灵活,你还可以用Subject 这个类来解决,下文讲解

concat

看它名字就知道,把两个流有序的连接成一个流。

const observable = concat(of(1,2), of(3,4));
observable.subscribe(log);  // 打印1,2,3,4

注意这个concat,第一个流结束后,第二个流的值才能被接收。

考虑以下代码:

const observable2 = new Observable((x) => {
  x.next(1);
  // x.complete();
});
const observable3 = new Observable((x) => {
  console.log(666);
  x.next(2);
});
concat(observable2, observable3).subscribe(log); // 打印1之后结束

其实rxjs这些都是基于订阅者模式实现的。concat的操作就是返回这么一个新的流:它订阅了observable2,当observable2结束时改为订阅observable3

只要observable2里没有调用complete,rxjs就判定这个流没有结束,因此observable3一直没有被订阅(它的subscribe方法一直没有被调用)。

nodejs环境中,跑完这段代码js执行栈为空,引擎的任务队列没有等待中的任务,此时便结束程序,因此结果为打印1之后结束程序。

如果不是用of、from这些官方函数,注意这个问题,是个坑 (玩熟之后其实基本都不会new Observable了)

merge

const observable2 = new Observable((x) => {
  x.next(1);
});
const observable3 = new Observable((x) => {
  x.next(2);
});
merge(observable2, observable3).subscribe(log); // 打印1,2之后结束

merge返回这么一个新的流:订阅所有的流(调用它们的subscribe方法,获得它们的输出后,输入到自己的流中)

combineLatest

const observable2 = new Observable((x) => {
  x.next(1);
});
const observable3 = new Observable((x) => {
  setTimeout(()=>{
    x.next(2);
  }, 1000)
});
combineLatest(observable2, observable3).subscribe(log); // 1s后打印数组 [1,2]

返回这么一个新的流:订阅所有的流,当所有的流都有发出至少一个值后,以数组形式把所有流最新的值当成自己发出的值

其它构建函数

像interval,timer,race,fromEvent这些,概念和使用都很简单,官方看几个例子就好了,此处略

简单地与前端结合

前端经常会遇到的,如果只用上面那些(fromEvent不提了,一点也不react/vue),那么和前端框架结合时就很蛋疼了。比如从input组件监听输入,然后打印到控制台(react):

function Demo() {
  const observer = useRef([]);
  const observable = new Observable((ob) => {
    observer.current.push(ob);
  });
  observable.subscribe((x) => console.log(1, x));
  observable.subscribe((x) => console.log(2, x));

  return (
    <input
      onChange={(event) =>
        observer.current.forEach((item) => item.next(event.target.value))
      }
    />
  );
}

上面这串代码看起来比较蛋疼。原因是按照前面讲解的逻辑,应该在new Observable时传进去一个函数,这个函数需要调用传入的ob对象来实现订阅者模式。

但是实际上的触发时机是input的onChange事件。也就是实际触发订阅者更新的地方/时机,与new Observable是分离的,这就让我们的代码很难受了。

Subject

Subject继承了Observable,有Observable的一切方法。

此外它额外提供next、error、complete,还有有点像Subscription的unsubscribe方法。

上面的蛋疼点可以转化为以下代码:

function Demo() {
  const ref = useRef(new Subject());
  const subject = ref.current;
  subject.subscribe((x) => console.log(1, x));
  subject.subscribe((x) => console.log(2, x));

  return (
    <input onChange={(event) => subject.next(event.target.value)} />
  );
}

有空翻开源码看的话,核心思想跟我上面的差不多:

  1. 内部搞一个list成员,储存subscribe时传入的observer对象
  2. Subject.next函数可以理解为: x => list.forEach(item=>item.next(x))
  3. 完成Observer的继承兼容(pipe管道那些,下文讲这个)

前面提到的多播这里也可以解决。Subject自己也有next、error、complete这些方法,并且基于上面的1,2两点,理下逻辑就有了:

const observable = new Observable((x) => {
  x.next(1); // 这里的x就是下面传入的subject对象
});

const subject = new Subject();
subject.subscribe(log)
subject.subscribe(log)
subject.subscribe(log)

observable.subscribe(subject) // 连续打印3个1

⚠:为什么要起名Subject呢?因为他是一个完整的主体:既能像Observable一样被订阅,又能被外部调用next方法发送一个值到流里(就是循环内部的list并调用它们的方法,不过这个行为抽象一下,也能叫“流”嘛),同时还能像observer一样去订阅别的流,是订阅者模式实现“流”这一概念的大成之作。

什么是流

如何生成一个流上面很清晰了,剩下的话,个人观点提一下什么是“流”。

像开头提到的,说到流,我脑子里想的就是一个数组。流的管道操作就是数组的值变化,比如数组每个元素延迟一秒出来、元素去重、每个元素都变成一个Promise、数组合并之类的东西。但是嘴巴上说出来的就术语化了,像什么流的延迟一秒、流的合并、流的切换,因为严格来讲数组没有这些概念

灵活使用-操作符

上面的玩6了之后,开始rxjs的灵活所在-操作符。后面不再搞什么new Observable了,实际上很少用得到。我的话基本是这么一个流程:

  1. of/from/Subject/concat 得到一个流
  2. Observable.pipe(xxx,xxx,xxx) 完成数据变换的需要,下文重点
  3. Observable.subscribe 业务中消费 流的数据

链式调用

rxjs6以前的做法,即.map(xxx).map(xxx).conatAll()这种形式。坏处是方法全都挂在了类上,打包时代码全引入无法优化,而且写的长一些就很头疼了。这里不再使用这种做法

管道操作

Observable.pipe中传入若干个操作符即可

const observable = of(1).pipe(
  map(x=>x+1), // 1 => 1+1
  map(x=>x+1),// 2 => 2+1
  map(x=>x+1),// 3 => 3+1
)
observable.subscribe(log) // 打印4

常用的操作符

map,take,takeLast,startWith,endWith,tap,debounceTime,throttleTime 这些都常用,不过很简单,官网一看就懂了,此处省略。

concatAll

const observable = of(of(1, 2), of(3, 4), of(5, 6));
observable.pipe(concatAll()).subscribe(log); // 打印1,2,3,4,5,6

“打平”内部的Observable值。

类比一下:数组的元素是数组:[[1,2],[3,4],[5,6]] ,flat打平后是[1,2,3,4,5,6]

这里类似,Observable里面的值又是一个Observable,那么concatAll() 之后就会打平内部的Observable。

为什么会出现这种情况?因为rxjs相当一部分是用于处理用户输入和网络请求的。

var userInput = "666";
const observable = of(userInput);
observable
  .pipe(
    map((x) => from(getPromise(x))),
    concatAll(), // 不接受参数,别和构建流的concat混为一谈,这个是管道符
  )
  .subscribe(log);

像这种,把用户的输入变成一个Promise(即网络请求),然后用from把Promise变成一个流返回。此时这个Observable里面的值又是一个Observable。显然我们最后subscribe是想要内部Observable的值,而不是一个Observable,这就是“打平”的作用之一。

当然其实concatAll本身不常用,只是为了方便说明,拉出来举例。

mergeAll,switchAll

和concatAll一样具有“打平”的效果。不同的是:

  1. concatAll:期待得到每一个inner Observable的结果,并且必须是前面的Observable complete之后才得到后一个的结果。(与前文的concat(x1, x2, x3)理念一样)
  2. mergeAll:期待得到每一个inner Observable的结果,但顺序不重要,哪个inner Observable发出了值便可马上得到。(与前文的merge(x1, x2, x3)理念一样)
  3. switchAll:期待得到最新的那个inner Observable的结果。常用于搜索(如百度的输入框)。
    主要是为了防止出现网络竞态:前一次的搜索结果晚于后一次的搜索结果回来,导致用户输入了新的查询,显示的却是旧的搜索结果

concatMap、mergeMap、switchMap

var userInput = "666";
const observable = of(userInput);
observable
  .pipe(
    concatMap((x) => from(getPromise(x))),
  )
  .subscribe(log);

与前面的有相同效果,concatMap意味着map一下,再concatAllmergeMapswitchMap同理,先map一下,再mergeAll/switchAll

总结

😂吹个水吧。以前看了几天什么是流、什么是管道,还是很蒙。后来坑踩多了,翻了下Observable和Subject源码粗略看了下,发现基础就是普通的订阅者模式,没有同步/异步区别(什么时候调用订阅者就什么时候执行,本身是同步操作,异步是因为使用者异步调用)。然后知道了inner Observable,也就理解了mergeMap,api使用就通畅无阻了。最后再回头看,才觉得这的确很有“流”的感觉

Learn RxJS 的中文版

rxmarbles 宝石图