點燈坊

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

使用 merge() 將多個 Observable 合併成單一 Observable

Sam Xiao's Avatar 2020-06-10

既然 API 回傳結果與 DOM event 都是 Observable,可將兩個 Observable 合併成單一 Observable 顯示。

Version

macOS Catalina 10.15.4
WebStorm 2020.1
Vue 2.6.11
RxJS 6.5.5

Browser

merge000

+ 按下後除了顯示 FP in JavaScript 外,還同時顯示了其封面圖片。

merge001

若圖片載入錯誤,則顯示 Speaking JavaScript 與其封面圖片。

Data

{
  "title": "FP in JavaScript",
  "price": 100,
  "categoryId": 1,
  "image": "fpjs.jpg"
}

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

Split Observable

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

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

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

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

  let title$ = book$.pipe(pluck('title'))

  let image$ = book$.pipe(
    pluck('image'),
    map(x => `/images/${x}`)
  )

  return { title$, image$ }
}

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

第 3 行

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

除了 title 外,還多顯示了其封面圖片。

13 行

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

fetchBook$() 回傳為 Observable。

18 行

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

由於 title$image$ 都來自於 book$,無法由 click$ 直接 exhaustMap() 產生兩個 Observable,只能先 exhaustMap()book$,再分成 title$image$,並加上 share() 避免 multiple API request。

26 行

let image$ = book$.pipe(
  pluck('image'),
  map(x => `/images/${x}`)
)

除了 pluck()image property 外,因為圖片是放在 public/images 目錄下,因此我們還須透過 map() 加上完整路徑。

Image Loading Error

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

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

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

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

  let title$ = book$.pipe(pluck('title'))

  let image$ = book$.pipe(
    pluck('image'),
    map(x => `/image/${x}`)
  )

  return { title$, image$ }
}

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

23 行

let image$ = book$.pipe(
  pluck('image'),
  map(x => `/image/${x}`)
)

map() 的 callback 寫錯,從 images 打成 image

merge002

<img> 將載入圖片失敗。

這種載入圖片失敗,能使用 RxJS 解決嗎 ?

merge()

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

<script>
import { ajax } from 'rxjs/ajax'
import { merge } from 'rxjs'
import { map, mapTo, pluck, exhaustMap, share } from 'rxjs/operators'

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

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

  let successTitle$ = book$.pipe(pluck('title'))

  let successImage$ = book$.pipe(
    pluck('image'),
    map(x => `/image/${x}`)
  )

  let failedTitle$ = this.imageError$.pipe(
    mapTo('Speaking JavaScript')
  )

  let failedImage$ = this.imageError$.pipe(
    mapTo('/images/spjs.jpg')
  )

  let title$ = merge(successTitle$, failedTitle$)

  let image$ = merge(successImage$, failedImage$)

  return { title$, image$ }
}

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

第 5 行

<img v-stream:error="imageError$" :src="image$">

當圖片載入失敗時,會觸發 <img>error event,可將 error event 轉成 imageError$ Subject。

47 行

domStreams: [
  'click$',
  'imageError$'
],

宣告 imageError$domStreams

25 行

let successTitle$ = book$.pipe(pluck('title'))

let successImage$ = book$.pipe(
  pluck('image'),
  map(x => `/image/${x}`)
)

當圖片順利載入成功時則產生 successTitle$successImage$ Observable,負責顯示 FP in JavaScript 與其封面圖片。

32 行

let failedTitle$ = this.imageError$.pipe(
  mapTo('Speaking JavaScript')
)

let failedImage$ = this.imageError$.pipe(
  mapTo('/images/spjs.jpg')
)

當圖片載入失敗時則使用 imageError$ 產生 failedTitle$failedImage$ Observable,負責顯示 Speaking JavaScript 與其封面圖片。

由於產生固定值,因此使用 mapTo() 即可。

mapTo()
功能等同於 map(),差別在 map() 會隨著 Observable 內部值而變,而 mapTo() 為常數

40 行

let title$ = merge(successTitle$, failedTitle$)

let image$ = merge(successImage$, failedImage$)

本文關鍵在此,將 successTitle$failedTitle$ 兩個 Observable 合併然後顯示即可。

merge() 原意為將兩個 Observable 加以合併。

merge()
將任意個數 Observable 合併成單一 Observable

merge003

但這裡很有趣的是 successTitle$failedTitle$ 兩個 Observable 其實不會同時存在,因此儘管是 merge(),但其實最後只是 successTitle$failedTitle$ 兩者之一而已。

Point-free

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

<script>
import { ajax } from 'rxjs/ajax'
import { merge } from 'rxjs'
import { map, mapTo, pluck, exhaustMap, share } from 'rxjs/operators'
import { concat } from 'ramda'

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

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

  let successTitle$ = book$.pipe(pluck('title'))

  let successImage$ = book$.pipe(
    pluck('image'),
    map(concat('/image/'))
  )

  let failedTitle$ = this.imageError$.pipe(
    mapTo('Speaking JavaScript')
  )

  let failedImage$ = this.imageError$.pipe(
    mapTo('/images/spjs.jpg')
  )

  let title$ = merge(successTitle$, failedTitle$)

  let image$ = merge(successImage$, failedImage$)

  return { title$, image$ }
}

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

25 行

let successImage$ = book$.pipe(
  pluck('image'),
  map(concat('/image/'))
)

map() 的 callback 可使用 Ramda 的 concat() 使其 point-free。

Conclusion

  • 因為成功與失敗的 Observable 其實不會同時出現,可使用 merge() 巧妙的將兩個 Observable 合一顯示

Reference

John Lindquist, Handle Image Loading Errors in Vue.js with RxJS and domStreams
RxJS, exhaustMap()
RxJS, share()
RxJS, map()
RxJS, mapTo()
Ramda, concat()
Ramda, always()