若 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
FP
、FRP
與 JS
並不是從單一 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/:id
由 categoryId
取得 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。
這種寫法主要問題在於 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
。
一切都很直覺,但結果卻不如預期,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:
- When you directly set an item with the index, e.g.
vm.items[indexOfItem] = newValue
- 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 更新。
雖然 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。
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。
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