點燈坊

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

如何正確處理 Nested Promise ?

Sam Xiao's Avatar 2020-04-29

有了 Promise 後應盡量避免使用 Nested Callback,但並不表示 Nested Promise 也不好,有些需求剛好適合 Nested Promise。

Version

macOS Catalina 10.15.4
VS Code 1.44.0
Quokka 1.0.285
ECMAScript 2017

Nested Callback

let categories = [
  { id: 1, value: 'FP' },
  { id: 2, value: 'FRP' },
  { id: 3, value: 'JS' }
]

let fetchCategory = async id => categories.find(x => x.id === id)

let fetchBook = async _ => (
  { title: 'FP in JavaScript',
    price: 100,
    categoryId: 1 })

let f = _ => fetchBook()
  .then(x => fetchCategory(x.categoryId)
              .then(x => x.value))
  
f() // ?  

第 9 行

let fetchBook = async _ => (
  { title: 'FP in JavaScript',
    price: 100,
    categoryId: 1 })

模擬 RESTful API 只回傳 categoryId

第 7 行

let fetchCategory = async id => categories.find(x => x.id === id)

若要取得 category,還要呼叫另外一個 API。

14 行

let f = _ => fetchBook()
  .then(x => fetchCategory(x.categoryId)
              .then(x => x.value))

由於 then() 也是傳入 function,初學者常把 Promise 當 callback 使用,而寫成 nested callback。

promise000

Flatten Promise

let categories = [
  { id: 1, value: 'FP' },
  { id: 2, value: 'FRP' },
  { id: 3, value: 'JS' }
]

let fetchCategory = async id => categories.find(x => x.id === id)

let fetchBook = async _ => (
  { title: 'FP in JavaScript',
    price: 100,
    categoryId: 1 })

let f = _ => fetchBook()
  .then(x => fetchCategory(x.categoryId))
  .then(x => x.value)
              
f() // ?  

14 行

let f = _ => fetchBook()
  .then(x => fetchCategory(x.categoryId))
  .then(x => x.value)

fetchCategory() 回傳為 Promise,經過 then() 回傳後理論上會再包一層 Promise,也就是 nested promise,但 Promise 具有 Monad 特性,而 then() 也類似 chain(),可將 nested promise 攤平成一層 Promise,因此不必再使用 nested callback 處理這類 dependent API calls。

promise001

Async Await

let categories = [
  { id: 1, value: 'FP' },
  { id: 2, value: 'FRP' },
  { id: 3, value: 'JS' }
]

let fetchCategory = async id => categories.find(x => x.id === id)

let fetchBook = async _ => (
  { title: 'FP in JavaScript',
    price: 100,
    categoryId: 1 })

let f = async _ => {
  let book = await fetchBook()
  let category = await fetchCategory(book.categoryId)
  return category.value
}
              
f() // ?  

14 行

let f = async _ => {
  let book = await fetchBook()
  let category = await fetchCategory(book.categoryId)
  return category.value
}

ES2017 支援 async await 後有新寫法,對於回傳 Promise 的 function 可加上 await,它讓每個 Promise 都有 variable 對應,得以使用 sychronous imperative 風格實現 asynchronous,有別於 promise chain 以 function pipeline 實現。

promise002

Promise.all()

let categories = [
  { id: 1, value: 'FP' },
  { id: 2, value: 'FRP' },
  { id: 3, value: 'JS' }
]

let fetchCategory = async id => categories.find(x => x.id === id)

let fetchBook = async _ => (
  { title: 'FP in JavaScript',
    price: 100,
    categoryId: 1 })

let f = _ => fetchBook()
  .then(x => Promise.all([x, fetchCategory(x.categoryId)]))
  .then(([x, y]) => [x.title, y.value])

f() // ?  

若需求改變,我們不只希望回傳 category 而已,而是同時回傳 titlecategory

14 行

let f = _ => fetchBook()
  .then(x => Promise.all([x, fetchCategory(x.categoryId)]))
  .then(([x, y]) => [x.title, y.value])

title 來自於 fetchBook(),而 category 來自於 fetchCategory(),這對 function pipeline 就比較尷尬,因為橫跨在不同 then() 的 callback 內,彼此看不到對方。

實務上會使用 Promise.all() 將 synchronous 與 asynchronous 包起來傳到下一個 then(),由於 then()chain() 特性,下一個 then() 會將兩層 Promise 攤平並收到兩個 sychronous 值,可用 then()map() 使用整理成 array 回傳。

promise003

Side Effect

let categories = [
  { id: 1, value: 'FP' },
  { id: 2, value: 'FRP' },
  { id: 3, value: 'JS' }
]

let fetchCategory = async id => categories.find(x => x.id === id)

let fetchBook = async _ => (
  { title: 'FP in JavaScript',
    price: 100,
    categoryId: 1 })

let title = ''

let f = _ => fetchBook()
  .then(x => {
    title = x.title
    return fetchCategory(x.categoryId)
  })
  .then(x => [title, x.value])

f() // ?  

14 行

let title = ''

let f = _ => fetchBook()
  .then(x => {
    title = x.title
    return fetchCategory(x.categoryId)
  })
  .then(x => [title, x.value])

另一個方法是簡單使用 side effect 簡單存放 title,如此最後一個 then() 也能讀取到 title

如 Vue 就是以 data 儲存 side effect,因此也可將資料先存放在 data,如此整個 promise chain 都能讀取

promise004

Nested Promise

let categories = [
  { id: 1, value: 'FP' },
  { id: 2, value: 'FRP' },
  { id: 3, value: 'JS' }
]

let fetchCategory = async id => categories.find(x => x.id === id)

let fetchBook = async _ => (
  { title: 'FP in JavaScript',
    price: 100,
    categoryId: 1 })

let f = _ => fetchBook()
  .then(x => fetchCategory(x.categoryId)
    .then(y => [x.title, y.value]))

f() // ?    

14 行

let f = _ => fetchBook()
  .then(x => fetchCategory(x.categoryId)
    .then(y => [x.title, y.value]))

另一個直覺方法是使用 nested promise,由於 fetchCategory() 的 callback 在內層,因此可輕易讀取到外層 callback 的 x.title

promise005

let categories = [
  { id: 1, value: 'FP' },
  { id: 2, value: 'FRP' },
  { id: 3, value: 'JS' }
]

let fetchCategory = async id => categories.find(x => x.id === id)

let fetchBook = async _ => (
  { title: 'FP in JavaScript',
    price: 100,
    categoryId: 1 })

let f = _ => fetchBook()
  .then(x => fetchCategory(x.categoryId)
  .then(y => [x.title, y.value]))

f() // ?    

14 行

let f = _ => fetchBook()
  .then(x => fetchCategory(x.categoryId)
  .then(y => [x.title, y.value]))

Nested promise 看似有 callback hell 味道,其實只要在排版稍動手腳,看起來 then() 也是攤平的,這也是本文所要強調的:nested promise 本身並不邪惡,如本文需求使用 nested promise 就遠本 Promise.all() 簡單易懂。

promise006

Async Await

let categories = [
  { id: 1, value: 'FP' },
  { id: 2, value: 'FRP' },
  { id: 3, value: 'JS' }
]

let fetchCategory = async id =>
  categories.find(x => x.id === id)

let fetchBook = async _ => (
  { title: 'FP in JavaScript',
    price: 100,
    categoryId: 1 })

let f = async _ => {
  let x = await fetchBook()
  let y = await fetchCategory(x.categoryId)
  return [x.title, y.value]
}
  
f() // ?

15 行

let f = async _ => {
  let x = await fetchBook()
  let y = await fetchCategory(x.categoryId)
  return [x.title, y.value]
}

由於 await 會保留每個 then() 的 callback 所回傳 Promise,因此可輕易得到 x.title

若要說 async await 的優點之一,就是其保留每個 callback 所回傳 Promise,因此後續 expression 可隨時取用,不像 promise chain 的每個 callback 都有自己的 scope,因此要跨 callback 互相存取就很難,所以才必須靠 Promise.all() 這種小技巧

promise007

Conclusion

  • 若想將前一個 then() 內的值帶到下一個 then() 中,使用 Promise.all() 是 promise chain 慣用手法
  • 由於 then() 兼具 chain()map() 特性,因此 nested promise 並沒有像 nested callback 那樣邪惡,甚至只要透過排版小技巧,nested promise 的可讀性也很高
  • 若有 nested promise 需求時,也可考慮使用 async await,由於 await 使每個 Promise 都留下 variable,因此特殊需求下時很方便

Reference

Gabriel Lebec, When Nesting Promise is Correct