本文来自近期在内部做的一次关于 RxJS 分享,希望通过我的了解和认识能帮助大家在深入 RxJS 的学习之前提供些帮助。
原文链接请移步:RxJS 初探
经典例子:小试牛刀 (轻而易举搞定异步处理)
场景:input 输入框实时搜索,根据输入的关键字,实时发送异步请求进行搜索;
过程拆解:
- 监听 input 元素的 keyup 事件;(监听 keyup 是有意为之)
- 触发 keyup 事件,送出 value 值;
- 根据 value 值进行异步请求搜索;
- 将返回的数据渲染到 DOM;
面临的问题:
- 不停的打字会连续触发异步请求,占用服务器端资源;
- 相邻的 keyup 事件,有可能发出一样的 value 值(先输入了
abc
,请求出去了,又输入了abcde
,然后迅速的删除了de
,于是又发出了一个abc
的异步请求;又或者按下了键盘上不会改变 value 的按键); - 发出多个异步请求以后,每个事件所消费的时间不一定相同。如果前一个异步的事件过程很慢,那么当它返回时,有可能把后面的异步事件率先返回的结果覆盖;
基础解决方案:
- 场景 1: debounce 搞得定;
- 场景 2: 有点难度,debounce 已经搞不定了,因为 相同的 value 值是在 debounce 处理之后发出的;
- 场景 3: 很麻烦,由于不同的请求所消耗的时间并不一样,所以无法保证异步返回的先后顺序,而我们期待的仅仅只是最后一个异步请求返回的结果,其它的都不关心;
1
,2
,3
单独来看,都是有解的,虽然麻烦一点,但如果 123
的问题要同时解决,非常麻烦。
RxJS 怎么做?
核心代码示例:
// 初始化一个 input 事件流
const input$ = fromEvent($("input"), "keyup");
const resultEle = $('result');
input$.pipe(
// 抛出 e.target.value
map((e) => e.target.value.trim()),
// 过滤不合法的 value
filter(value => !!value),
// 去抖动
debounceTime(400),
// distinctUntilChanged: 对于任意相邻的事件,如果它们的返回值一样,则只要取前一个 (可类比 React 中的 shouldComponentDidUpdate 生命周期的作用)
distinctUntilChanged(),
// switchMap 其实是 map 和 switch 的组合功能,所以这里做了两个事情:
// 1 map. 将输入的 input dom 事件流装换为 searchRepoInfo$ 事件流,从而发起异步搜索请求
// 2 switch. 如果前一个事件流(从输入到异步请求是一次完整的流)没有结束又再次输入了新的值,则抛弃之前的(流),从而取消异步请求
switchMap(searchRepoInfo$),
// 处理响应结果
map(response => {
if (response.status !== 200) {
throw "出错误了";
}
return response.response;
}),
// 渲染响应结果
tap(repos => {
resultEle.innerHTML = repos.items.map(item => `${item.full_name}
`).join('');
}),
// 异常捕获
catchError(error => {
console.error(error);
return of("error");
}),
// 如果出错了,可以无限重试
repeat()
).subscribe();
弹珠图:
input: -(space)--(400ms)--r
-(space)--(400ms)--r-e
-(space)--(400ms)--r-e--a
-(space)--(400ms)--r-e--a--c
-(space)--(400ms)--r-e--a--c--t
-(space)--(400ms)--r-e--a--c--t--(400ms)--(->(键盘右键))---
debounceTime(400) 筛选出时间间隔不少于 400 ms 的事件
input$ -(space)--(400ms)--react--(400ms)--react(该值由于键盘右键触发)---
filter(text => !!text) 去除掉无效值
input$ ------react--react(该值由于键盘右键触发)---
distinctUntilChanged 去除重复的值,对于任意相邻的事件,如果它们的返回值一样,则只要取前一个
input$ ------react-----
switchMap(() => searchUserInfo$(react)) 发起异步请求
result response
如果回到 JQuery 的时代,要实现上面的功能,听说要写 900 多行代码。而使用 RxJS 来实现,不费吹灰之力。
RxJS 是什么?
关于 RxJS 我们可能了解到很多不同的描述,官方的解释是使用 Observables 的响应式编程库。它是 Reactive Extensions 思想在 JavaScript 中的实现,而 Rx 最开始是微软 .NET 的一个响应式扩展,最后经由 RxJava 发扬光大。
那什么是响应式编程呢?目前有很多的解释和争论,甚至对于 Rx 到底是不是纯粹的响应式编程也是存在不同的声音的。 外网有一篇被广泛阅读的文章 What is Reactive Programming(一个 gist 片段,坐拥 15K start) 中提到:Reactive programming is programming with asynchronous data streams.(响应式编程就是使用异步数据流进行编程)
。
如果我们从时间的维度去理解 FRP,那么:
FRP is about 'datatypes that represent a value 'over time'
参看 stackoverflow-What is (functional) reactive programming?。
即 FRP 是随 时间推移(over time) 而代表 值(value) 的 数据类型(datatypes),这里 时间 是一个非常关键的要素。
在 FRP 出现之前,几乎没有软件思想考虑过时间这个维度,在旧有的程序编码观念中,变量(状态)随着时间的推移,虽然会更据事件的触发而发生变化,但只是平面上孤立,离散的点(举个例子,拖拽的例子)。 而拥有了 FRP(RxJS) 后,程序设计中便拥有了对 时间 维度的掌控,业务逻辑不过是起始值随着时间的流动而推演出来的流(Stream),我们可以站时间的尺度上去思考过去,现在,和未来。
《三体》中领悟了操纵高维时空能力的女巫能从远处透过脑壳拿出敌人的大脑---别人惊讶她是如何做到的,而她只不过是看到了伸手直接拿了而已。
掌控时间
被祭献的少女
八稚女背后的故事
游戏厅里八稚女的出招顺序是: ↓ ↘ → ↘ ↓ ↙ ← + A 或 C
,方便键盘模拟我们简化成:↓ → ↓ ← + a (下 右 下 左 a)
:
核心代码:
// window keyup 事件流
const keyup$ = fromEvent(window, "keyup");
// 必杀技事件流
const ultimateKeys$ = keyup$.pipe(
// 以 3s 的时间周期为尺度,对数据流进行缓存截取
bufferTime(3000),
// 必杀技的按键组合必须大于 五个 键位
filter(events => events.length >= 5),
// 将历次的 event 对象的 key 映射出来
map(events => events.map(e => e.key)),
// 过滤掉不属于可以触发必杀技的按键集合
filter(isultimateKeyboradCollections),
// 发出必杀技
tap(console.log),
tap(() => alert('fire ultimate')),
).subscribe();
根据 keyup
在一定 时间阈值
内 filter
的按键组合来产生判断是否可以使用必杀技, 弹珠图:
弹珠图:
keyup: ---左---下---上---下-右--下-左--a-右
bufferTime(3000)
buffer: ------左下上------下右下左a右-----
filter(isUltimateKeyboradCollections)
filter: -----------------下右下左a右----
八稚女: ---F---F-----F-------T------F---
bufferTime 缓冲特定时间段内从源 Observable 发出的可观察值。
掌控空间
开篇的 小试牛刀
已经展示了一个 Observable 在空间维度上进行转换的例子。
# switchMap 是 map + switch 的快捷组合操作符
input$.pipe(switchMap(searchUserInfo$))
<==== 等价于 ====>
input$.pipe(
map(searchUserInfo$),
switch()
)
弹珠图:
input: ---Jack--- # 一维 Observable
map: ---searchUserInfo$(Jack)--- # 二维 Observable
switch: ---res$(jack)--- # 一维 Observable
再来看一个拖拽的场景,鼠标在元素上方按下并且移动鼠标时,元素会跟随鼠标移动,鼠标抬起时,拖拽结束:
const $ = id => document.getElementById(id);
const dragBox = $("drag-box");
// mouse down 事件流
const mouseDown$ = fromEvent(dragBox, "mousedown");
// mouse move 事件流
const mouseMove$ = fromEvent(window, "mousemove");
// mouse up 事件流
const mouseUp$ = fromEvent(window, "mouseup");
mouseDown$
.pipe(
switchMap(() => mouseMove$.pipe(takeUntil(mouseUp$))),
withLatestFrom(mouseDown$, (mouse: MouseEvent, down: MouseEvent) => {
return {
offetX: mouse.clientX - down.offsetX,
offetY: mouse.clientY - down.offsetY
};
}),
tap(offset => {
dragBox.style.left = offset.offetX + "px";
dragBox.style.top = offset.offetY + "px";
})
)
.subscribe();
代码简单且通俗易懂,并轻而易举同时拥有了对空间和时间的掌控。
mouseDown$.switchMap(() => mouseMove$.takeUntil(mouseUp$)))
, mouseDown
触发的时候(过去),转换为 mouseMove
事件(现在),而 mouseMove
将在 mouseUp
触发的时候结束(未来)
takeUntil 允许源 Observable 一直发出值,直到 Notify Observable 有值发出时完成。
withLatestFrom 是具有主从关系的操作符,只有在主 Observable 有值发出时,附属的 Observable 才会跟着被执行。
RxJS 背后的设计体系
事务与响应式
我们从的时间维度认识到了 FRP 在面对复杂逻辑上的一些处理方式,我们再回过头来,从事务的角度理解响应式编程。
看看下面这段代码:
// 使用 DOM API 构建 UI
const data = 'hello world';
const div = document.createElement('div')
const p = document.createElement('p')
p.textContent = data
const UI = div.append(p)
上面通过 JavaScript 的 DOM API 来将数据渲染到 UI 中,但是其实我们想要不是这种命令式的赋值动作,而是能建立 数据 与 UI 之间的一种永恒的关联
,即当数据发生改变时,UI 会自动更新,就像 React 一样:
// React 构建 UI 与数据的关联
const [data] = useState('hello world')
const h = React.craeteElement
const UI = h('div', null, h('p', null, data))
React 背后的思想其实就是响应式,而响应式的核心思想是通过某种方式来构建 事务 之间的关联关系。
事务是一个宽泛的概念,它可以是一个变量,一个对象,一段代码,或者就是一段业务逻辑(下文没有特殊说明统一指业务逻辑)
UI = f(State)
,React 通过 h
渲染函数构建了 UI
与 State
之间一种永恒的关联。这里的事务就是 UI
和 State
。
我们再来重新理解下 Reactive programming is programming with asynchronous data streams.(响应式编程就是使用异步数据流进行编程)
这句话,对此一个更加具象的解释其实是:响应式编程是一种通过 异步 和 数据流 来 构建事务关联 的编程模型。事务的关联 是响应式编程的核心理念,数据流 和 异步 是实现这个核心理念的关键。
- 事务关联:可以理解就是业务逻辑之前的关联。比如,UI 与数据,组件初始化获取数据加载,系统权限等。
- 数据流(Stream):数据流是相关事务之间沟通的桥梁,就像一条河,它可以被观测,被过滤,被操作,或者与另外一条流合并为一条新的流。但是只有数据流,还不能完美实现事务之间关联关系的构建,我们还需要异步。
- 异步:异步会尽可能挖掘设备的运行效率,同时可以让无关的事务之间相互独立。
使用 RxJS 构建事务之间的关联
RxJS 提供了一系列的 API,来为我们实现事务之间的关联关系:
- Observable: 被观察对象,一个值或者事件的集合;
- Observer:观察者,根据 Observable 进行处理;
- Subject:可以是 Observable,也可以是 Observer,会对内部的 Observers 进行多播(multicast);
- Operators: RxJS 的提供的一系列操作符,用于创建,组合,过滤 Observable;
import { Observable } from 'rxjs';
// 定义一个观察者
const observer = (subscriber) => {
console.log('hello world');
subscriber.next(1);
subscriber.next(2);
subscriber.next(3);
setTimeout(() => {
subscriber.next(4);
subscriber.complete();
}, 1000);
}
// 构建一个 Observable
const observable = new Observable(observer);
console.log('just before subscribe');
// 通过 Observable 的 subscribe 方法进行调用,并返回一个 subscription 用于资源释放
const subscription = observable.subscribe({
next(x) { console.log('got value ' + x); },
error(err) { console.error('something wrong occurred: ' + err); },
complete() { console.log('done'); }
});
console.log('just after subscribe');
// 取消订阅
subscription.unsubscribe();
我们创建了一个 Observable,通过 Observable 提供的 subscribe 方法对其进行订阅(调用
)。
在这里 Observable 是一个可以接收 Observer 对象作为参数的函数,并返回一个函数用来取消订阅。Observer 对象可以声明 next
、err
、complete
方法来处理流的不同状态。
Rx 是结合了观察者模式、迭代器模式、函数式编程,以使用可观察数据流进行异步编程,从上面的代码可略窥一二,前三个我们先不多讲,对于 Observable 中的异步
我们看下这段代码的输出结果:
just before subscribe
hello world
got value 1
got value 2
got value 3
just after subscribe
got value 4 // 异步
done
当调用 subscribe
后,Observable 可以同步地返回一个值,或异步地返回一个值,从这个角度我们可以看到,Observable 基于这样的实现,磨平了同步和异步的差异。
我们把数据流(Stream)比喻成一条河流,而这河里流动的就是 Observable。
观察者模式和迭代器模式的融合将 Observable 推向了一个全新的 Push 体系。
Push Vs Pull
Push 和 Pull 是生产者与消费者通信的两种模型:
_ | 生产者(Producer) | 消费者(Consumer) |
---|---|---|
Pull | 被动:被请求的时候产生数据 | 主动:决定何时请求数据 |
Push | 主动:按自己的节奏产生数据 | 被动:对接收到的数据进行处理 |
Pull 模型中,消费者决定何时从生产者那里接收数据,但是生产者本身并不知道自己什么时候会将数据发送给消费者。
- JavaScript 中函数是 Pull 模型,函数是生产者,执行函数的代码通过从调用中 拉 出 一个值 来消耗数据。
- 迭代器和生成器是另一种 Pull 模型,调用
iterator.next
的是消费者,和函数的区别是可以 拉 取 多个值。
Push 模型中,生产者决定何时像消费者发送数据,消费者并不知道自己何时会接收到数据。在 Push 模型中,消费者变主动为被动,消费者只管吃,反正来了食物我就吃,而不用再去操心食物是怎么来的。
- JS 的"事件系统"是一个 Push 模型:
window.addEventListener('click', () => {
// do something
})
- Promise 也是 JavaScript 中常见的 Push 模型,一个 Promise(生产者)发送一个 resolved,来执行一个回调(consumer),Promise 决定何时才将数据推送给消费者。
function sleep() {
return new Promise((resolve) => {
setTimeout(() => resolve('Beef'), 1000)
})
}
sleep().then((food) => console.log(`Wake up to eat ${food} !!!`));
RxJS 中的 Observable 是一个全新的 Push 模型,被观察者是一个可以产生多值的生产者,当产生新数据时,会主动推(异步或同步推送)给"观察者"(消费者)。
- | Single(单值-在时间序列上只能有一个值) | Multiple(多值-在时间序列上可以有多个值) |
---|---|---|
Pull | Function | Iterator |
Push | Promise | Observable |
Function
: 惰性求值,调用时会 同步 返回一个单一值。Generator
: 惰性求值,调用时会 同步 地返回零到(有可能的)无限多个值。Promise
:可能(或肯能不)返回单个值,整个过程不可取消,要么resolve
要么reject
并且只响应一次。Observable
:惰性求值,并且随着时间的推移可以 同步或者异步的发出零到无穷多个值,可取消,组合,过滤,扭转。
RxJS 的异常处理-恢复/重试-优雅的错误扭转
传统异常处理的方式和局限性
try/catch
:无法处理异步场景
// 无法捕获异常
try {
setTimeout(() => JSON.parse('invalid json'), 1000)
} catch (e) {
console.error(e);
}
- callback 回调函数: 可以解决
try/catch
只能支持同步操作的问题,但是如果事务之间存在依赖关联,则会造成 "回调地狱"; - promise 的异常处理机制
比较 try/catch
和callback
,promise 的异常处理机制确实进步了很多,但是依然有不足之处,不可取消,不支持重试,以及不强制要求异常捕获。
- rxjs 异常处理-兼具以上方式的所有优点
场景:实现一个登录控件,用户连续 N
次输入密码提交以后发生了异常(网络异常或者密码不对),登录流程需要暂停 P
秒以后再才能继续重试。
核心代码示例 :
const [handleLogin] = useEventCallback(
($event: Observable) =>
$event.pipe(
debounceTime(500),
tap((payload) => {
setLoading(true);
setErrMsg('');
}),
switchMap((payload) =>
from(requset(payload.username, payload.password))
),
tap((res) => {
setLoading(false);
if (res.code !== 200) {
return setErrMsg(res.message);
}
message.success('登录成功!')
}),
catchError((err) => {
setErrMsg(err.message)
return _throw(err);
}),
finalize(() => setLoading(false)),
retry(retryCount),
catchError((err) => {
setSubmitButtonDisabled(true);
return _throw(err);
}),
retryWhen((errors) =>
errors.pipe(
tap(() => {
message.warn({
type: 'warning',
content: `重试次数已经超过${retryCount}次,${retryDelayTime}后秒再试`
});
}),
delayWhen(() => timer(1000 * retryDelayTime).pipe(tap(() => setSubmitButtonDisabled(false))))
)
)
),
{ code: -10001 }
);
像呼吸一样自然:React hooks + RxJS
rxjs-hooks
小结
RxJS 中概念众多,初次学习门槛和使用成本都很高,而其学习曲线说是断崖式的,有图为证:
RxJS 打开了一个新的编程世界的大门,我们可以站在更高阶的时间维度去思考和组织代码,我们不在纠结于同步与异步的区别,其对响应编程的拓展,可以轻而易举的实现实世界中事务之间的关联构建,我们从命令式编程的琐碎与庞杂中解脱出来,其一切皆流的思想更是对以往编程思维的革新,面对纷繁复杂的业务逻辑,我们从未于此清晰,也从未如此迷惘。
本文初略介绍了 RxJS,所涉相关点不过其冰山一角,RxJS 的强大决于开发者的想象力。
参考文章/学习资料
推荐几个 RxJS 学习资源和几篇 RxJS 相关的精彩文章:
- rxjs-官方网站
- 30 天精通 RxJS
- RxJS 入门指引和初步应用
- 谈谈FRP和Observable(一)陈天
- 流动的数据——使用 RxJS 构造复杂单页应用的数据逻辑
- teambition-sdk 竞品 teambiton 技术窥探,使用 RxJS 构建一套前端数据层 sdk,不依赖任何展现框架,可以被任何展现框架使用,甚至可以在NodeJS中使用,对外提供了一整套 Reactive 的 API。
- 基于 RxJs 的前端数据层实践 类似 teambition-sdk 思路,使用 RxJS 大致复杂业务场景下的前端数据层。
- Cycle.js Cycle.js 是个不一样的 Web 框架,基于函数式和响应式,完全使用响应式编程作为编程范式,Cycle 提出了一种新的 MVI 的分形(fractal) 架构模式,定义了整个应用的代码组织方式和开发范式。