【武漢-第149期】如何理解RxJS?

一.背景介紹

Rx(Reactive Extension -- 響應式擴展 http://reactivex.io )最近在各個領域都非常火。其實Rx這個貨是微軟在好多年前針對C#寫的一個開源類庫,但好多年都不溫不火,一直到Netflix針對Java平臺做出了RxJava版本后才在開源社區熱度飛速躥升。

二.知識剖析

我們從最基礎的異步回調講起,然后再從? Promise過渡到 RXJS。

異步回調:

在我們平時編程中,當需要解決異步操作時,用得最多的應該就是把回調函數當做參數傳遞給異步函數了吧

function print_msg(msg) {

//Do something with msg

console.log(msg);

}

function async_read(callback) {

// Some async_work start

// Async get data from a PORT into msg

// Some async_work end

callback(msg);

}

async_read(print_msg);

在例1中,我們通過傳遞 print_msg 這個回調函數給異步操作 async_read 以達到當異步操作完成時輸出從端口讀到的 msg 這個目的。

這樣看起來,這種方式簡單易懂,但是,真的很好用嗎?想象一下,我們所傳遞的回調函數也是一個異步操作,也需要傳入一個回調函數來處理異步結果,如例子2:

function my_console(value2) {

//Do something with value2

console.log(value2);

}

function async_work1(callback1, callback2) {

// some async_work start

//......get value1

// some async_work end

callback1(value1, callback2);

}

function async_work2(value1, callback2) {

// some async_work start and do something with value1

//......get value2

// some async_work end

callback2(value2);

}

async_work1(async_work2, my_console);

在例2中,我們將回調函數1和回調函數2傳給了異步操作1,以便在異步操作1完成時調用回調函數1,再將異步操作1得到的 value1 與回調函數2傳給回調函數1,最終當回調函數1完成后將調用回調函數2輸出異步操作2得到的 value2 。當回調函數2也是異步操作的時候怎么辦?難道真的這樣一層套一層?這樣的代碼讀起來晦澀易懂,我們要通過某種方式將其變得更簡單,于是 Promise 出現了!

Promise:

Promise 很好的將這種嵌套式調用轉變成了鏈式調用,使得代碼的可讀性維護性都更高。對于例1,我們可以這樣:

var promise = new Promise(function(resolve, reject) {

// Some async_work start

// Async get data from a PORT into msg

// Some async_work end

if (/* Async_work successed */) {

resolve(msg);

}

else {

reject(error);

}

});

promise.then(function(msg) {

//Do something with msg

console.log(msg)

}, function(error) {

// Failure, do something here

console.log(error);

});

在上例中,我們創建了一個 Promise 對象,并且傳入了一個函數對象,注意這個函數并不是異步操作結束后將被調用的函數,而是用來初始化 Promise 的。這個函數接受2個參數 resolve 和 reject (后者可選),并且我們將異步操作全部移植入到這個函數中,當異步操作執行成功之后,調用了 resolve(msg),這是什么意思?很簡單,如果我們用第一種方法,resolve 這一行肯定是 callback(msg),也就是調用回調函數并將異步得到的 msg 傳給其。所以這里? resolve(msg) 的意思就是通知注冊號的回調函數異步操作已經完成了,并且產出了可用的 msg 參數。那么回調函數是在哪里注冊的呢?可用看到,之后我們又調用了 promise 的 then 方法,它接收一個函數對象,不錯這個函數對象就是我們的回調函數。但是我們的 then 居然接收了2個回調函數, 很顯然,第二個函數是用來處理 reject 通知過來的 error 的。

既然有了 Promise,那么何必再加入 RXJS 這個玩意呢?

Promise 有一個缺點,那便是一旦調用了 resolve 或者 reject 之后便返回了,不能再次 resolve 或者 reject,想象一下,若是從端口源源不斷地發來消息,每次收到消息就要通知回調函數來處理,那該怎么辦呢?

于是,偉大的 RXJS 又出現了!!

RXJS:

我們已經知道了 Promise 的作用和用法,通過 Promise 對象,我們可以在完成異步工作之后調用 resolve(X) 通知回調函數異步操作已經完成了,并且生產了可使用的 X 對象。既然 RXJS 比 Promise 更厲害,那么它當然也可以完成這個任務,并且可以做得更好。

先從官網搬來rxjs的幾個實例概念

Observable: 可觀察的數據序列.

Observer: 觀察者實例,用來決定何時觀察指定數據.

Subscription: 觀察數據序列返回訂閱實例.

Operators: Observable的操作方法,包括轉換數據序列,過濾等,所有的Operators方法接受的參數是上一次發送的數據變更的值,而方法返回值我們稱之為發射新數據變更.

Subject: 被觀察對象.

Schedulers: 控制調度并發,即當Observable接受Subject的變更響應時,可以通過scheduler設置響應方式,目前內置的響應可以調用Object.keys(Rx.Subject)查看。

JSBin這個在線Javascript IDE

記得要添加庫


三.常見問題

Observable到底是什么

先上代碼:

let foo = Rx.Observable.create(observer => {

console.log('Hello');

observer.next(42);

});

foo.subscribe(x => console.log(x));

foo.subscribe(y => console.log(y));


這里可以把foo想象成一個函數,這意味著你每次調用foo都會導致傳入Rx.Observable.create里的回調函數重新執行一次, 調用的方式為foo.subscribe(callback), 相當于foo()。 接收函數返回值的方式也從var a = foo()改為通過傳入回調函數的方式獲取。第三行的observer.next表示返回一個值, 你可以調用多次,每次調用observer.next后, 會先將next里的值返回給foo.subcribe里的回調函數, 執行完后再返回。observer.complete, observer.error來控制流程。 具體看代碼:

var observable = Rx.Observable.create(observer => {

try {

observer.next(1);

console.log('hello');

observer.next(2);

observer.next(3);

observer.complete();

observer.next(4);

} catch (err) {

observer.error(err);

}

});

let subcription = observable.subscribe(value => {

console.log(value)

})


如上的第一個回調函數里的結構是推薦的結構。 當observable的執行出現異常的時候,通過observer.error將錯誤返回, 然而observable.subscribe的回調函數無法接收到.因為observer.complete已經調用, 因此observer.next(4)的返回是無效的. Observable不是可以返回多個值的Promise。 雖然獲得Promise的值的方式也是通過then函數這種類似的方式, 但是new Promise(callback)里的callback回調永遠只會執行一次!因為Promise的狀態是不可逆的。

Observer是什么

先看代碼:

let foo = Rx.Observable.create(observer => {

console.log('Hello');

observer.next(42);

});

let observer = x => console.log(x);

foo.subscribe(observer);

代碼中的第二個變量就是observer. 沒錯, observer就是當Observable"返回"值的時候接受那個值的函數!第五行中的observer其實就是通過foo.subscribe傳入的callback. 只不過稍加封裝了。 怎么封裝的? 看代碼:

var observable = Rx.Observable.create(observer => {

try {

observer.next(1);

console.log('hello');

observer.next(2);

observer.next(3);

observer.complete();

observer.next(4);

} catch(err) {

observer.error(err);

}

});

var observer = {

next(value) { console.log(value) ;},

complete() { console.log('completed');},

error(err) { console.error(err); }

}

let subcription = observable.subscribe(observer);

你看到observer被定義成了一個對象, 其實這才是完整的observer. 傳入一個callback到observable.subcribe相當于傳入了 { next: callback }。


Subcription里的陷阱

Subscription是什么, 先上代碼:

var observable = Rx.Observable.interval(1000);

var subscription = observable.subscribe(x => console.log(x));

setTimeout(() => {

subscription.unsubscribe();

}, 3100)

Rx.Observable.interval可以返回一個能夠發射(返回)0, 1, 2, 3..., n數字的Observable, 返回的時間間隔這里是1000ms。 第二行中的變量就是subscription。 subscription有一個unsubscribe方法, 這個方法可以讓subscription訂閱的observable發射的數據被observer忽略掉。 通俗點說就是取消訂閱。


unsubscribe存在一個陷阱。 先看代碼:

var foo = Rx.Observable.create((observer) => {

var i = 0

setInterval(() => {

observer.next(i++)

console.log('hello')

}, 1000)

})

const subcription = foo.subscribe((i) => console.log(i))

subcription.unsubscribe()

剛才說了, unsubscribe只會讓observer忽略掉observable發射的數據,但是setInterval依然會繼續執行。 這看起來似乎是一個愚蠢的設計。 所以不建議這樣寫。


Subject

Subject是一種能夠發射數據給多個observer的Observable, 這讓Subject看起來就好像是EventEmitter。 先上代碼:

var subject = new Rx.Subject();

subject.subscribe({

next: (v) => console.log('observerA: ' + v)

});

subject.subscribe({

next: (v) => console.log('observerB: ' + v)

});

subject.next(1);

subject.next(2);


與Observable不同的是, Subject發射數據給多個observer。 其次, 定義subject的時候并沒有傳入callback, 這是因為subject自帶next, complete, error等方法。從而可以發射數據給observer。 這和EventEmitter很類似。observer并不知道他subscribe的是Obervable還是Subject。 對observer來說是透明的。 而且Subject還有各種派生, 比如說:

BehaviorSubject 能夠保留最近的數據,使得當有subscribe的時候,立馬發射出去。看代碼:

ReplaySubject 能夠保留最近的一些數據, 使得當有subscribe的時候,將這些數據發射出去。

AsyncSubject 只會發射結束前的一個數據

Multicasted Observables 是一種借助Subject來將數據發射給多個observer的Observable。

四.解決方案

五.代碼實戰

我們來舉幾個例子。比如說在傳統的編程中 a=b+c,表示將表達式的結果賦給a,而之后改變b或c 的值不會影響a。但在響應式編程中,a的值會隨著b或c的更新而更新。

JSBin這個在線Javascript IDE

傳統編程中b,c的變化不會影響a

var a,b=1,c=2;

a=b+c;

console.log('b=' + b);

console.log('c=' + c);

console.log('a=' + a);

b=3;

c=2;

console.log('a=' + a);


那么用響應式編程方法寫出來就是這個樣子,可以看到隨著b和c的變化a也會隨之變化。

var b$ = Rx.Observable.from([1,3]);

var c$ = Rx.Observable.from([2,2]);

var a$ = Rx.Observable.zip(b$, c$, (b,c) => {

console.log('b=' + b);

console.log('c=' + c);

return b+c;

});

a$.subscribe(a=> console.log('a=' +a));


看出來一些不一樣的思維方式了嗎?響應式編程需要描述數據流,而不是單個點的數據變量,我們需要把數據的每個變化匯聚成一個數據流。如果說傳統編程方式是基于離散的點,那么響應式編程就是線。

上面的代碼雖然很短,但體現出Rx的一些特點

Lamda表達式,對,就是那個看上去像箭頭的東西 => 。你可以把它想象成一個數據流的指向,我們從箭頭左方取得數據流,在右方做一系列處理后或者輸出成另一個數據流或者做一些其他對于數據的操作。

操作符:這個例子中的 from, zip 都是操作符。Rx中有太多的操作符,從大類上講分為:創建類操作符、變換類操作符、過濾類操作符、合并類操作符、錯誤處理類操作符、工具類操作符、條件型操作符、數學和聚集類操作符、連接型操作符等等。

Rx再體驗

首先在HTML中引入Rx類庫,然后定義一個id為todo的文本輸入框:

在Javascript標簽中選擇 ES6/Babel,因為這樣可以直接使用ES6的語法,在文本框中輸入以下javascript。在RxJS領域一般在Observable類型的變量后面加上$標識這是一個“流變量”(由英文Stream得來,Observable就是一個Stream,所以用$標識),不是必須的,但是屬于約定俗成。

let todo = document.getElementById('todo');

let input$ = Rx.Observable.fromEvent(todo, 'keyup');

input$.subscribe(input => console.log(input.target.value));

如果Console窗口默認沒有打開的話,請點擊 Console 標簽,然后選中右側的 Run with JS 旁邊的Auto-run js復選框。在Output窗口中應該可以看到一個文本輸入框,在這個輸入框中輸入任意你要試驗的字符,觀察Console

Console和Output窗口


這幾行代碼很簡單:首先我們得到HTML中id為todo的輸入框對象,然后定義一個觀察者對象將todo這個輸入框的keyup事件轉換成一個數據流,最后訂閱這個數據流并在console中輸出我們接收到的input事件的值。我們從這個例子中可以觀察到幾個現象:

數據流:你每次在輸入框中輸入時都會有新的數據被推送過來。本例中,你會發現連續輸入“1,2,3,4”,在console的輸出是“1,12,123,1234”,也就是說每次keyup事件我們都得到了完整的輸入框中的值。而且這個數據流是無限的,只要我們不停止訂閱,它就會一直在那里待命。

我們觀察的是todo上發生的keyup這個事件,那如果我一直按著某個鍵不放會怎么樣呢?你的猜測是對的,一直按著的時候,數據流沒有更新,直到你抬起按鍵為止


如果觀察的足夠仔細的話,你會發現console中輸出的值其實是 input.target.value,我們觀察的對象其實是id為todo的這個對象上發生的keyup事件(Rx.Observable.fromEvent(todo, 'keyup'))。那么其實在訂閱的代碼段中的input其實是keyup事件才對。好,我們看看到底是什么,將 console.log(input.target.value) 改寫成 console.log(input),看看會怎樣呢?是的,我們得到的確實是KeyboardEvent


那么我們再來做幾個小練習,首先將代碼改成下面的樣子,其實不用我講,你應該也可以猜得到,這是要過濾出 keyCode=32 的事件,keyCode是Ascii碼,那么這就是要把空格濾出來

let todo = document.getElementById('todo');

let input$ = Rx.Observable.fromEvent(todo, 'keyup');

input$

.filter(ev=>ev.keyCode===32)

.subscribe(ev=>console.log(ev.target.value));

結果我們看到了,按123456789都沒有反應,直到按了空格


你可能一直在奇怪,我們最終只對輸入框的值有興趣,能不能數據流只傳值過來呢?當然可以,使用map這個變換類操作符就可以完成這個轉換了

let todo = document.getElementById('todo');

let input$ = Rx.Observable.fromEvent(todo, 'keyup');

input$

.map(ev=>ev.target.value*10)

.subscribe(value=>console.log(value));

map這個操作符做的事情就是允許你對原數據流中的每一個元素應用一個函數,然后返回并形成一個新的數據流,這個數據流中的每一個元素都是原來的數據流中的元素應用函數后的值。比如下面的例子,對于原數據流中的每個數應用一個函數10*x,也就是擴大了10倍,形成一個新的數據流。


最常見的兩個操作符我們上面已經了解了,我們繼續再來認識新的操作符。類似 .map(ev=>ev.target.value) 的場景太多了,以至于rxjs團隊搞出來一個專門的操作符來應對,這個操作符就是 pluck。這個操作符專業從事從一系列嵌套的屬性種把值提取出來形成新的流。比如上面的例子可以改寫成下面的代碼,效果是一樣的。那么如果其中某個屬性為空怎么辦?這個操作符負責返回一個 undefined 作為值加入流中。

let todo = document.getElementById('todo');

let input$ = Rx.Observable.fromEvent(todo, 'keyup');

input$

.pluck('target', 'value')

.subscribe(value=>console.log(value));

這里解釋下用到的操作符(創建類操作符(from.fromEvent)、變換類操作符(map)、過濾類操作符(filter)、合并類操作符(combineLatest.zip)、錯誤處理類操作符、工具類操作符、條件型操作符、數學和聚集類操作符、連接型操作符等等)

創建類操作符

通常來講,Rx團隊不鼓勵新手自己從0開始創建Observable,因為狀態太復雜,會遺漏一些問題。Rx鼓勵的是通過已有的大量創建類轉換操作符來去建立Observable。比如 from 和 fromEvent。

from操作符

from 可以支持從數組、類似數組的對象、Promise、iterable 對象或類似Observable的對象(其實這個主要指ES2015中的Observable)來創建一個Observable。

這個操作符應該是可以創建Observable的操作符中最常使用的一個,因為它幾乎可以把任何對象轉換成Observable。

var array = [10, 20, 30];

var result$ = Rx.Observable.from(array);

result$.subscribe(x => console.log(x));


fromEvent操作符

這個操作符是專門為事件轉換成Observable而制作的,非常強大且方便。對于前端來說,這個方法用于處理各種DOM中的事件再方便不過了。

var click$ = Rx.Observable.fromEvent(document, 'click');

click$.subscribe(x => console.log(x));


下面我們稍微給我們的頁面加點料,除了輸入框再加一個按鈕

在Javascript中我們同樣方法得到按鈕的DOM對象以及聲明對此按鈕點擊事件的觀察者:

let todo = document.getElementById('todo');

let input$ = Rx.Observable.fromEvent(todo, 'keyup');

input$

.pluck('target', 'value')

.subscribe(value=>console.log(value));

let addBtn = document.getElementById('addBtn');

let buttonClick$ = Rx.Observable.fromEvent(addBtn, 'click');

buttonClick$

.mapTo('clicked');

由于點擊事件沒有什么可見的值,所以我們利用一個操作符叫 mapTo 把對應的每次點擊轉換成字符 clicked。其實它也是一個 map 的簡化操作。


合并類操作符

combineLatest操作符

既然現在我們已經有了兩個流,應該試驗一下合并類操作符了,先來試試 combineLatest,我們合并了按鈕點擊事件的數據流和文本框輸入事件的數據流,并且返回一個對象,這個對象有兩個屬性,第一個是按鈕事件數據流的值,第二個是文本輸入事件數據流的值。也就是說應該是類似 { ev: 'clicked', input: '1'} 這樣的結構。

let todo = document.getElementById('todo');

let input$ = Rx.Observable.fromEvent(todo, 'keyup');

// input$

//? .pluck('target', 'value')

//? .subscribe(value=>console.log(value));

let addBtn = document.getElementById('addBtn');

let buttonClick$ = Rx.Observable.fromEvent(addBtn, 'click');

// buttonClick$

//? .mapTo('clicked');

Rx.Observable.combineLatest(buttonClick$, input$, (ev, input)=>{

return {

ev: ev,

input: input

}

})

.subscribe(value => console.log(value))

那看看結果如何,在文本輸入框輸入1,沒反應,再輸入2,還是沒反應


那我們點擊一下按鈕試試,這回有結果了,但有點沒明白為什么是12,輸入的數據流應該是: 1,12,... 但那個1怎么丟了呢?


再來文本框輸入3,4看看,這回倒是都出來了


我們來解釋一下combineLatest的機制就會明白了,如下圖所示,上面的2條線是2個源數據流(我們分別叫它們源1和源2吧),經過combineLatest操作符后產生了最下面的數據流(我們稱它為結果流)。

當源1的數據流發射時,源2沒有數據,這時候結果流也不會有數據產生,當源2發射第一個數據(圖中A)后,combineLatest操作符做的處理是,把A和源1的最近產生的數據(圖中2)組合在一起,形成結果流的第一個數據(圖中2A)。當源2產生第二個數據(圖中B)時,源1這時沒有新的數據產生,那么還是用源1中最新的數據(圖中2)和源2中最新的數據(圖中B)組合。

也就是說 combineLatest 操作符其實是在組合2個源數據流中選擇最新的2個數據進行配對,如果其中一個源之前沒有任何數據產生,那么結果流也不會產生數據。


講到這里,有童鞋會問,原理是明白了,但什么樣的實際需求會需要這個操作符呢?其實有很多,我這里只舉一個小例子,現在健身這么熱,比如說我們做一個簡單的BMI計算器,BMI的計算公式是:體重(公斤)/(身高身高)(米米)。那么我們在頁面給出兩個輸入框和一個用于顯示結果的div:

那么在JS中,我們想要達成的結果是只有兩個輸入框都有值的時候才能開始計算BMI,這時你發現combineLatest的邏輯不要太順溜啊。

let weight = document.getElementById('weight');

let height = document.getElementById('height');

let bmi = document.getElementById('bmi');

let weight$ = Rx.Observable

.fromEvent(weight, 'input')

.pluck('target', 'value');

let height$ = Rx.Observable

.fromEvent(height, 'input')

.pluck('target', 'value');

let bmi$ = Rx.Observable

.combineLatest(weight$, height$, (w, h) => w/(h*h/100/100));

bmi$.subscribe(b => bmi.innerHTML=b);

zip操作符

除了 combineLatest ,Rxjs還提供了多個合并類的操作符,我們再試驗一個 zip 操作符。 zip 和 combineLatest 非常像,但重要的區別點在于 zip 嚴格的需要多個源數據流中的每一個的相同順序的元素配對。

比如說還是上面的例子,zip 要求源1的第一個數據和源2的第一個數據組成一對,產生結果流的第一個數據;源1的第二個數據和源2的第二個數據組成一對,產生結果流的第二個數據。而 combineLatest 不需要等待另一個源數據流產生數據,只要有一個產生,結果流就會產生。


zip 這個詞在英文中有拉鏈的意思,記住這個有助于我們理解這個操作符,就像拉鏈一樣,它需要拉鏈兩邊的齒一一對應。從效果角度上講,這個操作符有減緩發射速度的作用,因為它會等待合并序列中最慢的那個。

六.拓展思考

七.參考文獻

參考:如何理解Rxjs

參考:RXJS詳解

參考:Angular 從0到1:Rx--隱藏在Angular 中的利劍

參考:通俗的方式理解RxJS

參考:RxJS 核心概念Observer Subscription

參考:30天精通Rxjs

八.更多討論




武漢第149期PPT:鏈接 https://ptteng.github.io/PPT/PPT/JS-10-How-to-understand-RxJS.html#/



武漢第149期視頻連接:視頻https://v.qq.com/x/page/u0517ceol5p.html


_騰訊視頻




------------------------------------------------------------------------------------------------------------------------

技能樹.IT修真院

“我們相信人人都可以成為一個工程師,現在開始,找個師兄,帶你入門,掌控自己學習的節奏,學習的路上不再迷茫”。

這里是技能樹.IT修真院,成千上萬的師兄在這里找到了自己的學習路線,學習透明化,成長可見化,師兄1對1免費指導。快來與我一起學習吧 !

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 第一節:初識Angular-CLI第二節:登錄組件的構建第三節:建立一個待辦事項應用第四節:進化!模塊化你的應用第...
    接灰的電子產品閱讀 15,636評論 31 77
  • 介紹 RxJS是一個異步編程的庫,同時它通過observable序列來實現基于事件的編程。它提供了一個核心的類型:...
    泓滎閱讀 16,639評論 0 12
  • 作者: maplejaw本篇只解析標準包中的操作符。對于擴展包,由于使用率較低,如有需求,請讀者自行查閱文檔。 創...
    maplejaw_閱讀 45,821評論 8 93
  • title: RxJS簡介date: 2017-08-01 09:45:33tags: [JavaScript, ...
    color_cat閱讀 312評論 0 0
  • 版權聲明:本文為小斑馬偉原創文章,轉載請注明出處! 上篇簡單的闡述了響應式編程的基本理論。這篇主要對響應編程進行詳...
    ZebraWei閱讀 2,536評論 0 2