最近半年开始接触 RxJS,接触一段时间后发现,是真的很香。相比较而言,其入门门槛确实挺高的,而且也找不到太多的资料。其实看了很多视频和资料,觉得比较好的依然还是 André Staltz 和 Ben Lesh.
这里的基本内容都来自于André Staltz 的一篇文章:The introduction to Reactive Programming you've been missing,Egghead 上也有他做的事情课程。
在开始 RxJS 之前,我们先将一下最近很火的一种编程方法,叫做: Reactive Programming. 其本质,就是通过使用流(stream)的一种编程方法。
什么是流?
先来看一个例子,代码中有个变量: a,并且 a 不断被赋新值。
let a = 1;
console.log(a);
// some other logic
a = 2;
console.log(a);
// some other logic
a = 3;
console.log(a);
如果我们把 a 整个周期的指看作是一个整体,很容易想到 a 的所有的指,组合起来,应该是一个数组 aList, 以上逻辑就可以成为:
let aList = [1, 2, 3]
aList.forEach(console.log)
这样就已经比较接近我们所说的流了。当然 aList 只能知道 a 内容变化的历史,但是并不能知道,数据是什么时候变化的。
所谓 流/stream,就是数据基于时间(event)变化的整体。stream = data + event。
那接下来,我们看看如何通过流思维,看待 a 的变化:
const a$ = new Subject();
a$.subscribe((x) => { console.log(x) });
a$.next(1);
// some logic
a$.next(2);
// some logic
a$.next(3);
那么这样写的好处是什么呢?其实最直接的好处已经能够看出来了,不再需要每次在 a 变化的时候 console.log。
当我们把数据和它的变化看成一个整体(数据流)的时候,可以直接表述,不同数据流之间的关系,不用每次在不同的数据变化中添加代码逻辑。
我们可以再看一个简单的例子:
有变量 a, b, b 永远等于 a + 1
传统的代码逻辑:
let a = 1;
b = a + 1;
// some logic
a = 2;
b = a + 1;
我们必须找到所有 a 变化的地方,添加,b = a + 1; 当然,我们可以把,a, b 封装成一个对象,
const obj = {
a: 1,
get b() {
return a + 1;
}
};
obj.a = 2;
console.log(obj.b)
你会发现,b 的值是 3,同样满足了我们的要求,并且不需要重复写 b 与 a 的计算关系。但是不难发现,b 其实是一个计算值,不是一个值,也就是,每次读取 b 的时候都需要重复计算 b 与 a 的关系,这在读取 b 非常频繁或者 b 与 a 的计算关系相对复杂时,是非常浪费资源的。
流的最大的好处是,能够监听数据的变化,执行相应的操作,从而最大可能性的减小性能的开销。
// 用 stream 描述 a 与 b 的关系。
const a$ = new Subject();
const b$ = a$.pipe(map( x=> x + 1));
b$.subscribe(console.log);
a$.next(1);
a$.next(1);
运行后你会发现,b 发生变化的时候,变化。
小结:其实在前端编程中,这种 reactive 的数据形式我们已经遇到很多的,只是大多数,都是由前端框架来实现的。
比如:AngularJS 中的 watch('xxx'. () => {}) 和 VueJS 中的 computed。 只是实现远离略有不同,AngularJS 是脏检查看 watch 的内容是否有变化,computed 是通过依赖收集实现。
本质上,还是以为前端的数据交互频繁,直接描述数据之间的关系会大大降低编程的复杂性,减少事件的监听,相信大家一定写过不少 onXXXXXChange 的代码。
例子一 动态响应数据的变化
页面上有:Fist Name, Last Name, 要求修改 First Name 或者 Last Name 的时候动态修改 Full Name.
传统做法思路:分别给两个 input 添加事件监听,当 input 更新时,动态修改 Full Name.
<input onchange="updateFullName()"/>
或者:
document.getElementsByClassName('name').addEventListener("change", function updateFullName() { });
这里的思路其实都是:
先确定元素 fullName 的值 (firstName + lastName), 再思考 fullName 什么时候会变化呢?监听到事件 firstName change 的时候,修改一次,lastName change event 发生的时候,修改一次。随着业务逻辑越来越复杂,fullName 被修改的地方可能会离散在各个不同的地方。
流思维该如何思考呢:
先确定元素 fullName 与其他元素的关系。fullName = firstName + LastName, 以及 firstName 或者 lastName 变化的时候,fullName 如何响应。
至于,firstName,lastName 怎么变化,如何变化,由他们自己管理,只需要在变化的时候通知 fullName 变化就可以了。在业务变复杂的情况,也是这样层级依赖,每个元素管理自己的变化,并通知上一层,自己已经变了。
这样就可以把数据在各种 event 下的变化中抽离出来,当一个数据变化了,我们不需要去一个一个排查,它到底是在哪里发生了变化。
代码如下:
const fullName$ = combineLatest([firstName$, lastName$]).pipe(map(([firstName, lastName]) => `${firstName} ${lastName}`));
这里你就可以看出来,fullName 的值是什么,什么时候变化,都是集中的,但是之前的例子中,业务逻辑变复杂后,fullName 的更新逻辑可能散落在各个角落(当然你可以通过好的编码习惯来避免,比如 updateFullName function 作为唯一的修改方式。)但是逻辑依然是离散的。
例子二 Button 事件
界面上又一个按钮,依次记录按钮的点击时间(区分,单击,双击),250ms 内点击两次算双击,三次算三击,以此类推。
传统的事件思维已经很难处理这样的例子。
let timmer = new Date();
let counter = 0;
const events = [];
document.getElementsByClassName('btn').addEventListener("click", function (event) {
const currentTime = new Date();
if (currentTime - timer <= 250) {
counter = counter + 1;
} else {
events.push(counter);
timmer = new Date();
counter = 0;
}
});
借助于 RxJS 封装的各种工具方法(operator),我们可以轻松的解决以上问题。我们可以简单的,按照将 250ms 内出现的事件分组,然后打印出每一组有多少个子事件即可。
buffer(clickEvent$.pipe(throttleTime(250))).pipe(map(clickArray => clickArray.length))
这个例子说明了,流思维非常善于解决跟时间相关的模型,通过对于工具方法的掌握,可以大大简化代码的逻辑。所谓全局思维和局部思维的区别吧。
Observable
Observable 的本质是什么?
简单的说,本质上: Observable is a function to generate values.
const observable = (observer) => {}
函数中会定义 value 的生成方式,通过调用,observer.next 来执行,observer 中定义的行为。(当然还有,error, complete,多数情况我们只关心如何生成 value 也就是什么时候调用 next)。
假如:我们通过 setInterval 每三秒产生一个 value,并通知 observer. 类似这样:--1--2--3--4
const observable = (observer) => {
let counter = 0;
const id = setInterval(() => observer.next(counter++), 3000);
}
这就是最简单的 observable, 我们可以通过调用 function 来执行。
observable({ next: (val) => console.log(val) });
这里不难发现其实有一个问题,就是:setInterval 会一直执行下去。我们无法控制 observable 让它停下来。
其实:定义 observable 的时候,可以 return 一个 function, 在这个 function 中可以做 observable 执行过程的清理操作,类似 C++ 中的析构函数。
const observable = (observer) => {
let counter = 0;
const id = setInterval(() => observer.next(counter++), 3000);
return () => clearInterval(id);
}
const cleanUpObservable = observable({ next: (val) => console.log(val) });
cleanUpObservable();
通过以上的例子我们可以看出:
- observable 本质上是一个 generate value 的函数,必须调用才会被执行。
- 函数的每一次调用,都会是一个新的执行空间,不同的函数执行是不相关的,observable 本质上是一个函数,所有互相也没有关系。 也就是我们常说,observable 是 cold 或者 lazy.
- observable 被调用后,必须能被关闭,否则会一只运行下去。
Observable 的 class 形式
其实不难发现,这不是我们熟悉的 observable, 我们熟悉的 observable 是一个 class,而不是 一个 function. 但是我们要知道,本质上,它其实是一个函数。我们可以看一下 Obserable class 是如何定义的:
class Obserbable {
construct(subscribe) {
this.subscribe = (observer) => {
unsubscribe: subscribe(observer),
};
}
}
Obserbable 中的 subscribe method 就是我们之前使用的 observable 函数,这也就是为什么,只有,subscribe 以后,value 才会开始生成。
Operator
fromEvent, of, map, filter 等等。
简单的说,operator 是一个输入为 Observable,返回也是 Observable 的方法。
const operator = (observable) => {
return (observer) => {
// generate values;
};
}
const operator = (observable) => {
return (observer) => {
observable({
next: (val) => observer.next(val);
});
};
}
所以说,operator 的本质是,描述从一个数据流到另一个数据流之间的关系。可以类比:Lodash。
const map = (observable, mapFun) => {
return (observer) => {
observable({
next: (val) => {
observer.next(mapFun(val));
}
});
};
}
const filter = (observable, filterFun) => {
return (observer) => {
observable({
next: (val) => {
if(filterFun(val)) {
observer.next(val);
}
}
});
};
}
这样,我们其实就实现了一个简单的 map 和 filter operator,那我们看一下,该如何使用呢?
source: --1--2--3--4--5--6--
get even: -----2-----4-----6--
number/2 -----1-----2-----3--
const data$ = observable();
const eventData$ = filter(data$, val => val%2 = 0);
const divideTwo$ = map(eventData$, val => val/2);
// 或者:
map(filter(data$, val => val%2 = 0), val => val/2);
由以上可以看出,因为 observable 本质上都是 function, 调用关系是:
divideTwo$ => eventData$ => data$
也就不难看出,只有divideTwo$ 被调用了,前面定义的所有函数才会被依次调用。(subscribe)
问题:我们能不能像数组或者 promise 一样写成链式?
const dataList.filter(val => val%2 = 0).map(val => val/2);
之所以数组能够写成链式,是以为 operator 永远返回一个新的数组,跟 observable 非常一致。其实也就很容易想到,我们可以使用 Observable 的 class 形式,然后把 operator 作为 class 的方法。
class Obserbable {
construct(subscribe) {
this.subscribe = (observer) => {
unsubscribe: subscribe(observer),
};
}
map(mapFun) {
return new Observable((observer) => {
this.subscribe({
next: (val) => {
observer.next(mapFun(val));
}
});
});
}
...
...
}
这样,上面的例子也就可以写成:
const divideTwo$ = data$
.filter(val => val%2 = 0)
.map(val => val/2);
实际上,这也是我们通常用 class 而不是 function 来表示 observable 的其中一个原因(当然 funcational 不容易理解也是一个原因)。但是本质上 observable 是一个函数,这能打通很多我们对于 observable 的误解。比如:
- observable 是无状态的,并没有 value 存在 observable 中,因为它本质上是描述数据生成的 function 而不是 一个存储数据的 object。
- observable 是 Lazy/cold,如果没有显式调用(subscribe),数据是不会生成的,很容易理解,因为它本质上是一个 function 的定义。
- 对于同一个 observable,在不同的地方 subscribe,是无关的。function 执行多次,互相没有关联是一致的。
为什么我们常用的是 pipe 而不是 链式调用?
其实原因很简单,RxJS 的 operator 有上百个,这都得添加到 Observable class 上,然后这个成员方法跟 Observable 的逻辑本身又是无关的。当我们要自定义一个 Operator 必须修改原来的 class,或者继承原来的 class, 很麻烦。
因为 operator 是链式的,前一个方法的输出是后一个方法的输入,很容易想到,我们可以通过数组描述这种状态:
const operators = [filter, map];
还是以刚刚的例子:
const originalMap = (observable, mapFun) => {
return (observer) => {
observable({
next: (val) => {
observer.next(mapFun(val));
}
});
};
}
因为我们将 operator 做成一个数组,我们就希望它的接口能够统一,比如:(observable)=> observable,当一个函数有多个参数,我们需要简化成一个参数的时候,可以通过柯里化实现。比如 map:
function map(mapFun) {
return (observable) => originalMap(observable, mapFun);
}
这样,我们只需要传入 observable,然后再依次调用 operator,直到数组结束。
function composate(observable, ...operators) {
return operators.reduce(previousState => operator(previousState), observable);
}
所以说,最开始的例子,我们又可以写成:
composate(
data$,
filter(val => val%2 = 0),
map(val => val/2),
)
当然,我们可以用 class的方法实现,并且把 composate 改名为 pipe,就成了:
data$.pipe(
filter(val => val%2 = 0),
map(val => val/2)
);
这里其实我们可以发现,Observable 本质上是一个函数,而 operator 的 pipe 方案,也是 functional programming 中的概念:Curry 和 Function Composition。这里只是一笔带过了,有时间我们可以细讲 functional 的部分。
这里不禁想,RxJS 为了让大家方便了解 observable 强行讲其 class 化了,是否,我们用纯 functional 的思维来理解会更加容易呢?observable是一个 function 而不是一个 object 完全是因为转 class 造成的,也许,对于 observable 我们可以找到更好的实现把。