點燈坊

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

呼叫 API 顯示 Nested Promise

Sam Xiao's Avatar 2020-08-11

若 API 遵循 RESTful 精神設計,常會第一個 API 僅回傳 id,若要取得其值就要呼叫第二個 API,也就是所謂 N + 1,本文分別以 Aysnc Await、Promise Chain 與 RxJS 三種寫法示範。

Version

macOS Catalina 10.15.4
WebStorm 2020.1.2
Vue 2.6.11
RxJS 6.5.5

Browser

nested000

FPFRPJS 並不是從單一 API 取得,而是得呼叫另外一個 API。

Async Await

Single Side Effect

<template>
  <div>
    <ul v-for="(x, i) in books" :key="i">
      <li>{{ x.title }} / {{ x.price }} / {{ x.category }}</li>
    </ul>
  </div>
</template>

<script>
import axios from 'axios'

let fetchBooks = async () => {
  let { data: books } = await axios.get('http://localhost:3000/books')
  return books
}

let fetchCategory = async x => {
  let { data: { value: category }} = await axios.get(`http://localhost:3000/categories/${x.categoryId}`)
  return category
}

let mounted = async function() {
  let books = await fetchBooks()

  for (let x of books)
    x['category'] = await fetchCategory(x)

  this.books = books
}

export default {
  name:'App',
  data: () => ({
    books: []
  }),
  mounted,
}
</script>

12 行

let fetchBooks = async _ => {
  let { data: books } = await axios.get('http://localhost:3000/books')
  return books
}

呼叫第一個 API:http://localhost:3000/books 取得所有書籍,由於遵守 RESTFul 設計,因此僅回傳 catetoryId,若要取得 category 必須再呼叫另外一個 API。

17 行

let fetchCategory = async x => {
  let { data: { value: category }} = await axios.get(`http://localhost:3000/categories/${x.categoryId}`)
  return category
}

呼叫第二個 API:http://localhost:3000/categories/:idcategoryId 取得 category

23 行

let books = await fetchBooks()

for (let x of books)
  x['category'] = await fetchCategory(x)

先產生 books ,然後呼叫 fetchCategory() 取得 asynchronous 資料後再動態新增 category property。

28 行

this.books = books

最後一次寫入 side effect。

nested004

這種寫法主要問題在於 await 會等 fetchCategory() 的 asynchronous 資料回來後才繼續,可明顯發現 fetchCategory() 會依序執行。

books 筆數少且 fetchCategory() 回傳資料少則無感,但若回傳資料大 (如 Base64 String),則會明顯感覺延遲一段時間後才顯示,比較好的方式是 books 先顯示,然後在陸續顯示 category

Not Work

<template>
  <div>
    <ul v-for="(x, i) in books" :key="i">
      <li>{{ x.title }} / {{ x.price }} / {{ x.category }}</li>
    </ul>
  </div>
</template>

<script>
import axios from 'axios'

let fetchBooks = async () => {
  let { data: books } = await axios.get('http://localhost:3000/books')
  return books
}

let fetchCategory = async x => {
  let { data: { value: category }} = await axios.get(`http://localhost:3000/categories/${x.categoryId}`)
  return category
}

let mounted = async function() {
  this.books = await fetchBooks()

  for (let [i, x] of this.books.entries())
    this.books[i]['category'] = await fetchCategory(x)
}

export default {
  name:'App',
  data: () => ({
    books: []
  }),
  mounted,
}
</script>

22 行

let mounted = async function() {
  this.books = await fetchBooks()

  for (let [i, x] of this.books.entries())
    this.books[i]['category'] = await fetchCategory(x)
}

為了彌補前一種寫法在最後才一次寫入 side effect 缺失,在 fetchBooks 抓到資料後馬上寫入 books data,如此所有 books 將率先顯示,再使用 for loop 對 this.books[i] 動態新增 category property 寫入 category

nested003

一切都很直覺,但結果卻不如預期,category 並沒有顯示。

第 3 行

<ul v-for="(x, i) in books" :key="i">
  <li>{{ x.title }} / {{ x.price }} / {{ x.category }}</li>
</ul>

<ul> 並沒有使用 v-model,而是使用 v-for,這也暗示 Vue 對於 books data 之後的更新,不會立即 reactive 在 v-for 內。

在 Vue 官網的 Array Change Detection 提到:

Vue cannot detect the following changes to an array:

  1. When you directly set an item with the index, e.g. vm.items[indexOfItem] = newValue
  2. When you modify the length of the array, e.g. vm.items.length = newLength
this.books[i]['category'] = await fetchCategory(x)

也就是 26 行的 = 並不會使 Array 被 reactive 更新。

splice()

<template>
  <div>
    <ul v-for="(x, i) in books" :key="i">
      <li>{{ x.title }} / {{ x.price }} / {{ x.category }}</li>
    </ul>
  </div>
</template>

<script>
import axios from 'axios'

let fetchBooks = async () => {
  let { data: books } = await axios.get('http://localhost:3000/books')
  return books
}

let fetchCategory = async x => {
  let { data: { value: category }} = await axios.get(`http://localhost:3000/categories/${x.categoryId}`)
  return ({...x, category})
}

let mounted = async function() {
  this.books = await fetchBooks()

  for (let [i, x] of this.books.entries())
    this.books.splice(i, 1, await fetchCategory(x))
}

export default {
  name:'App',
  data: () => ({
    books: []
  }),
  mounted,
}
</script>

Vue 官網提出兩種 Reactive Array 寫法:

// Array.prototype.splice
vm.items.splice(indexOfItem, 1, newValue)
// Vue.set
Vue.set(vm.items, indexOfItem, newValue)

26 行

this.books.splice(i, 1, await fetchCategory(x))

改用 Array.prototype.splice() 更新 Array,這會引起 reactive 更新。

$set()

<template>
  <div>
    <ul v-for="(x, i) in books" :key="i">
      <li>{{ x.title }} / {{ x.price }} / {{ x.category }}</li>
    </ul>
  </div>
</template>

<script>
import axios from 'axios'

let fetchBooks = async _ => {
  let { data: books } = await axios.get('http://localhost:3000/books')
  return books
}

let fetchCategory = async x => {
  let { data: { value: category }} = await axios.get(`http://localhost:3000/categories/${x.categoryId}`)
  return ({...x, category})
}

let mounted = async function() {
  this.books = await fetchBooks()

  for (let [i, x] of this.books.entries())
    this.$set(this.books, i, await fetchCategory(x))
}

export default {
  name:'App',
  data: () => ({
    books: []
  }),
  mounted,
}
</script>

26 行

this.$set(this.books, i, await fetchCategory(x))

也可使用 this.$set() 修改 books data,這會引起 reactive 更新。

nested005

雖然 Array 能 reactive 更新,但因為使用了 await,因此 fetchCategory() 依然循序執行,但由於 books 會先顯示,然後 category 再一一顯示,因此視覺上並沒有等待的感覺,已經有所改善。

Promise Chain

Single Side Effect

<template>
  <div>
    <ul v-for="(x, i) in books" :key="i">
      <li>{{ x.title }} / {{ x.price }} / {{ x.category }}</li>
    </ul>
  </div>
</template>

<script>
import axios from 'axios'

let fetchBooks = axios.get('http://localhost:3000/books')
  .then(x => x.data)

let fetchCategory = x => axios.get(`http://localhost:3000/categories/${x.categoryId}`)
  .then(x => x.data.value)
  .then(category => ({...x, category}))

let mounted = function() {
  fetchBooks
    .then(x => Promise.all(x.map(fetchCategory)))
    .then(x => this.books = x)
}

export default {
  name:'App',
  data:() => ({
    books:[]
  }),
  mounted
}
</script>

12 行

let fetchBooks = axios.get('http://localhost:3000/books')
  .then(x => x.data)

改用 Promise Chain 讀取 http://localhost:3000/books API。

15 行

let fetchCategory = x => axios.get(`http://localhost:3000/categories/${x.categoryId}`)
  .then(x => x.data.value)
  .then(category => ({...x, category}))

改用 Promise Chain 讀取 http://localost:3000/categories/:id API,並順便組成新的包含 category 的 Object。

20 行

fetchBooks
  .then(x => Promise.all(x.map(fetchCategory)))

x 為 Array,因此使用 map() 透過 fetchCategory() 取得包含 category 的新 Object,此為 Promise Array 剛好適合使用 Promise.all() 一次取得所有 Promise。

nested006

Promise.all()async await 可發現明顯差異,async await 會在 API 結束後才發起另外一次 API,但 Promise.all() 則近乎平行執行,因此速度較快。

22 行

.then(x => this.books = x)

最後一並寫入 books side effect。

這種寫法的致命傷是 Array 完全產生後才寫入 books,若第一個 API 回傳筆數多,或第二個 API 回傳資料大時,會明顯感覺到延遲一段時間才顯示

Promise.all()

Not Work

<template>
  <div>
    <ul v-for="(x, i) in books" :key="i">
      <li>{{ x.title }} / {{ x.price }} / {{ x.category }}</li>
    </ul>
  </div>
</template>

<script>
import axios from 'axios'

let fetchBooks = axios.get('http://localhost:3000/books')
  .then(x => x.data)

let fetchCategory = x => axios.get(`http://localhost:3000/categories/${x.categoryId}`)
  .then(x => x.data.value)

let mounted = async function() {
  fetchBooks
    .then(x => this.books = x)
    .then(x => Promise.all(x.map(fetchCategory)))
    .then(x => x.forEach((x, i) => this.books[i]['category'] = x))
}

export default {
  name:'App',
  data:() => ({
    books:[]
  }),
  mounted,
}
</script>

19 行

fetchBooks
  .then(x => this.books = x)

為了彌補前一種寫法在最後才一次寫入 side effect 的缺失,在 fetchBooks 抓到資料後馬上寫入 books data,如此所有 books 將率先顯示。

21 行

.then(x => Promise.all(x.map(fetchCategory)))

x 為 Array,使用了 map() 取得所有 category,因為結果為 Promise Array,因此最後套用了 Promise.all() 一次取得所有 Promise。

22 行

.then(x => x.forEach((x, i) => this.books[i]['category'] = x))

最後將 category 寫入 books data,直覺會使用 forEach() 搭配 = 寫入新建的 category property。

正如之前所述,Vue 並不支援 = 寫法的 Reactive Array。

splice()

<template>
  <div>
    <ul v-for="(x, i) in books" :key="i">
      <li>{{ x.title }} / {{ x.price }} / {{ x.category }}</li>
    </ul>
  </div>
</template>

<script>
import axios from 'axios'

let fetchBooks = axios.get('http://localhost:3000/books')
  .then(x => x.data)

let fetchCategory = x => axios.get(`http://localhost:3000/categories/${x.categoryId}`)
  .then(x => x.data.value)
  .then(category => ({...x, category}))

let mounted = async function() {
  fetchBooks
    .then(x => this.books = x)
    .then(x => Promise.all(x.map(fetchCategory))
    .then(x => x.forEach((x, i) => this.books.splice(i, 1, x)))
}

export default {
  name:'App',
  data:() => ({
    books:[]
  }),
  mounted,
}
</script>

15 行

let fetchCategory = x => axios.get(`http://localhost:3000/categories/${x.categoryId}`)
  .then(x => x.data.value)
  .then(category => ({...x, category}))

fetchCategory() 稍做手腳,直接將 category 湊成新 Object 回傳。

23 行

.then(x => x.forEach((x, i) => this.books.splice(i, 1, x)))

forEach() 內改用 splice() 即可支援 Reactive Array。

$set()

<template>
  <div>
    <ul v-for="(x, i) in books" :key="i">
      <li>{{ x.title }} / {{ x.price }} / {{ x.category }}</li>
    </ul>
  </div>
</template>

<script>
import axios from 'axios'

let fetchBooks = axios.get('http://localhost:3000/books')
  .then(x => x.data)

let fetchCategory = x => axios.get(`http://localhost:3000/categories/${x.categoryId}`)
  .then(x => x.data.value)
  .then(category => ({...x, category}))

let mounted = async function() {
  fetchBooks
    .then(x => this.books = x)
    .then(x => Promise.all(x.map(fetchCategory))
    .then(x => x.forEach((x, i) => this.$set(this.books, i, x)))
}

export default {
  name:'App',
  data:() => ({
    books:[]
  }),
  mounted,
}
</script>

23 行

.then(x => x.forEach((x, i) => this.$set(this.books, i, x)))

亦可使用更精簡的 this.$set() 支援 Reactive Array。

forEach()

Not Work

<template>
  <div>
    <ul v-for="(x, i) in books" :key="i">
      <li>{{ x.title }} / {{ x.price }} / {{ x.category }}</li>
    </ul>
  </div>
</template>

<script>
import axios from 'axios'

let fetchBooks = axios.get('http://localhost:3000/books')
  .then(x => x.data)

let fetchCategory = x => axios.get(`http://localhost:3000/categories/${x.categoryId}`)
  .then(x => x.data.value)
  .then(category => ({...x, category}))

let mounted = function() {
  fetchBooks
    .then(x => this.books = x)
    .then(x => x.map(fetchCategory))
    .then(x => x.forEach((x, i) => x.then(x => this.books[i] = x)))
}

export default {
  name:'App',
  data:() => ({
    books:[]
  }),
  mounted,
}
</script>

既然 books 已經先顯示,category 在背景以 asynchronous 慢慢呈現亦可接受。

20 行

fetchBooks
  .then(x => this.books = x)

依然在 fetchBooks 之後馬上寫入 books data,讓 books 能率先顯示。

22 行

.then(x => x.map(fetchCategory))

使用 map() 取得所有 category,雖然仍為 Promise Array,但為了不想讓 Promise.all() 近乎平行 API request,因此沒使用 Promise.all()

23 行

.then(x => x.forEach((x, i) => x.then(x => this.books[i] = x)))

x 為 Array,因此使用 forEach() 寫入,由於會使用 index 寫入 books data,別忘了 forEach() callback 的第二個 argument 及為 index。

但如之前所述,Vue 並不支援 = 寫法的 Reactive Array。

splice()

<template>
  <div>
    <ul v-for="(x, i) in books" :key="i">
      <li>{{ x.title }} / {{ x.price }} / {{ x.category }}</li>
    </ul>
  </div>
</template>

<script>
import axios from 'axios'

let fetchBooks = axios.get('http://localhost:3000/books')
  .then(x => x.data)

let fetchCategory = x => axios.get(`http://localhost:3000/categories/${x.categoryId}`)
  .then(x => x.data.value)
  .then(category => ({...x, category}))

let mounted = function() {
  fetchBooks
    .then(x => this.books = x)
    .then(x => x.map(fetchCategory))
    .then(x => x.forEach((x, i) => x.then(x => this.books.splice(i, 1, x))))
}

export default {
  name:'App',
  data:() => ({
    books:[]
  }),
  mounted,
}
</script>

15 行

let fetchCategory = x => axios.get(`http://localhost:3000/categories/${x.categoryId}`)
  .then(x => x.data.value)
  .then(category => ({...x, category}))

fetchCategory() 稍做手腳,直接將 category 湊成新 Object 回傳。

23 行

.then(x => x.forEach((x, i) => x.then(x => this.books.splice(i, 1, x))))

改成 splice() 即支援 Reactive Array。

$set()

<template>
  <div>
    <ul v-for="(x, i) in books" :key="i">
      <li>{{ x.title }} / {{ x.price }} / {{ x.category }}</li>
    </ul>
  </div>
</template>

<script>
import axios from 'axios'

let fetchBooks = axios.get('http://localhost:3000/books')
  .then(x => x.data)

let fetchCategory = x => axios.get(`http://localhost:3000/categories/${x.categoryId}`)
  .then(x => x.data.value)
  .then(category => ({...x, category}))

let mounted = function() {
  fetchBooks
    .then(x => this.books = x)
    .then(x => x.map(fetchCategory))
    .then(x => x.forEach((x, i) => x.then(x => this.$set(this.books,i, x))))
}

export default {
  name:'App',
  data:() => ({
    books:[]
  }),
  mounted,
}
</script>

23 行

.then(x => x.forEach((x, i) => x.then(x => this.$set(this.books,i, x))))

亦可改成 this.$set()即支援 Reactive Array。

RxJS

forkJoin()

<template>
  <div>
    <ul v-for="(x, i) in books" :key="i">
      <li>{{ x.title }} / {{ x.price }} / {{ x.category }} </li>
    </ul>
  </div>
</template>

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

let fetchBooks$ = ajax('http://localhost:3000/books').pipe(
  pluck('response')
)

let fetchCategory$ = x => ajax(`http://localhost:3000/categories/${x.categoryId}`).pipe(
  pluck('response', 'value'),
  map(category => ({...x, category}))
)

let mounted = function() {
  fetchBooks$.pipe(
    mergeMap(x => forkJoin(x.map(fetchCategory$)))
  ).subscribe(x => this.books = x)
}

export default {
  name:'App',
  data: () => ({
    books: []
  }),
  mounted
}
</script>

18 行

let fetchCategory$ = x => ajax(`http://localhost:3000/categories/${x.categoryId}`).pipe(
  pluck('response', 'value'),
  map(category => ({...x, category}))
)

fetchCategory$() 先把包含 category property 的 Object 準備好回傳。

24 行

fetchBooks$.pipe(
  flatMap(x => forkJoin(x.map(fetchCategory$)))
).subscribe(x => this.books = x)

map() 回傳回 Observable Array,故適合使用 forkJoin() 一次取得所有 Observable。

但因為 forkJoin() 回傳亦為 Observable 而造成 Observable of Observable,故使用 mergeMap() 攤平。

最後使用 subscribe() 寫入 side effect。

nested007

forkJoin() 本質就是 Observable 版的 Promise.all(),故 API request 也近乎平行。

splice()

<template>
  <div>
    <ul v-for="(x, i) in books" :key="i">
      <li>{{ x.title }} / {{ x.price }} / {{ x.category }} </li>
    </ul>
  </div>
</template>

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

let fetchBooks$ = ajax('http://localhost:3000/books').pipe(
  pluck('response')
)

let fetchCategory$ = (x, i) => ajax(`http://localhost:3000/categories/${x.categoryId}`).pipe(
  pluck('response', 'value'),
  map(category => ({...x, category, i}))
)

let mounted = function() {
  fetchBooks$.pipe(
    tap(x => this.books = x),
    mergeMap(x => x.map((x, i) => fetchCategory$(x, i))),
    tap(x => x.forEach(x => this.books.splice(x.i, 1, x)))
  ).subscribe()
}

export default {
  name:'App',
  data: () => ({
    books: []
  }),
  mounted
}
</script>

RxJS 是否也能先顯示 books,之後再慢慢顯示 category 呢 ?

23 行

fetchBooks$.pipe(
  tap(x => this.books = x),

使用 tap() 率先寫入 books data。

25 行

mergeMap(x => x.map((x, i) => fetchCategory$(x, i))),

一樣使用 mergeMap() 避免 Observable of Observable,但並沒有使用 forkJoin()

26 行

tap(x => x.forEach(x => this.books.splice(x.i, 1, x)))

使用 forEach()splice() 一一將 Observable 寫入 side effect。

$set()

<template>
  <div>
    <ul v-for="(x, i) in books" :key="i">
      <li>{{ x.title }} / {{ x.price }} / {{ x.category }} </li>
    </ul>
  </div>
</template>

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

let fetchBooks$ = ajax('http://localhost:3000/books').pipe(
  pluck('response')
)

let fetchCategory$ = (x, i) => ajax(`http://localhost:3000/categories/${x.categoryId}`).pipe(
  pluck('response', 'value'),
  map(category => ({...x, category, i}))
)

let mounted = function() {
  fetchBooks$.pipe(
    tap(x => this.books = x),
    mergeMap(x => x.map((x, i) => fetchCategory$(x, i))),
    tap(x => x.forEach(x => this.$set(this.books,x.i, x)))
  ).subscribe()
}

export default {
  name:'App',
  data: () => ({
    books: []
  }),
  mounted
}
</script>

26 行

tap(x => x.forEach(x => this.$set(this.books,x.i, x)))

使用 forEach()this.$set 一一將 Observable 寫入 side effect。

Conclusion

  • 示範了這麼多種寫法,大多數人應該最能接受 async await 方式,然其 await 特性使的前一個 API request 必須完成後,才能執行下一個 API request,因此效能較差
  • async await 可搭配 Reactive Array 寫法讓第一個 API request 結果先顯示,如此視覺效果較佳
  • Promise.all()forkJoin() 可讓 API request 以近乎平行方式呼叫,無論 Promise Chain 或 RxJS 寫法都相當精妙
  • Vue 的 v-for 有條件支援 Reactive Array,但須改用 splice()this.$set(),不支援 = 寫法

Reference

Gabriel Lebec, When Nesting Promises is Correct
Vue, Reactivity in Depth for Arrays
Vue, Array Change Detection