查看原文
其他

【第1340期】希望是最浅显易懂的RxJS教程

huli 前端早读课 2019-06-05

前言

偶有一天在邮箱里看到的。刚好周末又听到相关的主题,所以。。。今日早读文章由@huli分享。

@huli,野生工程师,相信分享与交流能让世界变得更美好。

正文从这开始~

关注RxJS已经好一段时间了,最早知道这个东西是因为redux-observable,是一个redux的middleware,Netflix利用它来解决复杂的非同步相关问题,那时候我连redux-saga都还没搞懂,没想到就又有新的东西出来了。

半年前花了一些时间,在网络上找了很多资料,试着想要搞懂这整个东西。可是对我来说,很多教程的步调都太快了,不然就是讲的太仔细,反而让初学者无所适从。

这次有机会在公司的新项目里面尝试使用redux-observable,身为提倡要引入的人,势必要对这个东西有一定的了解。秉持着这个想法,上周认真花了点时间再次把相关资源都研究了一遍,渐渐整理出一套“我觉得应该可以把RxJS讲得更好懂”的方法,在这边跟大家分享一下。

好,那就让我们开始吧!

请你先忘掉RxJS

没错,你没有看错。

要学会RxJS的第一件事就是:忘记它。

忘记有这个东西,完全忘记,先让我讲几个其他东西,等我们需要讲到RxJS的时候,我会在提醒你。

在我们谈到主角之前,先来做一些有趣的事情吧。

程序基础能力测试

先让我们做一个简单的练习题暖身,题目是这样的:

有一个数组,里面包含三种类型,数字、a~z组成的字符串、数字组成的字符串,请你把每个数字以及数字组成的字符串乘以二之后相加。
比如输入:[1,5,9,3,’hi’,’tb’,456,’11’,’yoyoyo’]

你看完之后应该会说:这有什么难的?并且在一分钟以内就写出下面的代码:

const source = [ 1 , 5 , 9 , 3 , 'hi' , 'tb' , 456 , '11' , 'yoyoyo' ];
let total = 0 ;
for ( let i = 0 ; i < source.length; i++) {
 
let num = parseInt (source[i], 10 );
 
if (! isNaN (num)) {
   total
+= num * 2 ;
 
}
}

相信大家一定都是很直觉的就写出上面的代码,但如果你是个functional programming 的爱好者,你可能会改用另外一种思路来解决问题:

const source = [ 1 , 5 , 9 , 3 , 'hi' , 'tb' , 456 , '11' , 'yoyoyo' ];
let total = source
 
.map( x =>  parseInt (x, 10 ))
 
.filter( x => ! isNaN (x))
 
.map( x => x * 2 )
 
.reduce( ( total, value ) => total + value )

一开始的例子叫做Imperative(命令式),用数组搭配一堆函数的例子叫做Declarative(声明式)。如果你去查了一下定义,应该会看到这两个的解释:

Imperative 是命令机器去做事情(how),这样不管你想要的是什么(what),都会按照你的命令实现;Declarative 是告诉机器你想要的是什么(what),让机器想出如何去做(how)

好,你有看懂上面这些在说什么吗?

我是没有啦。

所以让我们再看一个例子,其实Declarative 你已经常常在用了,只是你不知道而已,那就是SQL:

SELECT * from dogs INNER  JOIN owners WHERE dogs.owner_id = owners.id

这句话就是:我要所有狗的资料加上主人的资料。

我只有说「我要」而已,那要怎么拿到这些资料?我不知道,我也不用知道,都让SQL 底层决定怎么去操作就好。

如果我要自己做出这些资料,在JavaScript里面我必须这样写(代码取自声明式编程和命令式编程的比较):

//dogs = [{name: 'Fido', owner_id: 1}, {...}, ... ]
//owners = [{id: 1, name: 'Bob'}, {...}, ...]
var dogsWithOwners = []
var dog, owner
for ( var di= 0 ; di < dogs.length; di++) {
 dog
= dogs[di]
 
for ( var oi= 0 ; oi < owners.length; oi++) {
   owner
= owners[oi]
   
if (owner && dog.owner_id == owner.id) {
     dogsWithOwners
.push({
       dog
: dog,
       owner
: owner
     
})
   
}
 
}
}

应该可以大致体验出两者的差别吧?后者你必须自己一步步去决定该怎么做,而前者只是仅仅跟你说:「我想要怎样的资料」而已。

接着我们再把目光放回到把数字乘以二相加的那个练习。对我来说,最大的不同点是后面那个用数组搭配函数的例子,他的核心概念是:

把原始资料经过一连串的转换,变成你想要的信息

这点超级重要,因为在一开始的例子中,我们是自己一步步去parse,去检查去相加,得出数字的总和。而后面的那个例子,他是把原始的资料(数组),经过一系列的转换(map, filter, reduce),最后变成了我们想要的答案。

画成图的话,应该会长这样(请原谅我偷懒把乘二的部分拿掉了,但意思不影响):

把原始资料经过一连串的转换,最后变成你想要的答案,这点就是后者最大的不同。只要你有了这个基础知识之后,再来看RxJS 就不会觉得太奇怪了。

Reactive Programming

谈到RxJS 的时候,都会谈到Reactive 这个词,那什么是Reactive 呢?可以从英文上的字义来看,这个单字的意思是:「反应、反应性的」,意思就是你要对一些事情做出反应。

所以Reactive 其实就是在讲说:「某些事情发生时,我能够做出反应」。

让我们来举一个大家非常熟知的例子:

window .addEventListener( 'click' , function () {
 console
.log( 'click!' );
})

我们加了一个event listener 在window 上面,所以我们可以监听到这个事件,每当使用者点击的时候就列印出log。换句话说,这样就是:「当window 被点击时,我可以做出反应」。

正式进入RxJS

如果你去看ReactiveX的网页,你会发现他有明确的定义ReactiveX:

ReactiveX is a combination of the best ideas from
the Observer pattern, the Iterator pattern, and functional programming

第一个Observer pattern 就像是event listener 那样,在某些事情发生时,我们可以对其作出反应;第二个Iterator pattern 我们跳过不讲,我认为暂时不影响理解;第三个就像是一开始的例子,我们可以把一个数组经过多次转换,转换成我们想要的资料。

在Reactive Programming 里面,最重要的两个东西叫做Observable 跟Observer,其实一开始让我最困惑的点是因为我英文不好,不知道这两个到底谁是观察的谁是被观察的。

先把它们翻成中文,Observable 就是「可被观察的」,Observer 就是所谓的「观察者」。

这是什么意思呢?就如同上面的例子一样,当(可被观察的东西)有事情发生,(Observer,观察者)就可以做出反应。

直接举一个例子你就知道了:

Rx.Observable.fromEvent( window , 'click' )
 
.subscribe( e => {
   console
.log( 'click~' );
 
})

上面这段代码跟window加上event listener在做的事情完全一样,只是这边我们使用了RxJS提供的方法叫做fromEvent,来把一个event转成Observable(可被观察的),并且在最后加上subscribe。

这样写就代表说我订阅了这个Observable,只要有任何事情发生,就会执行我传进去的function。

所以到底什么是Observable?

Observable 就是一个可被观察的对象,这个对象可以是任何东西(例如说上述例子就是window 的click 事件),当有新资料的时候(例如说新的点击事件),你就可以接收到这个新资料的信息并且做出反应。

比起Observable这个冷冰冰的说法,我更喜欢的一个说法是stream,信息流。其实每一个Observable就是一个信息流,但什么是信息流?你就想像成是会一直增加元素的数组就好了,有新的事件发生就push进去。如果你喜欢更专业一点的说法,可以叫它:「时间序列上的一连串信息事件」(取自Reactive Programming简介与教学(以RxJS为例))

或是我再举一个例子,stream 的另外一个解释就是所谓的「串流视频」,意思就是随着你不断播放,就会不断下载新的片段进来。此时你脑中应该要有个画面,就是像水流那样,不断有新的东西流进来,这个东西就叫做stream。

我理解信息流了,然后呢?

上面有说过,我们可以把任何一个东西转成Observable,让它变成信息流,可是这不就跟addEventListener 一样吗?有什么特别的?

有,还真的比较特别。

希望你没有忘记我们刚开始做的那个小练习,就是把一个数组透过一系列转换,变成我们要的那个练习。我刚刚有说,你可以把Observable 想成是「会一直增加元素的数组」,这代表什么呢?

代表我们也可以把Observable 做一系列的转换!我们也可以用那些用在数组上的function!

Rx.Observable.fromEvent( window , 'click' )
 
.map( e => e.target)
 
.subscribe( value => {
   console
.log( 'click: ' , value)
 
})

我们把click 事件经过map 转换为点击到的element,所以当我们最后在subscribe 的时候,收到的value 就会是我们点击的东西。

接着来看一个稍微进阶一点的例子:

Rx.Observable.fromEvent( window , 'click' )
 
.map( e =>  1 )
 
.scan( ( total, now ) => total + now)
 
.subscribe( value => {
   document
.querySelector( '#counter' ).innerText = value;
 
})

首先我们先把每一个click事件都透过map转换成1(或者你也可以写成.mapTo(1)),所以每按一次就送出一个数字1。scan的话其实就是我们一开始对数组用的reduce,你可以想成是换个名字而已。透过scan加总以后传给subscriber,显示在页面上面。

就这样简单几行,就完成了一个计算点击次数的counter。

可以用一个简单的gif 图来表示上面的范例:

可是Observable 不只这样而已,接下来我们要进入到它最厉害的地方了。

威力无穷的组合技

如果把两个数组合并,会变成什么?例如说[1, 2, 3]跟[4, 5, 6]?

这要看你指的「合并」是什么,如果是指相连,那就是[1, 2, 3, 4, 5, 6],如果是指相加,那就是[5, 7, 9]。

那如果把两个Observable 合并会变成什么?

Observable 跟数组的差别就在于多了一个维度:时间。

Observable 是「时间序列上的一连串信息事件」,就像我前面讲的一样,可以看成是一个一直会有新资料进来的数组。

我们先来看看一张很棒的图,很清楚地解释了两个Observable 合并会变成什么:

取自:http://rxmarbles.com/#merge

上面是一个Observable,每一个圆点代表一个资料,下面也是一样,把这两个合并之后就变成最下面那一条,看图解应该还满好懂的,就像是把两个时间轴合并一样。

让我们来看一个可以展现合并强大之处的范例,我们有+1 跟-1 两个按钮以及文字显示现在的数字是多少:

该怎么实现这个功能呢?基本的想法就是我们先把每个+1的click事件都通过mapTo变成数字1,取叫Observable_plus1好了。再做出一个Observable_minus1是把每个-1的click事件都通过mapTo变成数字-1。

把这两个Observable合并之后,再利用刚刚提到的scan加总,就是目前应该要显示的数字了!

Rx.Observable.fromEvent( document .querySelector( 'input[name=plus]' ), 'click' )
 
.mapTo( 1 )
 
.merge(
   
Rx.Observable.fromEvent( document .querySelector( 'input[name=minus]' ), 'click' )
     
.mapTo( -1 )
 
)
 
.scan( ( total, now ) => total + now)
 
.subscribe( value => {
   document
.querySelector( '#counter' ).innerText = value;
 
})

如果你还是不懂的话,可以参考下面的精美范例,示范这两个Observable是怎么合在一起的(O代表点击事件,+1跟-1则是mapTo之后的结果):

让我们来比较一下如果不用Observable 的话,代码会长怎样:

var total = 0 ;
document
.querySelector( 'input[name=plus]' ).addEventListener( 'click' , () => {
 total
++;
 document
.querySelector( '#counter' ).innerText = total;
})
document
.querySelector( 'input[name=minus]' ).addEventListener( 'click' , () => {
 total
--;
 document
.querySelector( '#counter' ).innerText = total;
})

有没有发觉两者真的差别很大?就如同我之前所说的,是两种完全不同的思考模式,所以Reactive Programming 困难的地方不是在于理解,也不是在于语法(这两者相信你目前都有些概念了),而是在于换一种全新的思考模式。

以上面的写法来说,就是告诉电脑:「按下加的时候就把一个变数+1,然后更改文字;按下减的时候就-1 并且也更改文字」,就可以达成计数器的功能。

以Reactive 的写法,就是把按下加当成一个信息流,把按下减也当成一个信息流,再透过各种function 把这两个流转换并且合并起来,让最后的那个流就是我们想要的结果(计数器)。

你现在应该能体会到我一开始说的了:「把原始信息经过一连串的转换,最后变成你想要的答案」,这点就是Reactive Programming 最大的特色。

组合技中的组合技

我们来看一个更复杂一点的范例,是在canvas 上面实现非常简单的绘图功能,就是鼠标按下去之后可以画画,放开来就停止。


要实现这个功能很简单,canvas提供lineTo(x, y)这个方法,只要在鼠标移动时不断调用这个方法,就可以不断画出图形来。但有一点要注意的是当你在按下鼠标时,应该先调用moveTo(x, y)把绘图的点移到指定位置,为什么呢?

假设我们第一次画图是在左上角,第二次按下鼠标的位置是在右下角,如果没有先用moveTo移动而是直接用lineTo的话,就会多一条线从左上角延伸到右下角。moveTo跟lineTo的差别就是前者只是移动,后者会跟上次的点连接在一起画成一条线。

var canvas = document .getElementById( 'canvas' );
var ctx = canvas.getContext( '2d' );
ctx
.beginPath(); //开始画画
function  draw ( e ) {
 ctx
.lineTo(e.clientX,e.clientY); //移到鼠标在的位置
 ctx
.stroke(); //画画
}
// 按下去鼠标才开始侦测mousemove 事件
canvas
.addEventListener( 'mousedown' , function ( e ) {
 ctx
.moveTo(e.clientX, e.clientY); //每次按下的时候必须要先把绘图的点移到那边,否则会受上次画的位置影响
 canvas
.addEventListener( 'mousemove' , draw);
})
// 放开鼠标就停止侦测
canvas
.addEventListener( 'mouseup' , function ( e ) {
 canvas
.removeEventListener( 'mousemove' , draw);
})

那如果在RxJS 里面,该怎么实作这个功能呢?

首先凭直觉,应该就是先加上mousedown的事件对吧!至少有个开头。

Rx.Observable.fromEvent(canvas, 'mousedown' )
 
.subscribe( e => {
   console
.log( 'mousedown' );
 
})

可是鼠标按下去之后应该要变成什么?这个时候应该要开始监听mousemove对吧,所以我们这样写,用mapTo把每一个mousedown的事件都转换成mousemove的Observable:

Rx.Observable.fromEvent(canvas, 'mousedown' )
 
.mapTo(
   
Rx.Observable.fromEvent(canvas, 'mousemove' )
 
)
 
.subscribe( value => {
   console
.log( 'value: ' , value);
 
})

接着你看一下console,你会发现每当我点击的时候,console 就会印出FromEventObservable {_isScalar: false, sourceObj: canvas#canvas, eventName: “mousemove”, selector: undefined, options: undefined}

仔细想一下你会发现也满合理的,因为我用mapTo把每一个鼠标按下去的事件转成一个mousemove的Observable,所以用subscribe订阅之后拿到的东西就会是这个Observable。如果画成图,大概长得像这样:

好了,那怎么办呢?我想要的其实不是Observable 本身,而是属于这个Observable 里面的那些东西啊!现在这个情形就是Observable 里面又有Observable,有两层,可是我想要让它变成一层就好,该怎么办呢?

在此提供一个让Observable 变简单的诀窍:

只要有问题,先想想Array 就对了!

我前面有提过,可以把Observable 看成是加上时间维度的进阶版数组,因此只要是数组有的方法,Observable 通常也都会有。

举例来说,一个数组可能长这样:[1, [2, 2.5], 3, [4, 5]]一共有两层,第二层也是一个数组。

如果想让它变一层的话怎么办呢?压平!

有用过lodash或是其他类似的library的话,你应该有听过_.flatten这个方法,可以把这种数组压平,变成:[1, 2, 2.5, 3, 4, 5]。

用flat这个关键字去搜寻Rx文件的话,你会找到一个方法叫做FlatMap,简单来说就是先map之后再自动帮你压平。

所以,我们可以把代码改成这样:

Rx.Observable.fromEvent(canvas, 'mousedown' )
 
.flatMap( e => Rx.Observable.fromEvent(canvas, 'mousemove' ))            
 
.subscribe( e => {
   console
.log(e);
 
})

当你点击之后,会发现随着鼠标移动,console 会印出一大堆log,就代表我们成功了。

画成示意图的话会变成这样(为了方便说明,我把flatMap在图片上变成map跟flatten两个步骤):

接下来呢?接下来我们要让它可以在鼠标松开的时候停止,该怎么做呢?RxJS有一个方法叫做takeUntil,意思就是拿到…发生为止,传进去的参数必须是一个Observable。

举例来说,如果写.takeUntil(window, ‘click’),就表示如果任何window发生的点击事件,这个Observable就会立刻终止,不会再送出任何信息。

应用在绘画的例子上,我们只要把takeUntil后面传的参数换成鼠标松开就好!顺便把subscribe跟画画的function也一起完成吧!

Rx.Observable.fromEvent(canvas, 'mousedown' )
 
.flatMap( e => Rx.Observable.fromEvent(canvas, 'mousemove' ))
 
.takeUntil(Rx.Observable.fromEvent(canvas, 'mouseup' ))        
 
.subscribe( e => {
   draw
(e);
 
})

改完之后马上来实验一下!鼠标按下去之后顺利开始画图,松开以后画图停止,完美!

咦,可是怎么按下第二次就没反应了?我们做出了一个「只能够成功画一次图」的Observable。

为什么呢?我们可以先来看一下takeUntil的示意图(取自:http://rxmarbles.com/#takeUntil)

以我们的情形来说,就是只要mouseup事件发生,「整个Observable」就会停止,所以只有第一次能够画图成功。但我们想要的其实不是这样,我们想要的是只有mousemove停止而已,而不是整个都停止。

所以,我们应该把takeUntil放在mousemove的后面,也就是:

Rx.Observable.fromEvent(canvas, 'mousedown' )
 
.flatMap( e => Rx.Observable.fromEvent(canvas, 'mousemove' )
     
.takeUntil(Rx.Observable.fromEvent(canvas, 'mouseup' ))  
 
)
 
.subscribe( e => {
   draw
(e);
 
})

这样子里面的那个mousemove的Observable就会在鼠标松开时停止发送事件,而我们最外层的这个Observable监听的是鼠标按下,会一直监听下去。

到这边其实就差不多了,但还有一个小bug要修,就是我们没有在mousedown的时候利用moveTo移动,造成我们一开始说的那个会把上次画的跟这次画的连在一起的问题。

那怎么办呢?我已经把mousedown事件转成其他资料流了,我要怎么在mousedown的时候做事?

有一个方法叫做do,就是为了这种情形而设立的,使用时机是:「你想做一点事,却又不想影响信息流」,有点像是能够针对不同阶段subscribe的感觉,mousedown的时候subscribe一次,最后要画图的时候又subscribe一次。

Rx.Observable.fromEvent(canvas, 'mousedown' )
 
.do( e => {
   ctx
.moveTo(e.clientX, e.clientY)
 
})
 
.flatMap( e => Rx.Observable.fromEvent(canvas, 'mousemove' )
     
.takeUntil(Rx.Observable.fromEvent(canvas, 'mouseup' ))  
 
)
 
.subscribe( e => {
   draw
(e);
 
})

到这边,我们就顺利完成了画图的功能。

如果你想试试看你有没有搞懂,可以实作看看拖拉移动物体的功能,原理跟这个很类似,都是侦测滑鼠的事件并且做出反应。

喝口水休息一下,下半场要开始了

上半场的目标在于让你理解什么是Rx,并且掌握几个基本概念:

  • 一个信息流可以经过一系列转换,变成另一个信息流

  • 这些转换基本上都跟阵列有的差不多,像是map、filter、flatten等等

  • 你可以合并多个Observable,也可以把二维的Observable 压平

下半场专注的点则是在于实战应用,并且围绕着RxJS 最适合的场景之一:API。

前面我们有提到说可以把DOM节点的event 变成信息流,但除了这个以外,Promise 其实也可以变成信息流。概念其实也很简单啦,就是Promise 被resovle 的时候就发送一个信息,被reject 的时候就终止。

让我们来看一个简单的小范例,每按一次按钮就会发送一个request

function  sendRequest () {
 
return fetch( 'https://jsonplaceholder.typicode.com/posts/1' ).then( res => res.json())
}
Rx.Observable.fromEvent( document .querySelector( 'input[name=send]' ), 'click' )
 
.flatMap( e => Rx.Observable.fromPromise(sendRequest()))
 
.subscribe( value => {
   console
.log(value)
 
})

这边用flatMap的原因跟刚才的画图范例一样,我们要在按下按钮时,把原本的信息流转换成新的信息流,如果只用map的话,会变成一个二维的Observable,所以必须要用flatten把它压平。

你可以试试看把flatMap改成map,你最后subscribe得到的值就会是一堆Observable而不是你想要的信息。

知道怎么用Rx 来处理API 之后,就可以来做一个经典范例了:AutoComplete。

我在做这个范例的时候有极大部分参考30天精通RxJS(19):实务范例-简易Auto Complete实作、Reactive Programming简介与教学(以RxJS为例)以及构建流式应用—RxJS详解,再次感谢这三篇文章。

为了要让大家能够体会Reactive Programming 跟一般的有什么不一样,我们先用老方法做出这个Auto Complete 的功能吧!

先来写一下最底层的两个函数,负责抓资料的以及render 建议清单的,我们使用维基百科的API 来当作范例:

function  searchWikipedia ( term ) {
   
return $.ajax({
       url
: 'http://en.wikipedia.org/w/api.php' ,
       dataType
: 'jsonp' ,
       data
: {
           action
: 'opensearch' ,
           format
: 'json' ,
           search
: term
       
}
   
}).promise();
}
function  renderList ( list ) {
 $
( '.auto-complete__list' ).empty();
 $
( '.auto-complete__list' ).append(list.map( item =>  '<li>' + item + '</li>' ))
}

这边要注意的一个点是维基百科回传的信息会是一个数组,格式如下:

[你输入的关键字, 关键字清单, 每个关键字的介绍, 每个关键字的连结]
// 范例:
[
 
"dd",
 
["Dd", "DDR3 SDRAM", "DD tank"],
 
["", "Double data rate type three SDRAM (DDR3 SDRAM)", "DD or Duplex Drive tanks"],
 
[https://en.wikipedia.org/wiki/Dd", "https://en.wikipedia.org/wiki/DDR3_SDRAM", "...略"]
]

在我们的简单示范中,只需要取index为1的那个关键字清单就好了。而renderList这个function则是传进一个数组,就会把数组内容转成li显示出来。

有了这两个最基础的function 之后,就可以很轻易地完成Auto Complete 的功能:

document .querySelector( '.auto-complete input' ).addEventListener( 'input' , (e) => {
 searchWikipedia
(e.target.value).then( ( data ) => {
   renderList
(data[ 1 ])
 
})
})

代码应该很好懂,就是每次按下输入东西的时候去call api,把回传的信息拿给renderList去渲染。

最基本的功能完成了,我们要来做一点优化,因为这样子的实作其实是有一些问题的。

第一个问题,现在只要每打一个字就会送出一个request,可是这样做其实有点浪费,因为使用者可能快速的输入了:java想要找相关的资料,他根本不在乎j、ja、jav这三个request。

要怎么做呢?我们就改写成如果250ms 里面没有再输入新的东西才发送request 就好,就可以避免这种多余的浪费。

这种技巧称作debounce,案例上也很简单,就是利用setTimeout跟clearTimeout。

var timer = null ;
document
.querySelector( '.auto-complete input' ).addEventListener( 'input' , (e) => {
 
if (timer) {
   clearTimeout
(timer);
 
}
 timer
= setTimeout( () => {
   searchWikipedia
(e.target.value).then( ( data ) => {
     renderList
(data[ 1 ])
   
})
 
}, 250 )
})

在input 事件被触发之后,我们不直接做事情,而是设置了一个250ms 过后会触发的timer,如果250ms 内input 再次被触发的话,我们就把上次的timer 清掉,再重新设置一个。

如此一来,就可以保证使用者如果在短时间内不断输入文字的话,不会送出相对应的request,而是会等到最后一个字打完之后的250 ms 才发出request。

解决了第一个问题之后,还有一个潜在的问题需要解决。

假设我现在输入a,接着删除然后再输入b,所以第一个request会是a的结果,第二个request会是b的结果。我们假设server出了一点问题,所以第二个的response反而比第一个还先到达(可能b的搜寻结果有cache但是a没有),这时候就会先显示b的内容,等到第一个response回来时,再显示a的内容。

可是这样UI就有问题了,我明明输入的是b,怎么auto complete的推荐关键字是a开头?

所以我们必须要做个检查,检查返回的信息跟我现在输入的信息是不是一致,如果一致的话才render:

var timer = null ;
document
.querySelector( '.auto-complete input' ).addEventListener( 'input' , (e) => {
 
if (timer) {
   clearTimeout
(timer);
 
}
 timer
= setTimeout( () => {
   searchWikipedia
(e.target.value).then( ( data ) => {
     
if (data[ 0 ] === document .querySelector( '.auto-complete input' ).value) {
       renderList
(data[ 1 ])
     
}
   
})
 
}, 250 )
})

到这里应该就差不多了,该有的功能都有了。

接着,让我们来挑战用RxJS 实作吧!

首先,先从简单版的开始做,就是不包含debounce跟上面API顺序问题的例子,监听input事件转换成request,然后用flatMap压平,其实就跟上面的流程差不多:

Rx.Observable
 
.fromEvent( document .querySelector( '.auto-complete input' ), 'input' )
 
.map( e => e.target.value)
 
.flatMap( value => {
   
return Rx.Observable.from(searchWikipedia(value)).map( res => res[ 1 ])
 
})
 
.subscribe( value => {
   renderList
(value);
 
})

这边用了两个map,一个是把e转成e.target.value,一个是把传回来的结果转成res[1],因为我们只需要关键字列表,其他的东西其实都不用。

那要如何实作debounce的功能呢?

RxJS已经帮你实作好了,所以你只要加上.debounceTime(250)就好了,就是这么简单。

Rx.Observable
 
.fromEvent( document .querySelector( '.auto-complete input' ), 'input' )
 
.debounceTime( 250 )
 
.map( e => e.target.value)
 
.flatMap( value => {
   
return Rx.Observable.from(searchWikipedia(value)).map( res => res[ 1 ])
 
})
 
.subscribe( value => {
   renderList
(value);
 
})

还有最后一个问题要解决,那就是刚才提到的request 的顺序问题。

Observable 有一个不同的解法,我来解释给大家听听。

其实除了flatMap以外,还有另外一种方式叫做switchMap,他们的差别在于要怎么把Observable给压平。前者我们之前介绍过了,就是会把每一个二维的Observable都压平,并且「每一个都执行」。

而switchMap的差别在于,他永远只会处理最后一个Observable。拿我们的例子来说,假设第一个request还没回来的时候,第二个request就发出去了,那我们的Observable就只会处理第二个request,而不管第一个。

第一个还是会发送,还是会接收到信息,只是接收到信息以后不会再把这个信息emit 到Observable 上面,意思就是根本没人理这个信息了。

可以看一下简陋的图解,flatMap每一个promise resolve之后的资料都会被发送到我们的Observable上面:

而switchMap只会处理最后一个:

所以我们只要把flatMap改成switchMap,就可以永远只关注最后一个发送的request,不用去管request传回来的顺序,因为前面的request都跟这个Observable无关了。

Rx.Observable
 
.fromEvent( document .querySelector( '.auto-complete input' ), 'input' )
 
.debounceTime( 250 )
 
.map( e => e.target.value)
 
.switchMap( value => {
   
return Rx.Observable.from(searchWikipedia(value)).map( res => res[ 1 ])
 
})
 
.subscribe( value => {
   renderList
(value);
 
})

做到这边,就跟刚刚实作的功能一模一样了。

但其实还有地方可以改进,我们来做个小小的加强好了。现在的话当我输入abc,会出现abc的相关关键字,接着我把abc全部删掉,让input变成空白,会发现API这时候回传一个错误:The “search” parameter must be set.。

因此,我们可以在input是空的时候,不发送request,只回传一个空数组,而回传空数组这件事情可以用Rx.Observable.of([])来完成,这样会创造一个会发送空数组的Observable:

Rx.Observable
 
.fromEvent( document .querySelector( '.auto-complete input' ), 'input' )
 
.debounceTime( 250 )
 
.map( e => e.target.value)
 
.switchMap( value => {
   
return value.length < 1 ? Rx.Observable.of([]) : Rx.Observable.from(searchWikipedia(value)).map( res => res[ 1 ])
 
})
 
.subscribe( value => {
   renderList
(value);
 
})

还有一个点击关键字清单之后把文字设定成关键字的功能,在这边就不示范给大家看了,但其实就是再创造一个Observable 去监听点击事件,点到的时候就设定文字并且把关键字清单给清掉。

我直接附上参考代码:

Rx.Observable
 
.fromEvent( document .querySelector( '.auto-complete__list' ), 'click' )
 
.filter( e => e.target.matches( 'li' ))
 
.map( e => e.target.innerHTML)
 
.subscribe( value => {
   document
.querySelector( '.auto-complete input' ).value = value;
   renderList
([])
 
})

虽然我只介绍了最基本的操作,但RxJS的强大之处就在于除了这些,你甚至还有retry可以用,只要轻松加上这个,就能够有自动重试的功能。

相关的应用场景还有很多,只要是跟API 有关连的几乎都可以用RxJS 很优雅的解决。

React + Redux 的非同步解决方案:redux-observable

这是我们今天的最后一个主题了,也是我开场所提到的。

React + Redux 这一套非常常见的组合,一直都有一个问题存在,那就是没有规范非同步行为(例如说API)到底应该怎么处理。而开源社区也有许多不同的解决方案,例如说redux-thunk、redux-promise、redux-saga 等等。

我们前面讲了这么多东西,举了这么多范例,就是要证明给大家看Reactive programming很适合拿来解决复杂的非同步问题。因此,Netflix就开源了这套redux-observable,用RxJS来处理非同步行为。

在了解RxJS之后,可以很轻松的理解redux-observable的原理。

在redux 的应用里面,所有的action 都会通过middleware,你可以在这边对action 做任何处理。或者我们也可以把action 看做是一个Observable,例如说:

// 范例而已
Rx.Observable.from(actionStreams)
 
.subscribe( action => {
   console
.log(action.type, action.payload)
 
})

有了这个以后,我们就可以做一些很有趣的事情,例如说侦测到某个action 的时候,我们就发送request,并且把response 放进另外一个action 里面送出去。

Rx.Observable.from(actionStreams)
 
.filter( action => action.type === 'GET_USER_INFO' )
 
.switchMap(
   action
=> Rx.Observable.from(API.getUserInfo(action.payload.userId))
 
)
 
.subscribe( userInfo => {
   dispatch
({
     type
: 'SET_USER_INFO' ,
     payload
: userInfo
   
})
 
})

上面就是一个简单的例子,但其实redux-observable已经帮我们处理掉很多东西了,所以我们只要记得一个概念:

action in, action out

redux-observable是一个middleware,你可以在里面加上很多epic,每一个epic就是一个Observable,你可以监听某一个指定的action,做一些处理,再转成另外一个action。

直接看程代码会比较好懂:

import Actions from  './actions/user' ;
import ActionTypes from  './actionTypes/user'
const getUserEpic = action$ =>
 action$
.ofType(actionTypes.GET_USER)
   
.switchMap(
     action
=> Rx.Observable.from(API.getUserInfo(action.payload.userId))
   
).map( userInfo => Actions.setUsers(userInfo))

大概就是像这样,我们监听一个action type(GET_USER),一接收到的时候就发送request,并且把结果转为setUsers这个action,这就是所谓的action in, action out。

这样的好处是什么?好处是明确制定了一个规范,当你的component 需要资料的时候,就送出一个get 的action,这个action 经过middleware 的时候会触发epic,epic 发request 给server 拿资料,转成另外一个set 的action,经过reducer 设定资料以后更新到component 的props。

可以看这张流程图:

总之呢,epic就是一个Observable,你只要确保你最后回传的东西是一个action就好,那个action就会被送到reducer去。

碍于篇幅的关系,今天对于redux-observable只是概念性的带过去而已,没有时间好好示范,之后再来找个时间好好写一下redux-observable的实战应用。

结论

从一开始的阵列讲到Observable,讲到画图的范例再讲到经典的Auto Complete,最后还讲了redux-observable,这一路的过程中,希望大家有体会到Observable在处理非同步行为的强大之处以及简洁。

这篇的目的是希望能让大家理解Observable 大概在做什么,以及介绍一些简单的应用场景,希望能提供一篇简单易懂的中文入门文章,让更多人能体会到Observable 的威力。

参考资料:

  • 30天精通RxJS (01):认识RxJS

  • Reactive Programming简介与教学(以RxJS为例)

  • The introduction to Reactive Programming you’ve been missing

  • 构建流式应用—RxJS详解

  • Epic Middleware in Redux

  • Combining multiple Http streams with RxJS Observables in Angular2

视频:

  • Netflix JavaScript Talks - RxJS + Redux + React = Amazing!

  • RxJS Quick Start with Practical Examples

  • RxJS Observables Crash Course

  • Netflix JavaScript Talks - RxJS Version 5

  • RxJS 5 Thinking Reactively | Ben Lesh

关于本文
作者:@huli
原文:https://blog.techbridge.cc/2017/12/08/rxjs/

最后,为你推荐


【第1338期】利用StoryBook开发UI组件管理


【第1335期】这个控件叫:Skeleton Screen/加载占位图

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存