什么是 RxJS?
RxJS 是一个 JavaScript 库,用来编写异步和基于事件的程序。RxJS 结合了观察者模式、迭代器模式和使用集合的函数式编程,以满足以一种理想方式来管理事件序列所需要的一切。
可以把 RxJS 当作用来处理事件的 Lodash。
为什么要学 Rxjs?
在现在的 Web 开发中,异步(Async)操作随处可见,比如使用 ajax 提交一个表单数据,我们需要等待服务端返回提交结果后执行后续操作,这就是一个典型的异步操作。虽然 JavaScript 为了方便开发者进行异步操作,提出了很多解决方案(callback,Promise,Async/await 等等),但是随着需求愈加复杂,如何优雅的管理异步操作仍然是个难题。
此外,异步操作 API 千奇百怪,五花八门:
DOM Events
XMLHttpRequest
fetch
WebSockets
Service Worker
Timer
……
以上这些常用的 API 全部都是异步的,但是每个使用起来却完全不同,无形中给开发者增加了很大的学习和记忆成本。
使用 RxJS 可以很好的帮助我们解决上面两个问题,控制大量异步代码的复杂度,保持代码可读性,并统一 API。
举个栗子:页面上有一个搜索框,用户可以输入文本进行搜索,搜索时要向服务端发送异步请求,为了减小服务端压力,前端需要控制请求频率,1 秒最多发送 5 次请求,并且输入为空时不发送请求,最后将搜索的结果显示在页面上。
通常我们的做法是这样的,先判断输入是否为空,如果不为空,则构造一个截流函数来控制请求频率,这其中涉及到创建和销毁定时器,此外,由于每个请求返回时间不确定,如何获取最后一次搜索结果,需要构造一个栈来保存请求顺序,想完美实现需求并不简单。
RxJS 是如何解决这个问题的呢?请看下面的代码:
// 1. 获取 dom 元素
const typingInput = document.querySelector(“#typing-input”); // 输入
const typingBack = document.querySelector(“#typing-back”); // 输出
// 2. 模拟异步请求
const getData = value =>
new Promise(resolve =>
setTimeout(() => resolve(`async data ${value}`), 1000)
);
// 3.RxJS 操作
const source$ = fromEvent(typingInput, “input”) // 创建事件数据流
.pipe(// 管道式操作
map(e => e.target.value), // 获取输入的数据
filter(i => i), // 过滤空数据
debounceTime(200), // 控制频率
switchMap(getData) // 转化数据为请求
);
// 4. 输入结果
source$.subscribe(asyncData => (typingBack.innerHTML = asyncData));
这就是全部代码,也许有些地方看不太懂,没关系,先不要着急,我们分步解读一下。
使用选择器获取了两个 dom 元素,第一个是输入框,第二个是搜索结果的容器;
使用 Promise 来模拟一个异步请求的函数,1 秒后返回请求结果;
这部分是 RxJS 操作,这里我们要先介绍一个概念,“数据流”(stream,简称“流”),“流”是 RxJS 中一种特殊的对象,我们可以想象数据流就像一条河流,而数据就是河里的水,顺流而下。代表“流”的变量一般用“$”结尾,这是 RxJS 编程的一种约定,被成为“芬兰式命名法”。代码中的 source$ 就是输入框的输入事件产生的数据流,我们可以使用 pipe 方法,像搭建“管道”一样对流中的数据进行加工,先使用 map 函数将事件对象转化成输入值,然后使用 fllter 方法过滤掉无效的输入,接着使用 debounceTime 控制数据向下流转的频率,最后使用 switchMap 把输入值转化成异步请求,整个数据流就构建完成了。
最后我们使用数据流的 subscribe 方法添加对数据的操作,也就是将请求的结果输出到页面上。注意,这段代码我们使用的全部变量都是用 const 声明的,全部是不可变的,也即是变量声明时是什么值,就永远是什么值,就像定义函数一样。相对于传统的指令式编程,RxJS 的代码就是由一个一个不可变的函数组成,每个函数只是对输入参数作出相应,然后返回结果,这样的代码写起来更加清爽,也更好维护。
RxJS 结合了函数式和响应式这两种编程思想,为了更深入的了解 RxJS,先来介绍一下什么是函数式编程和响应式编程。
函数式编程
函数式编程(Functional Porgramming)是一种编程范式,就像“面向对象编程”一样,是一种编写代码的“方法论”,告诉我们应该如何思考和解决问题。不同于面向对象编程,函数式编程强调使用函数来解决问题。
这里有两个问题:
任何语言都支持函数式编程么?并不是,能够支持函数式编程的语言至少要满足“函数是一等公民(First Class)”这个要求,意思是函数可以被赋值给一个变量,并且可以作为参数传递给另一个函数,也可以作为另一个函数的返回值。显然 JavaScript 满足这个条件。
函数式编程里的函数有什么特别之处?函数式编程里要求函数满足以下几个要求:声明式、纯函数、数据不可变。
声明式(Declarative)
与之对应的是命令式编程,也是最常见的编程模式。
举个例子,我们希望写个函数,把数组中的每个元素乘以 2,使用命令式编程,大概是这个样子的:
function double(arr) {
const result = []
for(let i=0,l=arr.length;i<l;i++) {
result.push(arr[i] * 2)
}
return result
}
我们将整个逻辑过程完整描述了一遍,完美。
但如果又来了一个新需求,实现一个新函数,把数组中每个元素加 1,简单,再来一遍:
function addOne(arr) {
const result = []
for(let i=0,l=arr.length;i<l;i++) {
result.push(arr[i] + 1)
}
return result
}
是不是感觉哪里不对?double 和 addOne 百分之九十的代码完全一样,“重复的代码是万恶之源。”我们应该想办法改进一下。
这里就体现了命令式编程的一个问题,程序按照逻辑过程来执行,但是很多问题都有相似的模式,比如上面的 double 和 addOne。很自然我们想把这个模式抽象一下,减少重复代码。
接下来我们使用 JavaScript 的 map 函数来重写 double 和 addOne:
function double(arr) {
return arr.map(function(item) {return item * 2})
}
function addOne(arr) {
return arr.map(function(item) {return item + 1})
}
重复代码全部被封装到 map 函数中。而我们需要做的只是告诉 map 函数应该如何映射数据,这就是声明式编程。相比较之前的代码,这样的代码更容易维护。
如果使用箭头函数,代码还可以进一步简化:
const double = arr => arr.map(item => item * 2)
const addOne = arr => arr.map(item => item + 1)
注意以上两个函数的返回结果都是一个新的数组,而并没有对原数组进行修改,这符合函数式编程的另外一个要求:纯函数。
纯函数(Pure Function)
纯函数是指满足以下两个条件的函数:
相同的参数输入,返回相同的输出结果;
函数内不会修改任何外部状态,比如全局变量或者传入的参数对象;
举个栗子:
const arr = [1, 2, 3, 4, 5]
arr.slice(0, 3) // [1, 2, 3]
arr.slice(0, 3) // [1, 2, 3]
arr.slice(0, 3) // [1, 2, 3]
JavaScript 中数组的 slice 方法不管执行几次,返回值都相同,并且没有改变任何外部状态,所以 slice 就是一个纯函数。
const arr = [1, 2, 3, 4, 5]
arr.splice(0, 3) // [1, 2, 3]
arr.splice(0, 3) // [4, 5]
arr.slice(0, 3) // []
相反,splice 方法每次调用的结果就不同,因为 splice 方法改变了全局变量 arr 的值,所以 splice 就不是纯函数。
不纯的函数往往会产生一些副作用(Side Effect),比如以下这些:
改变全局变量;
改变输入参数引用对象;
读取用户输入,比如调用了 alert 或者 confirm 函数;
抛出一个异常;
网络 I /O,比如发送了一个 AJAX 请求;
操作 DOM;
使用纯函数可以大大增强代码的可维护性,因为固定输入总是返回固定输出,所以更容易写单元测试,也就更不容易产生 bug。
数据不可变(Immutability)
数据不可变是函数式编程中十分重要的一个概念,意思是如果我们想改变一个变量的值,不是直接对这个变量进行修改,而是通过调用函数,产生一个新的变量。
如果你是一个前端工程师,肯定已经对数据不可变的好处深有体会。在 JavaScript 中,字符串(String),数字(Number)这两种类型就是不可变的,使用他们的时候往往不容易出错,而数组(Array)类型就是可变的,使用数组的 pop、push 等方法都会改变原数组对象,从而引发各种 bug。
注意,虽然 ES6 已经提出了使用 const 声明一个常量(不可变数据),但是这只能保证声明的对象的引用不可改变,而这个对象自身仍然可以变化。比如用 const 声明一个数组,使用 push 方法仍然可以像数组中添加元素。
和面向对象编程相比,面向对象编程更倾向把状态的改变封装到对象内部,以此让代码更清晰。而函数式编程倾向数据和函数分离,函数可以处理数据,但不改变原数据,而是通过产生新数据的方式作为运算结果,以此来尽量减少变化的部分,让我们的代码更清晰。
响应式编程
和函数式编程类似,响应式编程(Reactive Programming)也是一种编程的范式。从设计模式的角度来说,响应式编程就是“观察者模式”的一种有效实践。简单来说,响应式编程指当数据发生变化时,主动通知数据的使用者这个变化。
很多同学都使用过 vue 框架开发,vue 中很出名的数据双向绑定就是基于响应式编程的设计思想实现的。当我们在通过 v -bind 绑定一个数据到组件上以后,不管这个数据何时发生变化,都会主动通知绑定过的组件,使我们开发时可以专注处理数据本身,而不用关心如何同步数据。
而在相应时编程里最出名的框架就是微软开发的 Reactive Extension。这套框架旨在帮助开发者解决复杂的异步处理问题。我们的主角 RxJS 就是这个框架的 JS 版本。
怎么使用 RxJS
安装
npm install rxjs
导入
import Rx from “rxjs”;
请注意,这样导入会将整个 RxJS 库全部导入进来,而实际项目未必会用上 Rxjs 的全部功能,全部导入会让项目打包后变得非常大,我们推荐使用深链(deep link)的方式导入 Rxjs,只导入用的上的功能,比如我们要使用 Observable 类,就只导入它:
import {Observable} from “rxjs/Observable”;
实际项目中,按需导入是一个好办法,但是如果每个文件都写一堆 import 语句,那就太麻烦了。所以,更好的实践是用一个文件专门导入 RxJS 相关功能,其他文件再导入这个文件,把 RxJS 导入工作集中管理。
篇幅有限,下一讲将会讲解 RxJS 中几个核心概念,欢迎各位留言拍砖~