點燈坊

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

如何在 API 未完成時將 Button Disabled ?

Sam Xiao's Avatar 2020-06-10

由於讀取 API 為 Asynchronous 行為,若 Data 未及時回應,User 可能會再次按下 Button 而造成 Mutiple API Request,因此比較好的方式是當按下 Button 後立即將 Button Disabled,等 API 回傳完整後才將 Button Enabled。

Version

macOS Catalina 10.15.4
WebStorm 2020.1
Vue 2.6.11
RxJS 6.5.5

Disabled Button

button000

當 API 尚未完全回傳資料時,butotn 為 disabled 狀態。

button001

當 API 取得完整資料後,button 重回 enabled

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

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

let fetchBook$ = x => ajax(`http://localhost:3000/books/${x}`).pipe(
  delay(3000),
  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}`)
  )

  let disabled$ = merge(
    this.click$.pipe(mapTo(true)),
    book$.pipe(mapTo(false))
  )

  return { title$, image$, disabled$ }
}

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

14 行

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

為了模擬慢速網路讓 Disabled Button 效果更明顯,所以 pipe()delay() 3 秒鐘。

第 3 行

<button v-stream:click="{ subject: click$, data: 1 }" :disabled="disabled$">Show book</button>

disable$ Observable 綁定到 disabled attribute,由 disabled Observable 控制 button 的 disabled

32 行

let disabled$ = merge(
  this.click$.pipe(mapTo(true)),
  book$.pipe(mapTo(false))
)

disabled$ Observable 可由 click$book$ 兩個 Observable merge 而來,一開始由 click$ mapTo true,此時 button 為 disabled,當 book$ 產生後則 mapTo false,此時 button 為 enabled

Button Text

button002

當 API 尚未完全回傳資料時,butotn 除了 disabled 外,且 button 顯示 Loading

button001

當 API 取得完整資料後,button 重回 enabled,且 button 從 Loading 顯示為 Show Book

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

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

let fetchBook$ = x => ajax(`http://localhost:3000/books/${x}`).pipe(
  delay(3000),
  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}`)
  )

  let disabled$ = merge(
    this.click$.pipe(mapTo(true)),
    book$.pipe(mapTo(false))
  ).pipe(startWith(false))

  let buttonText$ = merge(
    this.click$.pipe(mapTo('Loading')),
    book$.pipe(mapTo('Show Book'))
  ).pipe(startWith('Show Book'))

  return { title$, image$, disabled$, buttonText$ }
}

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

第 3 行

<button v-stream:click="{ subject: click$, data: 1 }" :disabled="disabled$">

由於 button text 也會隨時間而變,因此適合使用 Observable 與 reactive programming。

35 行

let disabled$ = merge(
  this.click$.pipe(mapTo(true)),
  book$.pipe(mapTo(false))
).pipe(startWith(false))

由於 disable$ 之前只用於綁定 disabled attribute,因此沒有初始值也沒關係,即將使用 disable$ Observable 產生 buttonText$ Observable,disable$ 沒有初始值將使得其他 Observable 也沒有初始值的尷尬,因此特別加上 startWith() 指定初始值。

當使用 merge() 後的 Observable 產生其他 Observable 時,可能要考慮使用 startWith() 產生初始值

40 行

let buttonText$ = merge(
  this.click$.pipe(mapTo('Loading')),
  book$.pipe(mapTo('Show Book'))
).pipe(startWith('Show Book'))

可使用相同技巧將 this.click$ mapTo 為 Loading,將 book$ mapTo 為 Show Book,最後再將兩個 Observable merge 起來。

可發現藉由 merge Observable 可避免判斷 disable$ Observable 為 truefalse 的尷尬,大幅降低複雜度

Point-free

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

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

let fetchBook$ = x => ajax(`http://localhost:3000/books/${x}`).pipe(
  delay(3000),
  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(concat('/images/'))
  )

  let disabled$ = merge(
    this.click$.pipe(mapTo(true)),
    book$.pipe(mapTo(false))
  ).pipe(startWith(false))

  let buttonText$ = merge(
    this.click$.pipe(mapTo('Loading')),
    book$.pipe(mapTo('Show Book'))
  ).pipe(startWith('Show Book'))

  return { title$, image$, disabled$, buttonText$ }
}

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

32 行

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

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

Conclusion

  • 由於 disabled 與 button text 皆隨時間而變,因此很適合使用 Observable 與 reactive programming 思考
  • 若由 merge 後的 Observable 產生其他 Observable,可能要考慮使用 startWith() 產生初始值
  • 若使用 event 與 imperative 思考,由於集中在同一個 event 處理,因此常在 event handler 中伴隨複雜的 state 判斷,使其複雜度提高;若使用 reactive programming 思考,只要考慮各 Observable 產生自己的 mapTo(),各 Observable 最後 merge 起來即可,完全不需要使用邏輯判斷,可明顯降低複雜度
  • map() 後為固定值,也就是當 Ramda 使用 always() 時,別忘了使用 RxJS 的 mapTo() 系列 function

Reference

John Lindquist, Disabled Buttons While Data is Loading with RxJS and Vue.js
RxJS, exhaustMap()
RxJS, delay()
RxJS, merge()
RxJS, startWith()
RxJS, mapTo()
Ramda, concat()