前言
- 相关文章很多了,不非常仔细探讨“流”这一概念了,我只是个api caller,从实用角度出发
- 虽然很多人建议不要把流当数组,但我个人习惯上还是喜欢将流比作数组,只是这个数组不能用下标访问、不知大小、顺序不一定固定
- 全程typescript,nodejs环境
- 省略以下导入:
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相关的组合比较好玩,比较以下两种方式:
- 每次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
- 通过闭包处理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)} />
);
}
有空翻开源码看的话,核心思想跟我上面的差不多:
- 内部搞一个
list
成员,储存subscribe时传入的observer对象 - Subject.next函数可以理解为:
x => list.forEach(item=>item.next(x))
- 完成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了,实际上很少用得到。我的话基本是这么一个流程:
- of/from/Subject/concat 得到一个流
- Observable.pipe(xxx,xxx,xxx) 完成数据变换的需要,下文重点
- 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一样具有“打平”的效果。不同的是:
- concatAll:期待得到每一个inner Observable的结果,并且必须是前面的Observable complete之后才得到后一个的结果。(与前文的
concat(x1, x2, x3)
理念一样) - mergeAll:期待得到每一个inner Observable的结果,但顺序不重要,哪个inner Observable发出了值便可马上得到。(与前文的
merge(x1, x2, x3)
理念一样) - switchAll:期待得到最新的那个inner Observable的结果。常用于搜索(如百度的输入框)。
主要是为了防止出现网络竞态:前一次的搜索结果晚于后一次的搜索结果回来,导致用户输入了新的查询,显示的却是旧的搜索结果
concatMap、mergeMap、switchMap
var userInput = "666";
const observable = of(userInput);
observable
.pipe(
concatMap((x) => from(getPromise(x))),
)
.subscribe(log);
与前面的有相同效果,concatMap
意味着map
一下,再concatAll
。mergeMap
和switchMap
同理,先map
一下,再mergeAll
/switchAll
总结
😂吹个水吧。以前看了几天什么是流、什么是管道,还是很蒙。后来坑踩多了,翻了下Observable和Subject源码粗略看了下,发现基础就是普通的订阅者模式,没有同步/异步区别(什么时候调用订阅者就什么时候执行,本身是同步操作,异步是因为使用者异步调用)。然后知道了inner Observable,也就理解了mergeMap,api使用就通畅无阻了。最后再回头看,才觉得这的确很有“流”的感觉
rxmarbles 宝石图