首先在 GitHub - teambition/learning-rxjs: Learning RxJS step by step clone 项目所需的 seed,本文中全部涉及到 RxJS 的代码将所有使用 TypeScript 编写。git
使用 npm start 启动 seed 项目,在浏览器中经过 http://localhost:3000 进入 demo 页面,这篇文章中咱们将实现如下几点功能:github
若是要响应用户按下回车这个行为,咱们首先要获取用户输入的事件并把它转变成 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
但咱们并不关心用户的输入的其它值,只须要获取按下回车事件这个值,并做出响应。此时咱们只须要对这个 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()
为了完成在回车的时候将输入框中的文字变成一个 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()
再来一个 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()
将 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 = '' }) ...
在建立出这些 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 下一个操做符。
为了实现点击 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的,这意味着:
也就是说,咱们的 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 等事件接入到这些业务逻辑中。