新手視角 - RxJS之實時監控圖表

shineYao發表於2017-04-16

Rxjs之實時監控折線圖

初學rxjs,本著一個新手的角度完成一個小demo,相信過程中會有很多大家也遇到過的問題,同時整個過程不斷髮散,講解一些rxjs的核心知識點和API,希望這篇文章能給學習rxjs的同學們一些啟發。

專案地址

需求描述

折線圖有12個點(按時間分佈),每隔2秒(為了演示方便)重新整理出一個點。

怎麼做

先簡單點想,

需要一個集中儲存狀態的地方,這裡的狀態其實就是圖表對應的資料,這個地方每經過一個時間間隔就向伺服器請求一次資料,它需要儲存最近12個點對應的資料

把這種想法往rxjs上靠。首先我們先寫個最基本的可觀察物件fetchData$

新建src/app.ts

import {Observable, Observer} from 'rxjs'

import {Mock} from './mock'

const print = x => console.log('x: ', x)

const intervalEmit$ = Observable.interval(2000)

const fetchData$ = Observable.fromPromise(Mock.fetch())

intervalEmit$.subscribe(print)
fetchData$.subscribe(print)複製程式碼

新建src/mock.ts

import axios from 'axios'

export class Mock {

    static fetch():Promise<Number> {
        // base : 20
        return axios.get('https://zan.wilddogio.com/age.json')
        .then(res => Number(res.data) + Mock.randomAge(10))
    }

    // random 1 ~ x
    static randomAge(x) {
        return Math.floor(1 + Math.random() * x)
    }
}複製程式碼

子任務1 - 每兩秒發一個rest請求

很簡單一個是每兩秒produce一個遞增值,一個是請求回來一個promiseable值並produce
現在我們做個組合,也就是每隔兩秒請求回來一個promiseable值並produce,我們修改app.ts

const intervalEmit$ = Observable.interval(2000)

// 第一種
const app$ = intervalEmit$.switchMap(e => Observable.fromPromise(Mock.fetch()))

// 第二種,將switchMap拆開
const fetchData$ = intervalEmit$.map(e => Observable.fromPromise(Mock.fetch()))
const app$ = fetchData$.switch()

// 第三種,使用defer工廠建立Observable
const deferPromise$ = Observable.defer(function () {
     return Observable.fromPromise(Mock.fetch())
})
const app$ = intervalEmit$.switchMap(e => deferPromise$)

app$.subscribe(print)複製程式碼

先說第三種,它相對單純:),我們先看下defer定義,Creates an Observable that, on subscribe, calls an Observable factory to make an Observable for each new Observer. 意思也比較好理解,defer接受一個產生observable的函式,當defer所建立的observable被訂閱時就通過該函式建立一個observable物件。

第一種和第二種放在一塊說,map就不用說了,就是將一個observable經過一個函式轉換形成另一個observable,和Array.prototyp.map很像,但是你可以把它理解成一個時間點上的值或者對一個值的一對一變換。重點說下switch,同樣我們先看下定義,Converts a higher-order Observable into a first-order Observable by subscribing to only the most recently emitted of those inner Observables. 解釋一下,通過訂閱的方式將一個高階observable轉換為一個低階observable,同時僅產生一個低階最近產生的值。

首先要先清楚什麼叫高階,

var fn = function(a, b) {return a + b}複製程式碼

通過typeof fn可以看到fn的型別是function,繼續

var fn1 = fn(1,2)複製程式碼

通過typeof fn1可以看到fn1的型別是number,OK,它已經不是函式了,那麼如何讓fn1繼續是函式呢,我們改寫一下

var fn = function(a) {return function(b) {return a + b}}複製程式碼

如果這次你還想得到1+2=3,那麼你需要fn(1)(2)才能得到,也就是說我們想得到最終的結果呼叫了一次以上的函式,好的這就叫做高階,超過一次就是高階,這和數學裡的高階導數類似的。好了我們回到switch的主題。

var ob$ // 一個可觀察物件
var higher$ = ob$.例項operator(靜態operator)複製程式碼

這裡有一個例項operator,它就是一個轉換器,它將一個源observable作為一個模版轉變為另外一個observable,而且源observable是不被改變的,而靜態operator就像一個observable製造器一樣,一啟動(subscribe)就開始生產。因此

var higher$ = ob$.例項operator(靜態operator)

這裡得到的higher$就是一個高階observable了,因為當你訂閱它時,它不像靜態operator產生資料,而是產生observable,所以就像你執行fn(1)產生的是一個新的函式而不是值一樣。下面是個小栗子,可以看到列印出的是observable。

var print = x=>console.log('x: ', x)
var clicks = Rx.Observable.fromEvent(document, 'click');
var higherOrder = clicks.map((ev) => Rx.Observable.interval(1000));
higherOrder.subscribe(print)
// x:  IntervalObservable {_isScalar: false, period: 1000, scheduler: AsyncScheduler}複製程式碼

因此我們需要switch將high$轉換成低階observable,

var lower$ = higher$.switch()

這樣當我們訂閱lower$的時候,將會得到靜態operator所產生的值,看官方栗子,

var print = x=>console.log('x: ', x)
var clicks = Rx.Observable.fromEvent(document, 'click');
var higherOrder = clicks.map((ev) => Rx.Observable.interval(1000));
var lowerOrder = higherOrder.switch()
lowerOrder.subscribe(print)
//== 第一次點選 ==
// x: 0
// x: 1
//== 第二次點選 ==
// x: 0複製程式碼

可以看到,現在列印出的是值了,而且當我們再次點選時,Rx.Observable.interval(1000)被重新執行了,這也正是Flattens an Observable-of-Observables by dropping the previous inner Observable once a new one appears.的含義,當外層observable產生值時,它會觸發丟棄最近一次被訂閱的內層observable。我們知道promise物件一旦建立,它處於pending狀態,最終變為onFulfille或者onRejected狀態,因此它是不能被取消的。而通過rxjs可以達到目的,看一個栗子。我們用express做一個restFul伺服器,

app.js

var express = require('express');
var app = express()

app.use(express.static('blog'));

app.get('/delay', function(req, res) {
  setTimeout(function(){
    res.send('hello world')
  },3000)

})

var server = app.listen(3000, function () {
  var host = server.address().address
  var port = server.address().port

  console.log('app listening at http://%s:%s', host, port)
})複製程式碼

當伺服器接收到http://localhost:3000/delay請求時,延遲三秒傳送響應。再看客戶端程式碼

最近被取消.html

<script>
window.onload = function () {
            var print = x=>console.log('x: ', x)
            var ajax$ = Rx.Observable.fromPromise($.ajax('/delay'))

            var click$ = Rx.Observable.fromEvent(document, 'click')
            var higher$ = click$.map(e=>Rx.Observable.fromPromise($.ajax('/delay')))

            var app$ = higher$.switch()

            app$.subscribe(print)   //當我在三秒內瘋狂點選5次,其實只返回一次資料,也就是說前四次被unsubscribe了    
        }
</script>複製程式碼

此時我在頁面瘋狂點選五次(三秒之內),你會看到發出了五次請求,但是最終缺只列印出一條hello world,是的前四次都被unsubscribe
了也就是官網中多說的drop,這就達到了撤銷promise的效果。

我們繼續,現在我們實現了每兩秒傳送一個請求,接下來我們實現資料的儲存

子任務2 - 資料replay

首先我們要先儲存夠24個點,之後每來一個點丟棄一個最舊的點。我們小時候都聽過磁帶,錄音機有倒帶的功能(不是周杰倫給蔡依林寫的那首),因此磁帶儲存了整個過程,你可以回退到之前播放的任意一個時間點重新播放,其實我們的一次次請求就像在播放磁帶,我們想獲取到之前點的最好辦法就是可以儲存它們,磁帶也有儲存大小,那麼我們也不可能無限儲存,所以我們就暫存最近24次記錄。下面rxjs的倒帶replay登場。

在rxjs的api文件中搜尋replay可以看到兩個東東ReplaySubjectpublishReplay,前者是一個Subject類,後者是一個Observable例項operator,他們之間有沒有什麼關聯,我們還是先來看看他倆該怎麼用吧,先說和Observable更關係更緊密的publishReplay。

public publishReplay(bufferSize: , windowTime: , scheduler: *): ConnectableObservable -- 這是publicReplay的函式簽名,連個例子都沒有,或許不常用,或許一般都用ReplaySubject?不管怎麼樣我們還是要秉持刨根問底的態度。既然沒有任何栗子那我們就點開source看下原始碼

export function publishReplay(bufferSize = Number.POSITIVE_INFINITY, windowTime = Number.POSITIVE_INFINITY, scheduler) {
    return multicast.call(this, new ReplaySubject(bufferSize, windowTime, scheduler));
}複製程式碼

原來publishReplay的三個引數都是為ReplaySubject例項化服務的,那麼對於引數我們先按下不談,看看這個multicast,這個this代表Observable例項,那麼在我們看看這個operator之前,我們先說下單播多播,這對我們理解該operator很有幫助。

雖然到目前為止我們還沒有講Subject,但是先白話一下單播Observable和多播Subject,單播很高冷(cold)很專注(獨立),她從不主動聯絡別人,只有在別人關注她後,才會和這個人侃侃而談。再來一個人關注她,和她交流中感受不到還有別人的存在。而Subject就很熱情(hot)喜歡分享(不獨立)。不論何時關注她,她都樂於將經驗與人分享。下面看兩個小栗子。

Obserable單播

const printA = (val) => console.log('observerA :' + val)
const printB = (val) => console.log('observerB :' + val)

var clicks = Rx.Observable.fromEvent(document, 'click');
var ones = clicks.mapTo(1);
var seed = 0;
var count = ones.scan((acc, one) => acc + one, seed);
count.subscribe(printA);

setTimeout(function() {
    console.log('another subscribe.')
    ones.scan((acc, one) => acc + one, seed).subscribe(printB)  
}, 3000)複製程式碼

新手視角 - RxJS之實時監控圖表

從圖中可以看到,3秒以後observerB依然從1開始列印,同時也可以看出只有別人訂閱她的時候,她才會和別人溝通。

新手視角 - RxJS之實時監控圖表

從這個圖可以更直觀的看出,當我們訂閱藍色scan轉換後的observable和紅色scan轉換後的observable時,其實走的是兩個獨立的分支,每次訂閱也都是通過fromEvent建立了一個新的observable,其實observable就是一個函式,當收到訂閱時,就執行函式,在函式中通過訂閱者留下的通知方式通知到訂閱者。再來看Subject多播。

Subject多播

var subject = new Rx.Subject()
subject.subscribe(printA)

setTimeout(function() {
    console.log('another subscribe.')
    subject.subscribe(printB)
}, 3000)

Rx.Observable.fromEvent(document, 'click').mapTo(1).scan((acc, one) => acc + one, 0)
.do(num => subject.next(num))
.subscribe()複製程式碼

新手視角 - RxJS之實時監控圖表

從圖中看到雖然observerB3秒後姍姍來到,但是依然分享到了observerA的努力成果,從3開始列印。同時看到subject是主動告知訂閱者,so hot~

新手視角 - RxJS之實時監控圖表

可以看出Subject和Observable的區別,三秒後的訂閱並沒有建立一個新的分支,也就是沒有新的observable例項以及後續的一些列變換。

這裡我們簡單講解了Observable的冷、單播和獨立性以及Subject的熱、多播和共享性。那麼我們回來,繼續說multicast,接受一個Subject例項作為引數,我們有理由相信,這個operator是observable例項通過subject例項被賦予了多播的特性。我們看一個multicast的小栗子。

var clickAddOne$ = Rx.Observable.fromEvent(document, 'click').mapTo(1).scan((acc, one) => acc + one, 0)

var subject = new Rx.Subject

subject.subscribe(printA)
setTimeout(function() {
    console.log('another subscribe.')
    subject.subscribe(printB)
}, 3000)

var app$ = clickAddOne$.multicast(subject)

app$.subscribe()複製程式碼

這段程式碼執行起來除了another subscribe.,不論你如何點選都不會列印其他資訊。看來這個app$不是單純的observable例項,我們看下rxjs官網對於multicast的描述:

新手視角 - RxJS之實時監控圖表

意思大概是,返回值是一個ConnectableObservable例項,該例項可以產生資料共享給潛在的訂閱者(即Subject例項上的訂閱者),我們修改一下程式碼。

// app$.subscribe()
app$.connect()複製程式碼

新手視角 - RxJS之實時監控圖表

從圖中我們看到了和上面Subject多播一致的結果。這裡我們看到了一個陌生的方法connectConnectableObservable繼承自Observable,同時具有一個connect方法和一個refCount方法。connect方法決定何時訂閱生效,同時返回一個方法以決定何時取消所有訂閱。

var clickAddOne$ = Rx.Observable.fromEvent(document, 'click').mapTo(1).scan((acc, one) => acc + one, 0).do(x=>console.log('do: ' + x))

var subject = new Rx.Subject

subject.subscribe(printA)
setTimeout(function() {
    console.log('another subscribe.')
    subject.subscribe(printB)
}, 3000)

var app$ = clickAddOne$.multicast(subject)

var connector = app$.connect()

setTimeout(function() {
    connector.unsubscribe()
}, 6000)複製程式碼

6秒過後,點選不會產生任何列印資訊。這裡顯示呼叫connect和返回例項上的unsubscribe顯得太命令式了,這裡我們還可以使用refCount使得這個過程的關注點放在observer的訂閱和取消上。改寫下上面的例子

var clickAddOne$ = Rx.Observable.fromEvent(document, 'click').mapTo(1).scan((acc, one) => acc + one, 0).do(x=>console.log('do: ' + x))

var subject = new Rx.Subject

var app$ = clickAddOne$.multicast(subject).refCount()

app$.subscribe(printA)
setTimeout(function() {
    console.log('another subscribe.')
    app$.subscribe(printB)
}, 3000)複製程式碼

新手視角 - RxJS之實時監控圖表

這更加Observable,同時我們也達到了Observable多播化的目的,破費!

兜了一大圈回到publishReplay,再看下面的原始碼就更清楚了許多

export function publishReplay(bufferSize = Number.POSITIVE_INFINITY, windowTime = Number.POSITIVE_INFINITY, scheduler) {
    return multicast.call(this, new ReplaySubject(bufferSize, windowTime, scheduler));
}複製程式碼

publishReplay本身就是observable.multicast(new ReplaySubject)的語法糖,那麼我們就來看下ReplaySubject是個啥。先上一個小栗子

const printA = (val) => console.log('observerA :' + val)
const printB = (val) => console.log('observerB :' + val)
var subject = new Rx.ReplaySubject(3);
subject.subscribe({
    next: (v) => console.log('observerA: ' + v)
});
subject.next(1);
subject.next(2);
subject.next(3);
subject.next(4);
subject.subscribe({
    next: (v) => console.log('observerB: ' + v)
});

subject.next(5);

subject.subscribe({
    next: (v) => console.log('observerC: ' + v)
});複製程式碼

新手視角 - RxJS之實時監控圖表

可以看出後兩次subscribe,就列印出了前三次可觀察物件產生的值,這有點像Observable訂閱,但又不會建立新的Observable例項,這種帶有重新傳送以前資料的能力就是ReplaySubject了,因此下面兩端程式碼是所實現的功能是一樣的

var app$ = Rx.Observable.interval(1000).multicast(new Rx.ReplaySubject(3)).refCount()
app$.subscribe(printA)
setTimeout(function () {
    app$.subscribe(printB)
}, 3000)複製程式碼
var app$ = Rx.Observable.interval(1000).publishReplay(3).refCount()
app$.subscribe(printA)
setTimeout(function () {
    app$.subscribe(printB)
}, 3000)複製程式碼

新手視角 - RxJS之實時監控圖表

子任務2 - replay 24個請求資料

經過一個個引申我們掌握了不少rxjs的核心知識點和api使用,那麼回到demo上,我們已經完成了每兩秒完成一次rest請求,下面我們先完成這樣一個任務,當我們快取到第23個點時,後面每新增一個點列印update畫圖。聯絡之前的內容,首先我們要有一個buffersize為24的ReplaySubject例項。每次訂閱都會產生之前24個值,但是這裡會有個問題需要通過訂閱來獲取舊的值,訂閱完以後其實這個訂閱就沒有意義了,Replay功能的基礎其實就是buffer能力,但Subject提供的這種Replay能力卻是cold、lazy的,我們更希望這種replay能力可以更hot,當到達一個bufferSize,就自動把這個bufferSize的資料produce出來,這有點像interval,經過一個時間間隔就produce一個資料,那麼有沒有類似intervalBuffer這種的靜態operator呢:),我們先來搜搜和buffer有關的API。

新手視角 - RxJS之實時監控圖表

一看這個bufferCount好像挺適合我們的,估計是buffer了count個資料後,就會產生count個buffer資料。還是看個小栗子

var source$ = Rx.Observable.interval(1000)
var buffer$ = source$.bufferCount(10)
buffer$.subscribe(x => console.log(x))複製程式碼

新手視角 - RxJS之實時監控圖表

從圖中可以看到每隔10秒列印出了一組長度為10的數字,這顯然不是我們想要的,我們希望每秒列印出一組數字,且丟棄最舊的一個數字,看下bufferCount的函式簽名,

public bufferCount(bufferSize: number, startBufferEvery: number): Observable

bufferCount還接受第二個引數,該引數代表了代表了計算bufferSize的起始位置,第一次達到bufferSize就produce,而從第二次起bufferSize從上一次buffer資料的startBufferEvery開始計算,也就是說當第一次produce後,bufferCount為bufferSize-startBufferEvery,也就是還需要快取startBufferEvery個才會produce下一個buffer。改造下上一個栗子。

var source$ = Rx.Observable.interval(1000)
var buffer$ = source$.bufferCount(10, 1)
buffer$.subscribe(x => console.log(x))複製程式碼

新手視角 - RxJS之實時監控圖表

可以看到達到了我們預期。現在我們完成子任務2,這裡為了演示方便快取5個點。

const print = x => console.log('x: ', x)
const intervalEmit$ = Observable.interval(2000)
const fetch$ = intervalEmit$.switchMap(e => Observable.fromPromise(Mock.fetch()))
const app$ = fetch$.bufferCount(5, 1).do('update畫圖')
app$.subscribe(print)複製程式碼

新手視角 - RxJS之實時監控圖表

OK!

子任務3 - 畫圖

下面我們完成畫圖功能。

const line = new LineChart(document.getElementById('showAge') as HTMLDivElement)
line.setOptions({
        title: {
            left: 'center',
            text: '動態資料(年齡)'
        },
        xAxis: {
            type: 'time',
            splitLine: {
                show: false
            }
        },
        yAxis: {
            type: 'value',
            boundaryGap: [0, '100%'],
            splitLine: {
                show: false
            }
        },
        series: [{
            type: 'line',
            data: []
        }]
    })

line.showLoading()

const now = new Date().getTime()
const span = 2 * 1000
const bufferSize = 12

let counter = 0

const intervalEmit$ = Observable.interval(span)

const fetch$ = intervalEmit$.switchMap(e => Observable.fromPromise(Mock.fetch()))

const app$ = fetch$.bufferCount(bufferSize, 1).map(
    buffer => {
        counter === 0 && line.hideLoading()
        const points =  buffer.map((b, index) => {
            const point = []
            point[0] = now + index * span + span * counter
            point[1] = b
            return point
        })
        counter++
        return points
    }
).do(data => {
    debugger;
    line.setOptions({
        series: [{
            data
        }]
    })
})
app$.subscribe()複製程式碼

效果如下

新手視角 - RxJS之實時監控圖表

最後

一個簡單的實時監控折線圖的demo就完成了,由於本人也是初學rxjs,一些知識點難免會有疏漏,但也儘量做到不誤導,相信大家還是會有些收穫的。

相關文章