點燈坊

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

如何使 API 大於 1 秒鐘才顯示 Spinner ?

Sam Xiao's Avatar 2021-06-07

由於 API 會受網路狀況影響,因此希望 Response Time 大於 1 秒才顯示 Spinner,且若顯示 Spinner 後也不要立即隱藏,最少顯示 0.5 秒,這種需求該如何實現呢 ?

Version

Vue 3.0.11
Tailwind CSS 2.1.4

Promise Chain

spinner000

若 API 的 response time 小於 1 秒,會立即顯示 data 不會顯示 spinner。

spinner001

若 API 的 response time 大於 1 秒,會顯示 spinner 且最少顯示 0.5 後才隱藏。

<template lang='pug'>
div(v-if='isShowSpinner').fixed.top-0.left-0.right-0.bottom-0.w-full.h-screen.z-50.overflow-hidden.bg-gray-700.opacity-75.flex.flex-col.items-center.justify-center
  svg(xmlns='http://www.w3.org/2000/svg', viewBox='0 0 16 16').w-16.h-16.animate-spin
    path(fill='#ffffff', d='M12.9 3.1c1.3 1.2 2.1 3 2.1 4.9 0 3.9-3.1 7-7 7s-7-3.1-7-7c0-1.9 0.8-3.7 2.1-4.9l-0.8-0.8c-1.4 1.5-2.3 3.5-2.3 5.7 0 4.4 3.6 8 8 8s8-3.6 8-8c0-2.2-0.9-4.2-2.3-5.7l-0.8 0.8z')

div
  button(@click='onClick').border.border-gray-300.text-xs.text-gray-700.px-2.py-1 Get Books
ul
  li(v-for='x in books') {{ x.title }} / {{ x.price }}    
</template>

<script setup>
import getBooks from '/src/api/getBooks'

const API_THRESHOLD = 1000
const SPINNER_TIME = 500

ref: books = []
ref: isShowSpinner = false

let sleep = ms => new Promise(resolve => setTimeout(resolve, ms))

let showSpinner = () => isShowSpinner = true
let hideSpinner = () => isShowSpinner = false

let errorHandling = e => {
  let { response: { data: { error } } } = e
  console.error(error)
}

let onClick = () => {
  let booksP = getBooks()
    .then(x => x.data)
    .then(x => books = x)
    .catch(errorHandling)

  let showBooksSpinner = () => {
    showSpinner()
    Promise.all([
      booksP,
      sleep(SPINNER_TIME)
    ]).then(hideSpinner)
  }

  Promise.race([
    booksP.then(() => Promise.reject('API response less then 1000ms')),
    sleep(API_THRESHOLD)
  ]).then(showBooksSpinner)
    .catch(console.log)
}
</script>

第 2 行

div(v-if='isShowSpinner').fixed.top-0.left-0.right-0.bottom-0.w-full.h-screen.z-50.overflow-hidden.bg-gray-700.opacity-75.flex.flex-col.items-center.justify-center
  svg(xmlns='http://www.w3.org/2000/svg', viewBox='0 0 16 16').w-16.h-16.animate-spin
    path(fill='#ffffff', d='M12.9 3.1c1.3 1.2 2.1 3 2.1 4.9 0 3.9-3.1 7-7 7s-7-3.1-7-7c0-1.9 0.8-3.7 2.1-4.9l-0.8-0.8c-1.4 1.5-2.3 3.5-2.3 5.7 0 4.4 3.6 8 8 8s8-3.6 8-8c0-2.2-0.9-4.2-2.3-5.7l-0.8 0.8z')

使用 Tailwind 實現 spinner,並使用 isShowSpinner 控制顯示 spinner。

15 行

const API_THRESHOLD = 1000
const SPINNER_TIME = 500

設定 constant,可視需求自行調整:

  • API_THRESHOLD:API response time 大於 API_THRESHOLD 才會顯示 spinner
  • SPINNER_TIME:spinner 最短顯示時間

18 行

ref: books = []
ref: isShowSpinner = false
  • books:顯示所有 API 回傳資料
  • isShowSpinner:控制是否顯示 spinner

21 行

let sleep = ms => new Promise(resolve => setTimeout(resolve, ms))

sleep()setTimeout() 包裹成 Promise,用於讓 spinner 顯示 0.5 秒以上。

23 行

let showSpinner = () => isShowSpinner = true
let hideSpinner = () => isShowSpinner = false

將 spinner 隱藏或顯示。

32 行

let booksP = getBooks()
  .then(x => x.data)
  .then(x => books = x)
  .catch(errorHandling)

按下 onClick() 先執行 getBooks() 呼叫 API,並存入 booksP Promise,稍後由 Promise.race() 判斷。

45 行

Promise.race([
  booksP.then(() => Promise.reject('API response less then 1000ms')),
  sleep(API_THRESHOLD)
]).then(showBooksSpinner)
  .catch(console.log)

使用 Promise.race() 判斷 API response time 大於 1 秒才顯示 spinner:

  • booksP:呼叫 API 所回傳 Promise

  • sleep()1 秒 delay 的 Promise

sleep() 早於 booksP Promise,則 Promise.race() 將選擇 sleep() 並繼續執行 showBooksSpinner()

booksP Promise 早於 sleep(),則發起 Rejected Promise 終止後續執行。

37 行

let showBooksSpinner = () => {
  showSpinner()
  Promise.all([
    booksP,
    sleep(SPINNER_TIME)
  ]).then(hideSpinner)
}

sleep() 早於 booksP Promise 時,由於還沒呼叫 API,會在 showBooksSpinner() 呼叫 API。

使用 Promise.all() 讓所有 Promise 都執行才會繼續:

  • booksP:呼叫 API 所回傳 Promise
  • sleep()0.5 秒顯示 Spinner

sleep() 早於 booksP Promise,則會等 booksP Promise 呼叫 API 成功後才會隱藏 spinner

booksP Promise 早於 sleep(),則會等 sleep() 完成後才會隱藏 spinner,因此可確保 spinner 顯示最短時間。

Point-free

<template lang='pug'>
div(v-if='isShowSpinner').fixed.top-0.left-0.right-0.bottom-0.w-full.h-screen.z-50.overflow-hidden.bg-gray-700.opacity-75.flex.flex-col.items-center.justify-center
  svg(xmlns='http://www.w3.org/2000/svg', viewBox='0 0 16 16').w-16.h-16.animate-spin
    path(fill='#ffffff', d='M12.9 3.1c1.3 1.2 2.1 3 2.1 4.9 0 3.9-3.1 7-7 7s-7-3.1-7-7c0-1.9 0.8-3.7 2.1-4.9l-0.8-0.8c-1.4 1.5-2.3 3.5-2.3 5.7 0 4.4 3.6 8 8 8s8-3.6 8-8c0-2.2-0.9-4.2-2.3-5.7l-0.8 0.8z')

div
  button(@click='onClick').border.border-gray-300.text-xs.text-gray-700.px-2.py-1 Get Books
ul
  li(v-for='x in books') {{ x.title }} / {{ x.price }}    
</template>

<script setup>
import { ref } from 'vue'
import { write } from 'vue3-fp'
import { pipe, compose, andThen as then, otherwise, prop, path, T, F } from 'ramda'
import { sleep, reject, log, error } from 'wink-fp'
import getBooks from '/src/api/getBooks'

const API_THRESHOLD = 1000
const SPINNER_TIME = 500

let books = ref([])
let isShowSpinner = ref(false)

let showSpinner = compose(write(isShowSpinner), T)
let hideSpinner = compose(write(isShowSpinner), F)

let errorHandling = pipe(
  path(['response', 'data', 'error']),
  error
)

let onClick = () => {
  let booksP = pipe(
    getBooks,
    then(prop('data')),
    then(write(books)),
    otherwise(errorHandling)
  )()

  let showBooksSpinner = pipe(
    showSpinner,
    () => Promise.all([booksP, sleep(SPINNER_TIME)]),
    then(hideSpinner)
  )
  
  pipe(
    () => Promise.race([
      booksP.then(() => reject('API response less then 1000ms')),
      sleep(API_THRESHOLD)
    ]),
    then(showBooksSpinner),
    otherwise(log)
  )()
}
</script>

34 行

let booksP = pipe(
  getBooks,
  then(prop('data')),
  then(write(books)),
  otherwise(errorHandling)
)()

按下 onClick() 先使用 pipe() 組合 IIFE 回傳 booksP Promise:

  • getBooks:呼叫 API 回傳 Promise
  • then(prop('data')):從 Promise 內取得 data
  • then(write(books)):從 Promise 內取得內部值寫入 books state
  • otherwise(errorHandling):處理 Rejected Promise

47 行

pipe(
  () => Promise.race([
    booksP.then(() => reject('API response less then 1000ms')),
    sleep(API_THRESHOLD)
  ]),
  then(showBooksSpinner),
  otherwise(log)
)()

使用 pipe() 組合 IIFE:

  • () => Promise.race():使用 Promise.race() 比較 showBooks()sleep() 誰先回傳
  • then(showBooksSpinner):若 sleep() 先回傳則 then() 繼續呼叫 API
  • otherwise(log):若 showBooks() 先回傳則發出 Rejected Promise 終止後續執行

41 行

let showBooksSpinner = pipe(
  showSpinner,
  () => Promise.all([booksP, sleep(SPINNER_TIME)]),
  then(hideSpinner)
)

使用 pipe() 組合 showBooksSpinner() ,當 sleep() 早於 API response time 時再次處理 booksP Promise:

  • showSpinner:顯示 spinner
  • () => Promise.all([booksP, sleep(SPINNER_TIME)]):等所有 Promise 都執行完才會繼續,因此會確保 spinner 有最短顯示時間
  • then(hideSpinner):隱藏 spinner

Conclusion

  • 使用 Promise.race() 判斷 1 秒鐘才顯示 spinner
  • 使用 Promise.all() 確保 spinner 有最短顯示時間