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 才會執行。
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:['天氣', '新聞'] 的順序和陣列順序相同,雖然「新聞」比較早完成。
想一想:非同步與回調
下面的程式最後印出什麼?
const out = [];
out.push('A');
setTimeout(() => {
out.push('B');
console.log(out.join(' '));
}, 0);
Promise.resolve().then(() => out.push('C'));
out.push('D');
JavaScript 是單一執行緒,為什麼 setTimeout(fn, 3000) 在等 3 秒的時候,網頁還能回應點擊?
造成回調地獄(callback hell)的原因是什麼?
想一想:Promise
下面的程式會印出什麼?
const p = new Promise((resolve, reject) => {
resolve('第一次');
resolve('第二次');
reject(new Error('失敗'));
});
p.then((v) => console.log(v))
.catch((e) => console.log('catch', e.message));
下面的程式會印出什麼?
Promise.reject(new Error('x'))
.then(() => console.log('A'))
.catch(() => 'B')
.then((v) => console.log(v));
下面的程式會印出什麼?
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));
一個 Promise 已經是 rejected,可以把它改成 fulfilled 嗎?
實作:寫一個會等待的 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。