以前我對非同步的理解,大概只停留在「它會在背後幫你把很重的處理做好」這種程度,對於非同步到底做了什麼,其實沒有太深的理解。
即使被問到「為什麼一定要寫非同步?」我也只能回答:「因為如果不是非同步,畫面會等到處理結束才顯示,會變得很卡。」像這樣對「為什麼非同步是必要的」的理由一直很模糊,實際寫程式時也常常會猶豫:「這裡真的要用非同步嗎?」
所以這篇文章的目標,就是理解非同步處理實際上到底在做什麼。
先在理解非同步處理之前,說明一下同步處理。
同步處理是指:
依照由上到下的順序,一次只執行一個,前一個處理沒有結束之前,不會進到下一行
console.log('1');
heavyWork(); // 等到這裡做完
console.log('2'); // heavyWork 結束後才會執行
從伺服器取得資料的通訊,會因為對方的狀況和網路速度而無法預測何時結束。若用同步處理來寫,就必須等資料回來才能往下一行,通訊越慢,卡住的時間就越長。
在這段期間會出現:
從使用者的角度來看,就像整個頁面當機了一樣。
官方文件也提到:
一次只能做一件事。因此,呼叫耗時很長的同步處理後再回來,在那段期間就無法做其他事情。
實際上,我們平常寫的大部分程式碼都是同步處理。
這類不太耗時的處理,通常都用同步處理來寫。
因此,如果把一下子就能完成的事情也硬寫成非同步,不但不會變快,還會讓程式碼變多、變複雜,也更容易產生 bug,所以需要做好切分。
非同步程式設計是一種技術:即使啟動了可能很耗時的任務,也不會一直等待它完成,而是在任務執行期間仍能回應其他事件。當任務完成後,程式再顯示其結果。

用圖表示大概就是這樣
如同第 2 章引用的 MDN 內容所說,JavaScript 一次只能做一件事。
這稱為單執行緒:也就是處理流程只有一條。
因此,
像與伺服器通訊這種耗時的處理,如果用同步方式來寫,就必須等到處理結束,在那之前其他工作(例如按鈕反應、畫面更新等)都做不了,畫面就會卡住。
所以,在發出命令之後,不等待結果,先往下一個處理前進,等處理完成時再接收通知或結果。
這就是非同步處理。
console.log('1');
fetch('/api/data').then(() => {
console.log('3:資料到了'); // 通訊結束後執行
});
console.log('2'); // 不等待通訊完成,立刻執行
// .then() 裡面寫的是通訊結束後想做的事
執行結果會是 1 → 2 → 3 的順序,而且因為在等待通訊的期間 JavaScript 不會停止,所以畫面不會卡住。
第 3 章舉的是通訊的例子,但通訊如果沒有伺服器就沒辦法測試。所以這裡改用只靠瀏覽器就能測試的 setTimeout 來實際跑程式。
setTimeout:在指定時間過後執行處理的函式
console.log('1');
setTimeout(() => {
console.log('2');
}, 5 * 1000); // 5 秒後執行
console.log('3');

2 最後才出現。
console.log('1') 被執行setTimeout 請瀏覽器在 5 秒後執行 console.log('2')console.log('3')console.log('2')順帶一提,非同步處理中背後實際在運作的部分可以理解成是瀏覽器在幫忙做,因為不太好想像,所以我畫成了圖

順帶一提,如果把等待時間改成 0 秒 會怎樣呢?
console.log('1');
setTimeout(() => {
console.log('2');
}, 0); // 0 秒後執行
console.log('3');

即使是 0 秒,2 也還是最後才被處理。
傳給 setTimeout 的處理,即使時間是 0 秒,也仍然會作為非同步處理來處理。因此它會等到目前正在執行的同步處理(直到 console.log('3') 為止)全部結束後才執行。
所以,如果把不需要時間的處理寫成非同步,可能會出現資料已經拿到了,但畫面卻以空白狀態顯示的 bug,要特別注意。
let data;
fetch('/api/data')
.then((res) => res.json())
.then((json) => {
data = json; // 通訊結束後,這裡才會有值
});
console.log(data); // 不等通訊完成就先執行,所以是 undefined
修正方式有:
await 等到接收到結果await 會在下一章介紹。
到這裡為止,我們已經知道非同步處理的方式是:發出命令之後,不等待結果,先繼續下一個處理,等處理完成後再接收通知或結果。接下來要談的是,如何接收處理結果。
第 4 章也舉過一個例子:如果在結果送達前就先使用值,就會變成空值。為了安全地使用已到達的結果,常見的寫法就是
Promise 和 async/await。
MDN 對 Promise 的說明如下:
Promise 是由非同步函式回傳的物件,代表操作目前的狀態。當 Promise 回傳給呼叫端時,操作通常還沒有完成,但 Promise 物件提供了用來處理最終成功或失敗的方法。
只看這段對初學者來說還是不太容易懂……
MDN 其實還有更容易理解的說明;如果你看完「Promise 的用法」還是覺得卡卡的,會比較推薦去讀那一段。
簡單來說,Promise 是:
用來在之後接收非同步處理結果的物件,結果可以是成功或失敗及其值
Promise 有三種狀態:
狀態 意義
待定(pending) 還沒有結果
已完成(fulfilled) 成功了 → 會呼叫 .then()
已拒絕(rejected) 失敗了 → 會呼叫 .catch()
.then() 是在 Promise 變成「成功」時會被呼叫的處理註冊方法。
// 程式碼裡沒有直接寫 Promise,所以有點難看出來,
// 但呼叫 fetch() 時,其實會回傳 Promise
console.log('1');
fetch('/api/data').then(() => { // fetch 會回傳 Promise,這就是 Promise
console.log('3:資料到了'); // 通訊結束後執行
});
console.log('2'); // 不等待通訊完成,立刻執行
// .then() 裡面寫的是通訊結束後想做的事
寫成 new Promise(...) 的話,也可以自己建立 Promise。不過在實務上,我們大多是使用像 fetch 這種一開始就會回傳 Promise 的函式。
接下來介紹另一種接收處理結果的方式:async/await。
如果一直把 .then() 串下去,程式會變得不容易閱讀,所以就會使用 async/await。
async關鍵字可以讓非同步 Promise 程式以更簡潔的方式運作。(略)因此即使使用非同步函式,也能寫出看起來像同步程式的程式碼。
async:加在函式前面,會讓這個函式變成非同步函式await:加在 Promise 前面,會等待結果出來,並取得內容async function getData() {
const response = await fetch('/api/data'); // 等到結果出來為止
console.log('資料到了');
}
這樣就不需要 .then(),可以像同步處理一樣由上往下閱讀。
不過一聽到 await 會「等待」,就會讓人懷疑:這不就跟同步處理一樣會把畫面卡住嗎?所以我實際驗證了一下。
我比較了兩種情況:一種是每 0.5 秒顯示一次計數,同時用 await 等 5 秒;另一種是用同步處理等 5 秒。
如果等待期間計數還持續增加,就代表沒有卡住;如果計數停止,就代表畫面被卡住了。
// 每 0.5 秒顯示一次計數(7 秒後停止)
let count = 0;
const timer = setInterval(() => {
count++;
console.log(`計數 ${count}`);
if (count >= 14) clearInterval(timer);
}, 500);
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function getData() {
console.log('2:開始等待');
await wait(5 * 1000);
console.log('4:5 秒到了');
}
console.log('1');
getData();
console.log('3');

執行結果如下:
1
2:開始等待
3 // 不用等 5 秒就立刻顯示
4:5 秒到了 // 5 秒後顯示
在 2:開始等待 之後,等 5 秒的期間,3 和計數仍然持續顯示。
也就是說,被 await 暫停的只有 getData 裡面,而 JavaScript 本身還是繼續往下執行。
// 每 0.5 秒顯示一次計數(7 秒後停止)
let count = 0;
const timer = setInterval(() => {
count++;
console.log(`計數 ${count}`);
if (count >= 14) clearInterval(timer);
}, 500);
function waitSync(ms) {
const end = Date.now() + ms;
while (Date.now() < end) {}
}
function getDataSync() {
console.log('2:開始等待');
waitSync(5 * 1000);
console.log('4:5 秒到了');
}
console.log('1');
getDataSync();
console.log('3');

結果是 1 → 2 →(卡住 5 秒)→ 4 → 3。
在 2:開始等待 之後,5 秒內什麼都沒有顯示,計數也停止了。
這代表 JavaScript 5 秒都被 waitSync 佔住,完全做不了其他事。
如果這是 Web 應用程式,使用者端看到的就是畫面卡住、什麼都不能操作的狀態。
https://developer.mozilla.org/ja/docs/Learn_web_development/Extensions/Async_JS/Introducing
https://developer.mozilla.org/ja/docs/Learn_web_development/Extensions/Async_JS/Promises
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise
https://developer.mozilla.org/ja/docs/Learn_web_development/Extensions/Async_JS/Promises#async_%E3%81%A8_await
在 xincere 株式會社 中,我們有聘用沒有實務經驗的工程師與學生工程師實習生,一起工作。
※ 關於在シンシア的工作方式,請參考這裡
在シンシア,每年大約有 100 位沒有實務經驗的人投遞履歷並參加技術面試。