標籤:

Hello RxJS

本文是一系列介紹 RxJS 文章的第一篇,這一系列的文章將從一個小的例子開始,逐漸深入的講解 RxJS 在各種場景下的應用。對應的,也會有對 RxJS 各種操作符的講解(如果能堅持不棄坑的話。這篇文章將會用一個 Todo list 作為例子,講解 RxJS 是如何組合各種同步/非同步業務的,在這個過程中,初次接觸 RxJS 的同學可能會被各種操作符和組合搞得雲里霧裡,但沒關係,本片的主旨是讓大家了解 RxJS 是如何在我們熟悉的那些業務場景中使用的,更細節的介紹將會在後續的文章中。

準備工作

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

使用 npm start 啟動 seed 項目,在瀏覽器中通過 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.tsimport { 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 中流動了。

使用 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()

使用 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()

再來一個 createTodoItem 的 helper:

// lib.tsexport 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.tsimport { 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()

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

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

在上圖中,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 = "" 注釋掉就可以更直觀的觀察到程序現在的運行狀態了:

圖中紅色的箭頭是 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$ 是這樣的

關於 Observable 的 hot vs cold, Observable vs Subject 等概念,以及這裡為什麼用 publishReplay, 它的參數為什麼是 1,將會在後續的章節中深入講解,這裡我們只需要關注這種行為就好了。

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

廣告

skipUntil(you are interested in TypeScript)

關於 TypeScript 的最佳實踐,一直都有一些小夥伴問我,之前也一直沒有精力做詳細的回答。現在好消息來了,由我和 DavidCai1993 (David Cai) · GitHub, vagusX (vagusX) · GitHub 翻譯的 Learning TypeScript 中文版近期就會開賣了!書裡面涵蓋了運用面向對象的最佳實踐編寫可維護可擴展的 TypeScript 代碼,搭建自動化的 TypeScript workflow,使用 TypeScript 進行自動化單元測試 & 集成測試,使用 TypeScript 及其最佳實踐從頭實現 MVC 框架 & 應用 等多個方面的內容,講解非常詳細,我的很多 TypeScript 編寫習慣都是在譯這本書的時候養成的,值得一看!


推薦閱讀:

有哪些公司在使用或者準備使用Angular2?
如何進一步熟悉甚至掌握Angular?
angular 和 typescript 到底是否適合最佳實踐?
什麼時候選擇 Babel,什麼時候選擇 TypeScript?
現在 TypeScript 的生態如何?

TAG:RxJS | TypeScript |