Minghsin University of Science and Technology
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try a Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

1

Local AI

--

Uedu Code

--

明新科大 AQI 70 24°C PM2.5 19
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 24

Promise

非同步、callback hell 與 then、catch、Promise.all
Interaction實作 25 minute(s)

Learning Objectives

  • 分辨同步與非同步執行,說明 JavaScript 單一執行緒為什麼不會被等待擋住
  • 說明回調函式(callback)的用途,以及 callback 一層包一層造成的回調地獄
  • 說明 Promise 的三種狀態,用 new Promise、resolve、reject 建立 Promise
  • 用 then 串接、catch 處理錯誤、finally 收尾,並用 Promise.all、allSettled、race 處理多個 Promise

同步與非同步

JavaScript 是單一執行緒(single thread):同一個時間只做一件事。如果每件事都要等前一件做完, 等網路回應的那一兩秒,整個網頁就會卡住,按鈕按了沒反應。

執行方式 意思 生活中的例子
同步(synchronous) 前一個動作完成,才執行下一個 排隊結帳:前面的人結完帳才輪到你
非同步(asynchronous) 需要等待的動作先交出去,繼續做後面的事,等它完成再回來處理 點餐後拿呼叫器去找位子,餐點好了呼叫器才響

非同步不是多執行緒:JavaScript 自己仍然一次只做一件事。等待的工作(計時、網路請求)交給瀏覽器處理, 時間到了或資料回來了,瀏覽器再把要執行的函式排進佇列,等 JavaScript 手上的事情做完才執行。

下面的例子先設定兩個計時器,但「先去找位子」會先印出來。

setTimeout 不會擋住後面的程式

注意執行到第 4 行時只是設定計時器,箭頭函式並沒有馬上執行;第 12 行印完之後,兩個計時器的函式才依時間順序被呼叫。(動畫裡的時間是虛擬的,不會真的等 2 秒。)

回調函式與回調地獄

回調函式(callback function)是「當作參數傳給另一個函式,在未來某個時間點才被呼叫的函式」。你已經用過很多次:

  • setTimeout(callback, 1000)、setInterval(callback, 1000):時間到了呼叫
  • button.addEventListener('click', callback):使用者點擊時呼叫
  • [1, 2, 3].forEach(callback):這個是同步的 callback,forEach 結束前就全部呼叫完了

非同步的動作常常要依序完成:先煮水,水開了才泡茶,泡好了才倒進杯子。用 callback 寫,下一步只能寫在上一步的 callback 裡面, 於是一層包一層,變成回調地獄(callback hell):

  • 巢狀越來越深,程式往右縮排,很難閱讀與修改
  • 每一層都要自己處理錯誤,很容易漏掉
  • 把 callback 交給別人的函式,就只能相信它「會呼叫、只呼叫一次、在對的時間呼叫」

三層 callback:煮水、泡茶、倒進杯子

看右邊的呼叫堆疊:每一步完成時呼叫 callback(),才在 callback 裡開始下一步。開始準備 最先印出,因為三個步驟都是非同步的。想像一下要再多加五個步驟,這段程式會往右縮排到哪裡。

Promise:代表「未來才會有結果」的物件

Promise(ES2015 加入)是一個物件,代表一個非同步動作最終會成功或失敗。它有三種狀態:

狀態 意思
pending(等待中) 一開始的狀態,結果還沒出來
fulfilled(已實現) 動作成功,帶著一個結果值
rejected(已拒絕) 動作失敗,帶著一個錯誤(原因)

fulfilled 與 rejected 合稱 settled(已確定)。狀態只會從 pending 變成其中一種,一旦改變就不會再變。

建立 Promise 時,傳一個函式給 new Promise(),這個函式會收到 resolve 與 reject 兩個參數:

const order = new Promise((resolve, reject) => {
  // 做需要等待的事……
  resolve('珍珠奶茶');          // 成功:狀態變成 fulfilled,結果是 '珍珠奶茶'
  // reject(new Error('賣完了'));  // 失敗:狀態變成 rejected
});

這裡用的 (參數) => { … } 是箭頭函式:只有一個參數時可以省略括號(drink => …),本體只有一個運算式時可以省略大括號與 return(x => x * 2)。 寫 Promise 時幾乎都用箭頭函式,程式比較短。

看 Promise 的狀態改變

右邊的 order 與 soldOut 會顯示 [[PromiseState]]:一開始都是 pending。500 ms 時 soldOut 變成 rejected,1000 ms 時 order 變成 fulfilled。第 5 行的 reject 在 resolve 之後才呼叫,沒有任何作用:狀態已經確定了。

then 串接:把巢狀拉平

promise.then(函式) 登記「成功之後要做什麼」,函式會收到結果值。重點是:then 會回傳一個新的 Promise,所以可以一直接下去:

  • then 裡的函式回傳一個值,下一個 then 就收到這個值
  • then 裡的函式回傳一個 Promise,下一個 then 會等它完成,再收到它的結果

把「會等待的動作」寫成回傳 Promise 的函式,回調地獄就變成一串由上往下讀的 then:

step('煮水', 300)
  .then(() => step('泡茶', 200))
  .then(() => step('倒進杯子', 100));

這是 Promise 最大的優點:程式結構變得扁平,而且狀態只會確定一次、結果不會被改掉,比把 callback 交給別人更可靠。

用 then 改寫三個步驟

和前面的回調地獄做的事一樣,但每一步都是同一層的 .then。注意第 3 行:step 回傳一個 pending 的 Promise;計時器時間到、呼叫 resolve(name) 之後,下一個 then 才會執行。

動畫:Promise 與 setTimeout 誰先執行

程式依序印 A、設定 0 ms 的計時器印 B、用已完成的 Promise 的 then 印 C、最後印 D。先猜猜看輸出順序,再一步一步按到最後。看兩個佇列:then 的 callback 進的是微任務佇列(microtask queue),計時器進的是工作佇列(task queue);同步程式跑完後,會先清空微任務佇列。

catch 與 finally:錯誤處理

  • promise.catch(函式):Promise 被拒絕時呼叫,函式收到錯誤。串在鏈的最後,前面任何一步 reject 或在 then 裡丟出錯誤(throw),都會直接跳到 catch,中間的 then 會被略過。
  • promise.finally(函式)(ES2018):不論成功或失敗都會呼叫,適合放「收尾」的程式,例如把「載入中…」的提示關掉。它的函式不會收到結果值。
  • catch 處理完錯誤、回傳一個值之後,鏈會恢復正常:後面的 then 收到這個值。
checkStock('布丁')
  .then((count) => …)          // 失敗時這一步被略過
  .catch((err) => …)           // 前面任何一步失敗都會到這裡
  .finally(() => …);           // 一定會執行

一個 Promise 被拒絕卻沒有任何 catch 接住,主控台會出現 Uncaught (in promise) 錯誤。建議每一條 Promise 鏈最後都接一個 catch。

reject 之後跳到 catch,finally 一定執行

查的是「布丁」,所以第 6 行會 reject。播放時注意:第 12 行的 then 完全沒有執行,直接跳到第 16 行的 catch,最後是 finally。

同時處理多個 Promise

幾件互不相關的工作可以同時開始,再一起等結果。Promise 提供幾個靜態方法,參數都是一個 Promise 陣列:

方法 什麼時候完成 結果
Promise.all([…]) 全部成功才 fulfilled;任何一個失敗就立刻 rejected 依原本順序排列的結果陣列
Promise.allSettled([…])(ES2020) 全部都確定(成功或失敗)才完成,不會 rejected 每一項是 {status: 'fulfilled', value} 或 {status: 'rejected', reason}
Promise.race([…]) 第一個確定的 Promise 決定結果 最快那一個的結果或錯誤
Promise.any([…])(ES2021) 第一個成功的;全部失敗才 rejected 最快成功的那一個結果

Promise.all 的結果陣列順序和傳進去的順序一樣,跟誰先完成無關。

all、allSettled 與 race

wait(ms, value, ok) 在 ms 毫秒後成功(或在 ok 是 false 時失敗)。先看輸出區的順序:all 失敗 在 100 ms 就出現,因為有一個失敗就不用再等;all 的結果最晚,因為要等 300 ms 那一個。all:['天氣', '新聞'] 的順序和陣列順序相同,雖然「新聞」比較早完成。

想一想:非同步與回調

1.

下面的程式最後印出什麼?

const out = [];
out.push('A');
setTimeout(() => {
  out.push('B');
  console.log(out.join(' '));
}, 0);
Promise.resolve().then(() => out.push('C'));
out.push('D');
2.

JavaScript 是單一執行緒,為什麼 setTimeout(fn, 3000) 在等 3 秒的時候,網頁還能回應點擊?

3.

造成回調地獄(callback hell)的原因是什麼?

第 1 次作答

想一想:Promise

1.

下面的程式會印出什麼?

const p = new Promise((resolve, reject) => {
  resolve('第一次');
  resolve('第二次');
  reject(new Error('失敗'));
});
p.then((v) => console.log(v))
  .catch((e) => console.log('catch', e.message));
2.

下面的程式會印出什麼?

Promise.reject(new Error('x'))
  .then(() => console.log('A'))
  .catch(() => 'B')
  .then((v) => console.log(v));
3.

下面的程式會印出什麼?

const ok = (v, ms) => new Promise((r) => setTimeout(() => r(v), ms));
const bad = (ms) => new Promise((_, j) => setTimeout(() => j(new Error('壞了')), ms));
Promise.all([ok(1, 100), bad(50), ok(3, 10)])
  .then((vs) => console.log(vs))
  .catch((e) => console.log(e.message));
4.

一個 Promise 已經是 rejected,可以把它改成 fulfilled 嗎?

第 1 次作答

實作:寫一個會等待的 delay 函式

完成 delay(ms, value):回傳一個 Promise,在 ms 毫秒後以 value 完成(fulfilled)。再把第二站接在第一個 then 後面,讓輸出依序是 第一站、第二站、抵達終點。

你的輸出
預期輸出

重點精華回顧

  • JavaScript 是單一執行緒;非同步的動作(計時器、網路請求)交給瀏覽器等待,完成後 callback 才排進佇列執行,不會擋住後面的程式。
  • 回調函式是傳給另一個函式、之後才被呼叫的函式;依序的非同步動作用 callback 一層包一層,就成了回調地獄。
  • Promise 代表未來的結果,狀態是 pending → fulfilled 或 rejected,確定之後就不會再變;用 new Promise((resolve, reject) => { … }) 建立。
  • then 回傳新的 Promise,可以一直串接;回傳 Promise 時下一個 then 會等它完成,程式結構變得扁平。
  • catch 接住鏈中任何一步的錯誤,finally 不論成敗都執行;每一條 Promise 鏈最後都接一個 catch。
  • Promise.all 全部成功才成功、一個失敗就失敗;allSettled 等全部確定;race 看第一個確定的。
  • 箭頭函式讓 callback 寫得更簡潔:(a, b) => a + b。
這一課完成了!
Next lesson:async 與 await
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。