點燈坊

失くすものさえない今が強くなるチャンスよ

如何使每次 Click 都對應一個 Observable ?

Sam Xiao's Avatar 2020-06-24

DOM Event 為 Observable,而 API 回傳資料亦為 Observable,若在 DOM Event 發起 API Request,這就會產生 Highr Order Observable,RxJS 提供了多個 Operator 可在不同場景使用。

Version

macOS Catalina 10.15.4
WebStorm 2020.1
Vue 2.6.11
RxJS 6.5.5

Browser

switchmap000

按下 + 會顯示 titleFP in JavaScript

Data

{
  "title": "FP in JavaScript",
  "price": 100,
  "categoryId": 1,
}

http://localhost:3000/books/1 回傳為以上 object。

mergeMap()

<template>
  <div>
    <button v-stream:click="{ subject: click$, data: 1 }">+</button>
    <h1>{{ title$ }}</h1>
  </div>
</template>

<script>
import { ajax } from 'rxjs/ajax'
import { pluck, delay, mergeMap } from 'rxjs/operators'

let fetchBook$ = x => ajax(`http://localhost:3000/books/${x}`).pipe(
  pluck('response', 'title'),
  delay(2000)
)

let subscriptions = function() {
  let title$ = this.click$.pipe(
    pluck('data'),
    mergeMap(fetchBook$)
  )

  return { title$ }
}

export default {
  name: 'app',
  domStreams: ['click$'],
  subscriptions,
}
</script>

12 行

let fetchBook$ = x => ajax(`http://localhost:3000/books/${x}`).pipe(
  pluck('response', 'title'),
  delay(2000)
)

fetchBook$() 回傳為 Observable,為模擬慢速網路特別 delay 2 秒。

18 行

let title$ = this.click$.pipe(
  pluck('data'),
  mergeMap(fetchBook$)
)

Button 的 click event 將對應到 click$ Subject,我們希望 button 每按一下,就讀取 API 一次,也就是每 click 一次就回傳一個 Observable,這會產生 Higher Order Observable。

若要將 Higher Order Observable 攤平,且保持原本 Inner Observable 的 timing 順序與個數,則要使用 mergeMap()

mergeMap()
將 Higher Order Observable 攤平並保持原 Inner Observable 的 timing 順序與個數

api000

5030 還沒結束就出現,因此 50 會提早出來,mergeMap() 會忠實保留原本 Inner Observable 的 timing 與個數。

api001

若快速在 +3 次,mergeMap() 會發出 3 次 API request。

concatMap()

<template>
  <div>
    <button v-stream:click="{ subject: click$, data: 1 }">+</button>
    <h1>{{ title$ }}</h1>
  </div>
</template>

<script>
import { ajax } from 'rxjs/ajax'
import { pluck, delay, concatMap } from 'rxjs/operators'

let fetchBook$ = x => ajax(`http://localhost:3000/books/${x}`).pipe(
  pluck('response', 'title'),
  delay(2000)
)

let subscriptions = function() {
  let title$ = this.click$.pipe(
    pluck('data'),
    concatMap(fetchBook$)
  )

  return { title$ }
}

export default {
  name: 'app',
  domStreams: ['click$'],
  subscriptions,
}
</script>

18 行

let title$ = this.click$.pipe(
  pluck('data'),
  concatMap(fetchBook$)
)

mergeMap() 在本例雖然可行,都顯示 FP in JavaScript,但觀察其 marble diagram 會發現若 Outer Observable 在 Inner Observable 尚未完成時觸發,這使的 Inner Observable 會在前 Observable 尚未完成時出現,這會影響 Inner Observable 最後顯示結果。

若需求為無論如何都保持 Outer Observable 順序,不要受 Inner Observable 的 timing 影響,這就要使用 concatMap()

concatMap()
將 Higher Order Observable 攤平並保持原 Outer Observable 的順序與 Inner Observable 個數

api002

50 雖然在 30 還沒結束就出現,concatMap() 會依照原 Outer Observable 的順序顯示 Inner Observable。

api003

若快速在 +3 次,mergeMap() 也會發出 3 次 API request。

switchMap()

<template>
  <div>
    <button v-stream:click="{ subject: click$, data: 1 }">+</button>
    <h1>{{ title$ }}</h1>
  </div>
</template>

<script>
import { ajax } from 'rxjs/ajax'
import { pluck, delay, switchMap } from 'rxjs/operators'

let fetchBook$ = x => ajax(`http://localhost:3000/books/${x}`).pipe(
  pluck('response', 'title'),
  delay(2000)
)

let subscriptions = function() {
  let title$ = this.click$.pipe(
    pluck('data'),
    switchMap(fetchBook$)
  )

  return { title$ }
}

export default {
  name: 'app',
  domStreams: ['click$'],
  subscriptions,
}
</script>

18 行

let title$ = this.click$.pipe(
  pluck('data'),
  switchMap(fetchBook$)
)

雖然每次 click 都會對應一個 Observable,若需求為 Inner Observable 尚未完成前有新的 Inner Observable 出現,會優先使用最新 Inner Observable,捨棄尚未完成的 Inner Observable,這就要使用 switchMap()

switchMap()
將 Higher Order Observable 攤平,若有新的 Inner Observable 將捨棄尚未完成的 Inner Observale

api004

5030 還沒結束時就出現,swtichMap() 會優先顯示 50 並捨棄尚未完成的 30

api006

若快速在 +3 次,switchMap() 也會發出 3 次 API request。

exhaustMap()

<template>
  <div>
    <button v-stream:click="{ subject: click$, data: 1 }">+</button>
    <h1>{{ title$ }}</h1>
  </div>
</template>

<script>
import { ajax } from 'rxjs/ajax'
import { pluck, delay, exhaustMap } from 'rxjs/operators'

let fetchBook$ = x => ajax(`http://localhost:3000/books/${x}`).pipe(
  pluck('response', 'title'),
  delay(2000)
)

let subscriptions = function() {
  let title$ = this.click$.pipe(
    pluck('data'),
    exhaustMap(fetchBook$)
  )

  return { title$ }
}

export default {
  name: 'app',
  domStreams: ['click$'],
  subscriptions,
}
</script>

17 行

let title$ = this.click$.pipe(
  pluck('data'),
  exhaustMap(fetchBook$)
)

雖然每次 click 都會對應一個 Observable,因為在本例都發出相同 API,結果也相同,若需求只想發出一次 API request,並捨棄之後的 Inner Observable,這就要使用 exhaustMap()

exhaustMap()
將 Higher Order Observable 攤平,在原 Inner Observable 尚未完成前將捨棄新出現的 Inner Observable

switchmap001

5030 還沒結束時就出現,exhaustMap() 會優先顯示 30 並捨棄尚未出現的 50

api008

若快速在 +3 次,exhaustMap() 只會發出 1 次 API request,在 API request 尚未完成前的 Inner Observable 將被捨棄。

Conclusion

  • mergeMap()concatMap()switchMap()exhaustMap() 都可處理 Higher Order Observable,可視需求決定要使用哪個 operator

Reference

John Lindquist, Map Click Event Streams to Load Data with Vue.js and RJS
AJ Proyor, Demystifying Flattening Operators in RxJS without Code
RxJS, mergeMap()
RxJS, concatMap()
RxJS, switchMap()
RxJS, exhaustMap()