使用Rxjs寫前端異步流信息

準備工做

首先在 GitHub - teambition/learning-rxjs: Learning RxJS step by step clone 項目所需的 seed,本文中全部涉及到 RxJS 的代碼將所有使用 TypeScript 編寫。git

使用 npm start 啓動 seed 項目,在瀏覽器中經過 http://localhost:3000 進入 demo 頁面,這篇文章中咱們將實現如下幾點功能:github

  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:web

// 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 中流動了。npm

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 掉空值websocket

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 操做符中。app

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 了:socket

下一步咱們來實現點擊 add 按鈕增長一個 todo item 功能。能夠看到,在程序上這個操做和按下回車後須要的後續操做是同樣的。因此咱們只須要將點擊 add 按鈕事件也變成一個 Observable 而後與 按下回車 的 Observable merge 到一塊兒就行了:spa

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. 它被多個訂閱者訂閱會執行屢次,而且執行時上下文是獨立的

也就是說,咱們的 remove$ Observable 會從新讓 item$ Observable 中的邏輯從新執行一遍:

image

在上圖中,toggle$ Observable 先訂閱並執行了黃色箭頭部分的過程,remove$ Observable 訂閱的時候從新執行綠色部分的過程,然而這個時候 input$ Observable 中已經不會流數據出來了。

想象一下,首先 toggle$ Observable 被訂閱,隨後 remove$ Observable 被訂閱。此時因爲這兩個 Observable 被訂閱致使 $item Observable 被訂閱了**兩次**,因此對 input 與 add button 的 addEventlistener 邏輯執行了**兩次**。在按下回車或者點擊 add button 的時候,第一個 item$ Observable 的訂閱邏輯先執行,向 DOM 中加入了一個 todoItem 並將 input 清空,此時再執行第二個 item$ Observable 的訂閱邏輯,此時 input 裏面已經爲空,因此這個 item$ Observable 裏面沒有數據流過,這也是咱們的代碼沒有按照預期執行的緣由。爲了驗證這個猜測,咱們只須要把 $item Observable 中的 do 操做符中的* $input.value = '' *註釋掉就能夠更直觀的觀察到程序如今的運行狀態了:

image

圖中紅色的箭頭是 toggle$ Observable 的 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  coldObservable vs  Subject 等概念,以及這裏爲何用  publishReplay, 它的參數爲何是 1,將會在後續的章節中深刻講解,這裏咱們只須要關注這種行爲就行了。

自此,一個簡單的 todoList 的四種需求已經被咱們用 RxJS 實現了,下一篇文章咱們會介紹如何用 RxJS 把網絡請求,websocket 等事件接入到這些業務邏輯中。

相關文章
相關標籤/搜索