Reactive Programming 之 RxJS 入门

412 阅读3分钟

最近半年开始接触 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永远只在a 永远只在 a 发生变化的时候,变化。

小结:其实在前端编程中,这种 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 我们可以找到更好的实现把。