[译]理解响应式编程和RxJS

RxJS能够让我们很轻松地创建和操控事件和streams,虽然会让开发变得复杂,但是会让异步代码变得易读。

创建大型的异步的应用程序并非易事,其回调函数引发的问题让诸多开发者头疼,我们称其为回调地狱。之前有promises, generators以及async/await来处理回调函数引发的问题。但现在我们有了另外一个解决方案,那就是RxJS

RxJS在其github项目上的定义为“a set of libraries for composing asynchronous and event-based programs using observable sequences and fluent query operators”。说得通俗易懂点就是我们可以从事件以及其他数据源中创建streams,并且我们可以对streams进行合并,销毁,分离等操作,以获得我们想要的数据。

Observable或者stream(数据流)刚开始可能比较难理解。我会把它看成是一段时间内的事件或数据集合,而不是某个时间点上的单一事件或数据。

为了演示它是如何工作的,我们将要创建一个简单的天气应用。这个应用会根据你提交的邮编会返回邮编所在地区的气温。获得返回的气温后,我们会将气温和邮编一同显示在页面上。我们能够在页面上显示多组气温和邮编。最后,我们还会有个定时器能够定时刷新气温。

你可以在Github上查看最终源代码。

更新:这篇文章已经更新到RxJS version 5
对原先的代码只有少量改动,有必要的情况下我会高亮这些改动。

起步

首页我们需要一个HTML页面来加载RxJS,还需要包含一些CSS,代码如下:

<!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Weather Monitoring in RxJS</title>
    <style>
      #form {
        margin-bottom: 20px;
      }
      .location {
        float: left;
        padding: 10px;
        margin-right: 20px;
        margin-bottom: 20px;
        border: 1px solid #ddd;
        border-radius: 5px;
      }
      .location p {
        margin-top: 10px;
        margin-bottom: 10px;
        text-align: center;
      }
      .zip { font-size: 2em; }
      .temp { font-size: 4em; }
    </style>
  </head>
  <body>
    <div id="app-container">
      <div id="form">
        <label>Zip Code:</label>
        <input type="text" id="zipcode-input">
        <button id="add-location">Add Location</button>
      </div>
    </div>
    <!-- <script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/4.1.0/rx.all.min.js"></script> -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/5.0.0-beta.12/Rx.min.js"></script>
    <script>
      // 我们将在这里编码
      console.log('RxJS included?', !!Rx);
  </script>
  </body>
  </html>

在浏览器中打开这个页面,并打开console面板,如果你能看到RxJS included? true,说明你已经可以开始响应式编程啦。在这个页面上我们有一个表单,表单下面包含邮编的输入框和一个按钮。首先我们的JavaScript将会获取这两个表单元素,然后为它们创建stream。我们还会获取id为app-container的DIV,我们将会在后面用到。

更新:我已经在页面上引用了RxJS 5.0,而不是之前的4.1

// 获取HTML元素
const appContainer = document.getElementById('app-container');
const zipcodeInput = document.getElementById('zipcode-input');
const addLocationBtn = document.getElementById('add-location');

上面是很基本的JavaScript获到DOM节点,没什么特别的

// 成生点击按钮的数据流
const btnClickStream =
  Rx.Observable
    .fromEvent(addLocationBtn, 'click')
    // .map(() => true)
    .mapTo(true)
    .forEach(val => console.log('btnClickStream val', val));

在这里我们用到了RxJS!我们使用了Rx.Observable上的fromEvent方法,它为addLocationBtn的点击事件创建stream。意味着任何时候点击按钮,btnClickStream都会发送事件对象。因为我只需要知道点击事件的发生,所以我将点击事件产生的值用mapTo转换成布尔值true,我认为这样能够简化逻辑。当然这只是我喜欢的方式,如果你不喜欢,可以将它从代码中移除,没问题。最后,为了确保它正常工作,我们使用了forEach,它为数据流增加一个订阅者(subscriber),只是简单的输出值。

更新:RxJS 5.0新增了mapTo,它比map更可读。后者需要用一个函数来返回true。当然两者在这里的作用是一样的。

image

重新加载页面,点击几次按钮,你会发现console面板输出btnClickStream val true,说明代码正常工作了。现在我们去掉forEach,因为不再需要它了。接来下处理邮编,我们需要监听邮编输入框的变化,在这里要做一下过滤,只有输入的长度为5时才去处理,看下面代码:

// 成生邮编输入框的数据流
const zipInputStream =
  Rx.Observable
    .fromEvent(zipcodeInput, 'input')
    .map(e => e.target.value)
    .filter(zip => zip.length === 5)
    .forEach(val => console.log('zipInputStream val', val));

这里我们为邮编输入框创建了stream,使用map从事件中获取输入的值,然后使用filter过滤掉所有长度不为5的输入值。最后我们通过forEach历遍所有的值,将它们输出在console中。

image
image

刷新页面,在输入框中输入一些值,在console中看结果。仍然,在后面的代码中我们并不需要forEach,所以去掉它。现在我们要做的就是当用户点击按钮时,才将输入的值传过来。为此我们需要创建一个新的stream。

// 当点击按钮时才获取zipcode
const zipcodeStream =
  btnClickStream
    .withLatestFrom(zipInputStream, (click, zip) => zip)
    .distinct()
    .forEach(val => console.log('zipcodeStream val', val));

这里,我们在btnClickStream上调用withLatestFrom操作符,然后传入zipInputStream。意思就是只要接收到btnClickStream发射过来的值,它就会从zipInputStream中获取最新的值,然后把这两个值传到处理函数上(withLatestFrom第二个参数),这个函数作用类似map。无论这个函数返回什么,都会被传到withLatestFrom返回的stream中。接下来我们使用distinct来拦截重复值,比如你之前传了12345,这一次再输12345,这个值就会被拦截。最后我们还是使用forEach来输出结果。

image

同样,我们刷新页面输入值,点击按钮,在console面板查看结果,然后去掉forEach。当调用weather API的时候我们需要让代码可以重用。

// 创建可重用的获取气温的stream
const getTemperature = zip => fetch(`http://api.openweathermap.org/data/2.5/weather?q=${zip},us&units=imperial&APPID=<APPID>`).then(res => res.json());

const zipTemperatureStreamFactory = zip => Rx.Observable.fromPromise(getTemperature(zip)).map(({ main: { temp } }) => { temp, zip });

我们创建了两个函数。第一个getTemperature通过传入zip向weather API请求。因为fetch返回一个promise,所以我们使用了then。这个promise接收到回复,我们再将这个回复发json的形式返回,方便我们更好地处理数据。你需要更换上面代码中的<APPID>,使用你自己的ID,你可以在这里申请APPID。

第二个函数同样使用zip code做参数。我们使用了fromPromise来创建了一个stream。这个操作符是将getTemperature函数返回的promise转换成stream。因为返回的是stream,所以我们可以使用Rx.Observable上的操作符对其进行操作。我们使用map将数据以object的形式返回。注意,在map参数上,我们使用了ES2015新的语法:解构(destructuring)。使用解构能够轻松地将气温数据提取出来。关于解构,你可以访问MDN文档了解更多。

现在我们已经可以从weather API中获取数据了,现在我们将在页面上增加些元素。

// 点击按钮获得邮编,然后请求气温打印在页面上
zipcodeStream
  .flatMap(zipTemperatureStreamFactory)
  .forEach(({ zip, temp }) => {
    const locationEle = document.createElement('div');
    locationEle.id = `zip-${zip}`;
    locationEle.classList.add('location');

    const zipEle = document.createElement('p');
    zipEle.classList.add('zip');
    zipEle.innerText = zip;

    const tempEle = document.createElement('p');
    tempEle.classList.add('temp');
    tempEle.innerHTML = `${temp}°F`;

    locationEle.appendChild(zipEle);
    locationEle.appendChild(tempEle);
    appContainer.appendChild(locationEle);

    zipcodeInput.value = '';
  });

我们在zip code的数据流上使用flatMap操作符。flatMap类似于map,不同的是它返回的是所有Stream中的每个Stream,然后取出每个stream中的值。意味着它会“打平”我们从weather API获得的stream,返回我们需要处理的数据,也就是包含邮编和气温的对象。

接下来就是我们使用forEach来处理我们获得的数据,将它们添加到页面中。最后我们清空输入框的值。

image

重新加载页面,然后在输入框中输入几个邮编,你会看到页面上有新增元素,包含了你输入的邮编以及对应的气温。

现在我们已经能够让气温显示在页面上,但是我们要让它保持更新。所以我们要创建了个stream,让它每个一段时间发射最新的数据。但是在此之前,我们要先拿到所以已经添加到页面上的邮编。怎样拿呢?可以使用ReplaySubject。ReplaySubjuct能够订阅一个stream,并且记住这个stream所有的值。我们就可以在任何时候重新拿到那些值。

// 创建一个stream,以便我们在想要的时候获得邮编
const replayZipsStream = new Rx.ReplaySubject();
zipcodeStream.subscribe(replayZipsStream);

这里我们创建了一个新的ReplaySubject对象,然后在zipcodeStream中订阅。意味着ReplaySubject会记住我们输入的所有的邮编。

// 创建个定时器,更新页面
Rx.Observable
  .interval(20000)
  // .flatMapLatest(() => replayZipsStream)
  .switchMap(() => replayZipsStream)
  .flatMap(zipTemperatureStreamFactory)
  .forEach(({ zip, temp }) => {
    console.log('Updating!', zip, temp);

    const locationEle = document.getElementById(`zip-${zip}`);
    const tempEle = locationEle.querySelector('.temp');

    tempEle.innerHTML = `${temp}°F`;
  });

首先我们要创建个stream,作用是在指定的间隔上发射值。发射什么值并不是我们要关心的,我们只是要在这个stream发射值的时候执行其他操作。然后我们使用了一个新的操作符switchMap。这里使用switchMap而不是flatMap的原因是我们只需要在replayZipsStream上有一个订阅者(subscriber)。如果我们使用flatMap,我们就会在相同的ReplaySubject上有多个订阅者,这会导致我们向weather API发送多个额外的请求。这时候我们就得到了一个包含邮编的stream,就像之前我们在页面中添加邮编时一样。所以我们可以用同样的方式处理stream返回的值。我们使用flatMap,把zipTempateratureStreamFactory传进去,后者会向weather API发送请求。最后,遍历所有返回回来的数据,将它们更新到页面上。

更新:在RxJS 5.0版本中,flatMapLatest已更改成switchMap

image

最后一次载新页面,添加几个邮编,你会看到它们会被添加到页面中。等待20秒,你会在console面板上看到消息,告诉我们所有东西都已经被更新了。你可能不会在页面上看到变化,因为在这20秒内,气温可能并不会发生变化。当然你可以在Rx.Observable.interval上自由改动间隔时间。

使用Auth0 Lock

假设你现在决定使用Auth0 Lock为你的天气应用添加身份验证,那么应该怎么做呢?其实挺简单的,因为Auth0 Lock的库把大部分的工作都做好了,我们所要做的就是一个按钮,点击后会显示一个modal。

首先,我们需要引入Auth0 Lock的库,初始化Lock,增加一个登陆按钮,点击后会弹出一个modal。

<button id="login">Login</button>
  <script src="http://cdn.auth0.com/js/lock/10.x.y/lock.min.js"></script>
  <script>
    // Initiating our Auth0Lock
    var lock = new Auth0Lock(
      'YOUR_CLIENT_ID',
      'YOUR_NAMESPACE'
    );

    // Listening for the authenticated event
    lock.on("authenticated", function(authResult) {
      // Use the token in authResult to getProfile() and save it to localStorage
      lock.getProfile(authResult.idToken, function(error, profile) {
        if (error) {
          // Handle error
          return;
        }

        localStorage.setItem('idToken', authResult.idToken);
        localStorage.setItem('profile', JSON.stringify(profile));
      });
    });
  </script>

剩下唯一要做的事是将点击登陆按钮转换成stream,只要这个stream发射数据,我们就打开modal。

Rx.Observable
 .fromEvent(document.getElementById('login'), 'click')
 .forEach(() => lock.open());

至此,所以工作已经完了。

总结

Observables或者streams刚开始可能并不容易理解。我会把它想象成一段时间内事件的集合而不是单一事件。一旦把这个搞清楚,那么把DOM上面所有事件想象成streams都不是难事。使用RxJS可以很容易地创建streams,并且很容易操作。相比于其实框架或库,它能让你代码逻辑更清晰。

本文翻译自 https://auth0.com/blog/understanding-reactive-programming-and-rxjs/

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,108评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,699评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,812评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,236评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,583评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,739评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,957评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,704评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,447评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,643评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,133评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,486评论 3 256
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,151评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,889评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,782评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,681评论 2 272

推荐阅读更多精彩内容