使用Rxjs写前端异步流信息

准备工作

首先在 GitHub - teambition/learning-rxjs: Learning RxJS step by step clone 项目所需的 seed,本文中所有涉及到 RxJS 的代码将全部使用 TypeScript 编写。

使用 npm start 启动 seed 项目,在浏览器中通过 http://localhost:3000 进入 demo 页面,这篇文章中我们将实现以下几点功能:

  1. 在输入框中输入字符,在回车的时候将输入框中的文字变成一个 todo item,同时清空输入框中的内容。
  2. 在输入框中输入字符,点击 add 按钮,将输入框中的文字变成一个 todo item,同时清空输入框中的文字。
  3. 点击一个 todo item,让它变成已完成的状态
  4. 点击 todo item 右边的 remove button,将这个 todo item 从 todo list 中移除。

第一个 Observable

如果要响应用户按下回车这个行为,我们首先要获取用户输入的事件并把它转变成 Observable,在 RxJS 中,可以直接使用 fromEvent 操作符直接将一个 eventListener 转变成一个 Observable:

// src/app.ts
import { Observable } from 'rxjs'

const $input = <HTMLInputElement>document.querySelector('.todo-val')

const input$ = Observable.fromEvent<KeyboardEvent>($input, 'keydown')
// do 操作符一般用来处理 Observable 的副作用,例如操作 DOM,修改外部变量,打 log
    .do(e => console.log(e))

const app$ = input$

app$.subscribe()

这样在控制台就能看到每次用户输入时对应的 event 在 input$ Observable 中流动了。

image

使用 filter 进行数据过滤

但我们并不关心用户的输入的其它值,只需要获取按下回车事件这个值,并作出响应。此时我们只需要对这个 Observable 进行 filter :

import { Observable } from 'rxjs'

const $input = <HTMLInputElement>document.querySelector('.todo-val')

const input$ = Observable.fromEvent<KeyboardEvent>($input, 'keydown')
  .filter(r => r.keyCode === 13)
  .do(r => console.log(r))

const app$ = input$

app$.subscribe()

image

使用 map 进行数据的变换

为了完成在回车的时候将输入框中的文字变成一个 todo item,我们需要获取 input 中的值,并将它变成一个 todo-item 节点。这个过程是一个很典型的 map 的过程:
可以类比于 Array 的 Map : [ … KeyboardEvent ] => [… HTMLElement ]
首先在输入回车的时候把 KeyboardEvent map 到 string, filter 掉空值

import { Observable } from 'rxjs'

const $input = <HTMLInputElement>document.querySelector('.todo-val')

const input$ = Observable.fromEvent<KeyboardEvent>($input, 'keydown')
  .filter(r => r.keyCode === 13)

const app$ = input$
  .map(() => $input.value)
  .filter(r => r !== '')
  .do(r => console.log(r))

app$.subscribe()

image

再来一个 createTodoItem 的 helper:

// lib.ts
export const createTodoItem = (val: string) => {
  const result = <HTMLLIElement>document.createElement('LI')
  result.classList.add('list-group-item')
  const innerHTML = `
    ${val}
    <button type="button" class="btn btn-default button-remove" aria-label="right Align">
      <span class="glyphicon glyphicon-remove" aria-hidden="true"></span>
    </button>
  `
  result.innerHTML = innerHTML
  return result
}

// app.ts
import { Observable } from 'rxjs'
import { createTodoItem } from './lib'

const $input = <HTMLInputElement>document.querySelector('.todo-val')

const input$ = Observable.fromEvent<KeyboardEvent>($input, 'keydown')
  .filter(r => r.keyCode === 13)

const app$ = input$
  .map(() => $input.value)
  .filter(r => r !== '')
  .map(createTodoItem)
  .do(r => console.log(r))

app$.subscribe()

image

将 map 出来的节点插入 DOM,顺便一提的是,在 RxJS 的范式中,数据流动中的 副作用 都应该写在 do 操作符中。

import { Observable } from 'rxjs'
import { createTodoItem } from './lib'

const $input = <HTMLInputElement>document.querySelector('.todo-val')
const $list = <HTMLUListElement>document.querySelector('.list-group')

const input$ = Observable.fromEvent<KeyboardEvent>($input, 'keydown')
  .filter(r => r.keyCode === 13)
  .map(() => $input.value)

const app$ = input$
  .filter(r => r !== '')
  .map(createTodoItem)
  .do((ele: HTMLLIElement) => {
    $list.appendChild(ele)
  })
  .do(r => console.log(r))

app$.subscribe()

实现到这一步,我们已经可以把输入的字符串变成一个个 item 了:

下一步我们来实现点击 add 按钮增加一个 todo item 功能。可以看到,在程序上这个操作和按下回车后需要的后续操作是一样的。所以我们只需要将点击 add 按钮事件也变成一个 Observable 然后与 按下回车 的 Observable merge 到一起就好了:

import { Observable } from 'rxjs'
import { createTodoItem } from './lib'

const $input = <HTMLInputElement>document.querySelector('.todo-val')
const $list = <HTMLUListElement>document.querySelector('.list-group')
const $add = document.querySelector('.button-add')

const enter$ = Observable.fromEvent<KeyboardEvent>($input, 'keydown')
  .filter(r => r.keyCode === 13)

const clickAdd$ = Observable.fromEvent<MouseEvent>($add, 'click')

const input$ = enter$.merge(clickAdd$)

const app$ = input$
  .map(() => $input.value)
  .filter(r => r !== '')
  .map(createTodoItem)
  .do((ele: HTMLLIElement) => {
    $list.appendChild(ele)
  })
  .do(r => console.log(r))

app$.subscribe()

接下来在 do 操作符中把 input 中的值清除掉:

...
  .do((ele: HTMLLIElement) => {
    $list.appendChild(ele)
    $input.value = ''
  })
...

从 Observable mergeMap 到 新的 Observable

在创建出这些 item 后,我们再给它们加上各自的 event listener 来完成 点击一个 todo item,让它变成已完成的状态 功能,而新的 eventListener 只能在这些 item 创建出来以后加上。所以这个过程是 Observable<HTMLElement> => map => Observable<MouseEvent> => merge 的过程,在 RxJS 中有一个操作符可以一步完成这个 map and merge 的过程:

import { Observable } from 'rxjs'
import { createTodoItem } from './lib'

const $input = <HTMLInputElement>document.querySelector('.todo-val')
const $list = <HTMLUListElement>document.querySelector('.list-group')
const $add = document.querySelector('.button-add')

const enter$ = Observable.fromEvent<KeyboardEvent>($input, 'keydown')
  .filter(r => r.keyCode === 13)

const clickAdd$ = Observable.fromEvent<MouseEvent>($add, 'click')

const input$ = enter$.merge(clickAdd$)

const app$ = input$
  .map(() => $input.value)
  .filter(r => r !== '')
  .map(createTodoItem)
  .do((ele: HTMLLIElement) => {
    $list.appendChild(ele)
    $input.value = ''
  })
  // map and merge
  .mergeMap($todoItem => {
    return Observable.fromEvent<MouseEvent>($todoItem, 'click')
      .filter(e => e.target === $todoItem)
      .mapTo($todoItem)
  })
  .do(($todoItem: HTMLElement) => {
    if ($todoItem.classList.contains('done')) {
      $todoItem.classList.remove('done')
    } else {
      $todoItem.classList.add('done')
    }
  })
  .do(r => console.log(r))

app$.subscribe()

因为 todoItem 上还有其它功能性的按钮,比如移除 todoItem ,所以在 mergeMap 中我们用 filter 过滤掉了非 li 标签的点击事件。同时下一个 do 操作符中需要 consume 这个 $todoItem 对象,所以我们在 filter 后将它 mapTo 下一个操作符。

从一个 Observable map 到不同的 Observable,share/publish 它再操作它

为了实现点击 remove 按钮,把当前的 todoItem 移除,我们需要从 item的 Observable 中重新 mergeMap 出新的 remove 的 Observable:

import { Observable } from 'rxjs'
import { createTodoItem } from './lib'

const $input = <HTMLInputElement>document.querySelector('.todo-val')
const $list = <HTMLUListElement>document.querySelector('.list-group')
const $add = document.querySelector('.button-add')

const enter$ = Observable.fromEvent<KeyboardEvent>($input, 'keydown')
  .filter(r => r.keyCode === 13)

const clickAdd$ = Observable.fromEvent<MouseEvent>($add, 'click')

const input$ = enter$.merge(clickAdd$)

const item$ = input$
  .map(() => $input.value)
  .filter(r => r !== '')
  .map(createTodoItem)
  .do((ele: HTMLLIElement) => {
    $list.appendChild(ele)
    $input.value = ''
  })

const toggle$ = item$.mergeMap($todoItem => {
  return Observable.fromEvent<MouseEvent>($todoItem, 'click')
    .filter(e => e.target === $todoItem)
    .mapTo($todoItem)
})
  .do(($todoItem: HTMLElement) => {
    if ($todoItem.classList.contains('done')) {
      $todoItem.classList.remove('done')
    } else {
      $todoItem.classList.add('done')
    }
  })

const remove$ = item$.mergeMap($todoItem => {
const $removeButton = $todoItem.querySelector('.button-remove')
  return Observable.fromEvent($removeButton, 'click')
    .mapTo($todoItem)
})
  .do(($todoItem: HTMLElement) => {
    // 从 DOM 上移掉 todo item
    const $parent = $todoItem.parentNode
    $parent.removeChild($todoItem)
  })

const app$ = toggle$.merge(remove$)
  .do(r => console.log(r))

app$.subscribe()

然而,这段代码并没有按我们预期的工作,remove button 点击之后是没有反应的。
这是因为:
Observable 默认是 lazy 且 unioncast的,这意味着:

  1. 它只有在订阅的时候才会被执行
  2. 它被多个订阅者订阅会执行多次,并且执行时上下文是独立的

也就是说,我们的 removeObservable 会重新让 item Observable 中的逻辑重新执行一遍:

image

在上图中,toggleObservable 先订阅并执行了黄色箭头部分的过程,remove Observable 订阅的时候重新执行绿色部分的过程,然而这个时候 input$ Observable 中已经不会流数据出来了。

想象一下,首先 toggleObservable 被订阅,随后 remove Observable 被订阅。此时由于这两个 Observable 被订阅导致 item Observable 被订阅了**两次**,所以对 input 与 add button 的 addEventlistener 逻辑执行了**两次**。在按下回车或者点击 add button 的时候,第一个 item Observable 的订阅逻辑先执行,向 DOM 中加入了一个 todoItem 并将 input 清空,此时再执行第二个 itemObservable 的订阅逻辑,此时 input 里面已经为空,所以这个 item Observable 里面没有数据流过,这也是我们的代码没有按照预期执行的原因。为了验证这个猜想,我们只需要把 item Observable 中的 do 操作符中的***input.value = '' ***注释掉就可以更直观的观察到程序现在的运行状态了:

image

图中红色的箭头是 toggleObservable 的 subscribe 逻辑执行的结果,这个 todoItem 节点只会处理 toggle 逻辑。黄色箭头的部分是 remove Observable 的 subscribe 逻辑执行的结果,这个 todoItem 节点只会处理 remove 逻辑。(这也很好的证明了 Observable 是 unioncast 的特性)

解决的方法其实很简单,我们不想要在每次订阅的时候都重复执行 item$ Observable 的逻辑,所以只需要:

const item$ = input$
  .map(() => $input.value)
  .filter(r => r !== '')
  .map(createTodoItem)
  .do((ele: HTMLLIElement) => {
    $list.appendChild(ele)
    $input.value = ''
  })
  .publishReplay(1)
  .refCount()

此时的 item$ 是这样的

image

关于 Observable 的 hot vs cold, Observable vs Subject 等概念,以及这里为什么用 publishReplay, 它的参数为什么是 1,将会在后续的章节中深入讲解,这里我们只需要关注这种行为就好了。

自此,一个简单的 todoList 的四种需求已经被我们用 RxJS 实现了,下一篇文章我们会介绍如何用 RxJS 把网络请求,websocket 等事件接入到这些业务逻辑中。

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