廢話前言

突然發覺我每篇都習慣有個前言,感覺有點冗,瀏覽了下其他文章的前言,內容都在解釋為什麼有這篇文章,其實也滿符合 WHY 的原則吧哈哈,所以不免俗的再來個前言吧

工作的關係最近半年開始接觸前端,在開發的過程中自然而然碰到了非同步的問題,避免自己日後忘記跟又換開發領域,跳轉來跳轉去的導致領域知識零碎,發一篇文章好好紀錄關於 JavaScript/TypeScript 中幫助開發者處理非同步情形的 Promise & async & await 的機制,是如何運作的

同步與非同步

先來講講在程式執行中什麼是同步跟非同步執行,其實這個在中文的定義裡滿容易搞混的,以前聽到這兩個名詞也混淆了一下,以為同步的意思平行執行,非同步就是照順序,但事實上定義是反過來

同步

同步 (synchronous) 在程式執行中的意思是,照程式碼的順序執行

可以想成這些程式碼在同個跑道上進行接力賽,所以叫做同步執行,只能一個接一個跑下去

非同步

也有人稱做「異步」

非同步 (asynchronous) 在程式執行中的意思是,不是按照程式碼的順序執行

可以想成這些程式碼不在同個跑道上進行賽跑,所以叫做非同步執行,可以一個在跑另一個也在跑

JS 程式執行基本認識

在 JavaScript 這個語言中常常會有非同步 (asynchronous) 的情形,然而 JavaScript 是 Single Threaded 的 programming language,所以只會有一個 call stack & memory heap,執行程式的時候會等待上一個程式區段完成才會執行下一個,這樣對於網頁來說不是件好事,如果網頁正在執行某個需要做 fetch 程式片段,而導致整個網頁都動彈不得,對使用者體驗來說會很糟糕

那到底 JavaScript 是如何在網頁(瀏覽器)中實現非同步的執行的?

Google 在 2008 年推出的開源軟體 V8 engine 給了 Solution:

透過瀏覽器自己的 Web API 提供一個 Queue 在背景來執行這些需要等待的程式片段,完成後再 push 回 main thread 的 call stack

其他瀏覽器也有自己的 JavaScript engine,像是 Apple 開發給 Safari 用的開源軟體 JavaScriptCore,或是 Microsoft 給 Edge 用的 ChakraCore 等等,都是運用同樣的概念讓 JS 能實現非同步的行為

這裡有個 JSConf 的 YouTube 影片 對於 JS 的 Call Stack 怎麼運作講得很好,聽完會對於 JS 跟 Browser Web API 是如何處理網頁的執行有個基本認識

Concurrent vs Parallel

又是一個在中文上容易混淆的 CS 名詞,Concurrent 的翻譯叫做「同時」,而 Parallel 叫做「平行」,乍看之下根本一樣,但事實上這兩個名詞分別代表兩個完成任務的方式

Concurrent

指的是同一時間只做一件任務,在多個任務需要做的情況下,可以想成一個人同時開始做了好幾個不同的任務,在一個時間內完成了很多任務,但事實上只是在各個任務間切換來切換去 (task switching)

就想成自己同時間打開了數學和英文作業,但只能用一隻手寫字,所以只是寫了點數學再換寫英文

Parallel

指的是同一時間做好幾件任務,在多個任務需要做的情況下,可以想成有多個人分別同時開始做了不同的任務

就是和同學兩個人同時打開數學和英文作業,兩人分工各自寫數學和英文

而 JS 在完成任務上是屬於 Concurrency 的語言

不同於 C++ 或是 Java 的 Concurrency 指的是將不同任務切成好幾個 Process or Thread,輪流跑在一個 CPU 上來完成任務

而是前面提過的

Call stack 與 WebAPI 的配合,讓 JS 不會因為一個任務還沒完成就不能執行下一個任務,所以可以 Concurrently 的進行這些任務,在不同任務間切換來切換去

Callback func

如同前面提到,Web API 會協助先暫時在背景執行需要時間的程式,讓 JS 能繼續往下執行,而 Callback function 就是一個例子,把函式當作另一個函式的參數,透過另一個函式來呼叫他

先來看一個簡單的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// taskA & taskB function call another function
taskA(function() {
console.log("callback A")

taskB(function() {
console.log("callback B");
});

console.log("End A")
});

taskC();

// 執行順序:taskA() -> taskC() -> "callback A" -> taskB() -> "End A" -> "callback B";

常見的 window.setTimeout 也是一個經典例子,他的目的是隔了某段時間後,要執行某件事情

也就是 counter 結束後就會回頭執行包在 setTimeout 內的程式碼

1
2
3
4
5
6
7
window.setTimeout(function taskA(){
// 過一秒後執行
}, 1000);

taskB();

// 執行順序:taskB() -> taskA()

Callback hell

如果 Callback function 在 JS 中大量地被使用,除了容易使開發者混淆順序不易維護程式碼之外,多巢結構也會使程式碼的可讀性降低,回調的時間點也都不一致,當 Callback 依賴於另一個 Callback 的 chain 不斷發生時就會很難處理錯誤情況,所以就有了 Callback hell 的戲稱,如下常見的圖所示

Event listener

跟 DOM (Document Object Model) 互動的 API

  • addEventListener
  • removeEventListener
  • dispatchEvent

延伸閱讀: What is HTML DOM - w3school

最典型的例子就是在網頁上各類按鈕觸發的事件,或是滑鼠鍵盤的事件,都是一種接收的 event,但不會使網頁當掉,收到特定的訊號就做特定的事情

監聽 Click 事件例子

1
2
3
4
5
6
7
const button = document.querySelector("button");
button?.addEventListener("click", buttonClicked);

function buttonClicked(this: HTMLElement) {
console.log("Clicked!");
this.removeEventListener("click", buttonClicked);
}

然而 Event Listener 對於很多情況還是不夠適用,如果我們想要等特定的事件結束後才做另外一件事,需要有通知告訴我們特定的件事已經結束

比如 loading image 的例子

1
2
3
4
5
6
7
8
9
var img1 = document.querySelector('.img-1');

img1.addEventListener('load', function() {
// woo yey image loaded
});

img1.addEventListener('error', function() {
// argh everything's broken
});

看起來好像我們可以在 image 載入後做事,但實際上程式執行時,有可能在我們開始監聽事件之前就已經載好 image

由此可見 event listener 沒有辦法 cover 所有需求,尤其是網頁通常會有向遠端伺服器載入資料的要求,有時候需要等載入特定資料後才能做其他相關的事情

Promise

Promise pattern 的設計是用來對付非同步的情況,將非同步的程式片段寫成像是同步,而它的參數就是一組 callback function,可以回傳不同狀態回來

Return state

  • fulfilled - The action relating to the promise succeeded
  • rejected - The action relating to the promise failed
  • pending - Hasn’t fulfilled or rejected yet
  • settled - Has fulfilled or rejected

以下我們直接以程式碼的範例來 go through Promise 怎麼運作跟使用

Resolving object

當 Promise 成功執行完需要的任務就會 call resolve() 回傳,then 的作用就是接收 Promise 任務完成後 callback 的結果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
console.log('start async task');

let promiseExample = new Promise((resolve, reject) => {
setTimeout(() => {
console.log('async task is done!');
resolve('success, call back.');
}, 1000);
});

console.log('executing');

promiseExample.then(result => {
console.log(result);
});

會印出

1
2
3
4
start async task
executing
async task is done!
success, call back.

Error handling

可以利用 reject callback 來處理 error,在 then 接收時搭配 catch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
console.log('start async task');

let promiseExample = new Promise((resolve, reject) => {
setTimeout(() => {
console.log('async task is done!');
reject('reject, call back.');
}, 1000);
});

console.log('executing');

promiseExample.then(result => {
console.log(result);
})
.catch(error => {
console.log(error);
});

會印出

1
2
3
4
start async task
executing
async task is done!
reject, call back.

Chaining

Promise.then() 會 return 一個 Promise,所以一個 .then 的後面,可以再串接更多的 .then 做回傳,可以讓回傳的資料型態透過包在 Promise 內而有所變化

1
2
3
4
5
6
7
8
9
10
11
12
13
let promise = new Promise((resolve, reject) => {
resolve(1);
});
// transform value by returning a new one
promise.then(val => {
console.log(val); // 1
return val + 2;
}).then(val => {
console.log(val); // 3
return val * 2;
}).then(val => {
console.log(val); // 6
});

會印出

1
2
3
1
3
6

Error handle with chaining

Promise rejections skip forward to the next then() with a rejection callback (or catch(), since it’s equivalent)

  • then(func1, func2)
    • func1func2 會被呼叫,或是都不會被呼叫
  • then(func1).catch(func2)
    • 如果 func1 rejects,func1func2 都會被呼叫

看以下例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
asyncThing1().then(function() {
return asyncThing2();
}).then(function() {
return asyncThing3();
}).catch(function(err) {
return asyncRecovery1();
}).then(function() {
return asyncThing4();
}, function(err) {
return asyncRecovery2();
}).catch(function(err) {
console.log("Don't worry about it");
}).then(function() {
console.log("All done!");
})

Flow chart

  • Green line: fulfilled
  • Red line: rejected

Exception

Promise 的 rejected status 會吃 thrown Error,即便沒有特別寫 reject() 做 callback

1
2
3
4
5
6
7
8
9
10
11
12
13
let jsonPromise = new Promise((resolve, reject) => {
// JSON.parse throws an error if you feed it some
// invalid JSON, so this implicitly rejects:
resolve(JSON.parse("This ain't JSON"));
});

jsonPromise.then(data => {
// This never happens:
console.log("It worked!", data);
}).catch(err => {
// Instead, this happens:
console.log("It failed!", err);
})

會印出

1
2
3
4
5
6
It failed! SyntaxError: Unexpected token T in JSON at position 0
at JSON.parse (<anonymous>)
at eval (eval at <anonymous> (runtime.ts:68), <anonymous>:5:18)
at new Promise (<anonymous>)
at eval (eval at <anonymous> (runtime.ts:68), <anonymous>:2:19)
at runtime.ts:68

更多深入細節可以參考這個 Google 的官方文件

Promise all

Aggregating multiple Promise result. It is typically used after having started multiple asynchronous tasks to run concurrently and having created promises for their results,

如果我們今天想要讓一些任務全部都結束後才開始做事,要使用一個一個 Promise 然後不斷的 return 再 .then 會很崩潰

於是就有了 Promise.all(),可以等指定的任務們都執行完成再統一收集結果,而 Promise.all() 的回傳值也是一個 Promise

目的就是等待所有任務完成(不管任務完成的先後順序),才開始做事

這邊示範一個聖誕派對的範例,等待朋友們給我禮物XD

朋友們基本上都不會準時,大家都會在不同時間抵達

而聖誕派對的規則是要確認所有朋友都抵達後才能開始交換所有禮物

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
function friend1(gift: string) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('friend 1 is delivered.');
resolve(gift);
}, 4000);
});
}
function friend2(gift: string) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('friend 2 is delivered.');
resolve(gift);
}, 2000);
});
}
function friend3(gift: string) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('friend 3 is delivered.');
resolve(gift);
}, 3000);
});
}
function friend4(gift: string) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('friend 4 is delivered.');
resolve(gift);
}, 1000);
});
}

let xmasPartyGifts = Promise.all([friend1('apple'),friend2('banana'), friend3('car'), friend4('disney')]);
xmasPartyGifts.then(res => {
// print after 4 seconds\
console.log(res);
console.log('Xmas Party Start!');
})
.catch(err => {
console.log(err);
console.log('Xmas Party Failed!');
})

會印出

1
2
3
4
5
6
friend 4 is delivered.
friend 2 is delivered.
friend 3 is delivered.
friend 1 is delivered.
["apple", "banana", "car", "disney"]
Xmas Party Start!

Fail-fast behavior

需要注意的是 Promise.all() 的其中一個 Promise 被 rejected 的話,即便其他 Promise 正常執行, Promise.all() 也會被rejected,我們可以使用 catch 來處理錯誤發生時的行為

把上面聖誕派對的範例程式其中的一小段改成

1
2
3
4
5
6
7
8
function friend3(gift: string) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('friend 3 is not delivered.');
reject('friend 3 does not have money for gift.');
}, 3000);
});
}

Friend 3 雷隊友沒有錢買禮物,沒有上繳禮物

就算其他人有上繳禮物,整個聖誕派對的交換禮物還是失敗了

會印出

1
2
3
4
5
6
friend 4 is delivered.
friend 2 is delivered.
friend 3 is not delivered.
friend 3 does not have money for gift.
Xmas Party Failed!
friend 1 is delivered.

Promise 看起來是能解決 Callback hell 的好東西,但如果 Promise chaining 太多的話也是另一個 hell,於是 JavaScript 推出了 async & await 來搭配 Promise,讓一堆非同步的程式碼看起來像同步的同時還能提升可讀性,所以 async & await 可以說是 Promise 的 syntax sugar

async & await

在一個 function 前面加上 async keyboard 就能使用 await ,而只有 Promise 物件能使用 await keyword,await 也只能在 async function 內被使用

async function 會確保在裡面的所有 Promise 任務都完成,當 async function 中的任務全都結束後,會返回一個 Promise,所以也可以用 then 來接收 async function 的 callback 狀態

如果該 async 函式回傳了一個值,視為 Promise 的 resolved,如果 async 函式拋出例外或某個值,則會視為 Promise 的 rejected

簡單的範例

1
2
3
4
5
6
const foo = async () => {
return 1; // will be wrapped in Promise
}
foo().then(res => {
console.log(res); // print 1
});

await keyword

在 async function 內加上 await keyword 的功用就是暫停,會等待當前 Promise 完成後才進行下一個 Promise

假設 runner1 需要花 4 秒,runner2 需要花 2 秒

加上 async & await,runner 2 會等待 runner 1 結束任務後才開始

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function runner1(money: number) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('runner 1 is finished.');
resolve(money);
}, 4000);
});
}

function runner2(money: number) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('runner 2 is finished.');
resolve(money);
}, 2000);
});
}
async function asyncRunning(): Promise<number> {
const firstRunner = await runner1(100);
const secondRunner = await runner2(200);
return firstRunner + secondRunner; // print after 6 seconds
}
console.time('asyncRunning');
asyncRunning().then(res => {
console.log(res);
console.timeEnd('asyncRunning');
});

會印出

1
2
3
4
runner 1 is finished.
runner 2 is finished.
300
asyncRunning: 6010.595947265625ms

有趣的是如果我們把 await keyword 放在 return,結果會不同

1
2
3
4
5
async function asyncRunning(): Promise<number> {
const firstRunner = runner1(100);
const secondRunner = runner2(200);
return await firstRunner + await secondRunner; // print after 4 seconds
}

會印出

1
2
3
4
runner 2 is finished.
runner 1 is finished.
300
asyncRunning: 4002.6240234375ms

上面小改後的範例看起來像是平行運行,但其實並不是,如同前面說過的,在這個例子裡,runner1 和 runner2 的計時器同時被建立起來,但他在背後是連續執行(Concurrent) 不是平行運行 (Parallel)

後記

介紹到這邊應該差不多了,如果想要更深入了解可以直接去看看源碼或是多翻一些影片來看實例,我本身得靠實際遇到了才比較能體會他的運作,或是吃到虧碰到坑才大徹大悟的類型,這也是本人需要改進的地方

碰到一個新領域時,我其實不會先花時間深入了解它,而是先粗淺的看文件跟其他人的範例,然後直接試著做出需求,後來被同團隊的 Sr. engineer 在 review code 時指出我似乎並沒有完整的了解機制,讓我有機會審視自己,不應該先急著讓東西生出來,而是先靜下心花時間去看懂基本的原理跟運作才對,免得以後碰到大坑根本不知道怎麼跌進去的,也很難爬出來

References: