若從其他程式語言跳來學習 ECMAScript,最不習慣應該就是 ECMAScript 有大量 Asynchronous 概念,如在 For Loop 中使用 setTimeout() 算是 ECMAScript 前十大坑之一。
Version
macOS Mojave 10.14.6
VS Code 1.38.1
Quokka 1.0.253
ECMAScript 5
ECMAScript 2017
ECMAScript 5
Callback Function
for(var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
若需求是在 1000ms 後印出 0,2000s 後印出 1,3000s 後印出 2。
這個問題在 ES5 與 ES6 有不同解法,先討論 ES5。
直覺會使用 for loop,使用 setTimeout() 在 1000ms 後印出 0,然後再繼續 1000ms 後印出 1,最後再繼續 1000ms 後印出 2,這符合 synchronous 思維。

但結果出乎意外:
- 都印出
3 - 且
3 3 3是一次印出,非每隔1000ms印出
原本我們預期是 synchronous 執行方式:
setTimeout()會等1000ms印出i,也就是0- 然後
setTimeout()會再等1000ms印出i,也就是1 - 最後
setTimeout()會再等1000ms印出i,也就是2
但因為 setTimeout() 為 asynchronous function,實際以 asynchronous 執行:
setTimeout()將 callback 丟到 callback queue,1000ms後再執行setTimeout()將 callback 丟到 callback queue,1000ms後再執行setTimeout()將 callback 丟到 callback queue,1000ms後再執行forloop 執行完i為31000ms後同時執行 3 個 callback,此時i為3,所以都印出3
我們可以發現 asynchronous 幾個特點:
setTimeout()並非如預期 synchronous 等 callback 執行完才繼續,而是 asynchronous 將 callback 丟到 callback queue 就繼續forloop 執行,所以i不斷累加到3forloop 的 synchronous 都執行完後,才開始執行 callback queue 內的 3 個 callback- 因為 3 個 callback queue 都註冊
1000ms後執行,此時i已經為3,因此同時印出3
IIFE
for(var i = 0; i < 3; i++) {
(function(x) {
setTimeout(function() {
console.log(x);
}, 1000);
})(i);
}
先解決都印出 3 問題:
在 ES5 使用 var 所定義的 variable,其 scope 是 function,若沒 function,則都是 global scope。
當 console.log(i) 時,因為 callback 內沒有 i,所以會往外層尋找,又因為 i 沒有 function 包住,所以 i 共用 global scope,這導致 for loop 所累積的 i,會被 callback queue 執行 callback 所使用,因此都讀到 3。
若我們每個 setTimeout() 都有自己的 function scope,就不會受 for loop 所影響。
ES5 可使用 IIFE 訂出 function scope,如此 i 傳入 IIFE 的 anonymous function 就被封在 scope 內,不會受 i 累積所影響。

如此已經能印出 0 1 2,但仍都是在 1000ms 一起印出。
for(var i = 0; i < 3; i++) {
void function(x) {
setTimeout(function() {
console.log(x);
}, 1000);
}(i);
}
IIFE 也可以使用 void 實現。

如此也經能印出 0 1 2,但仍都是在 1000ms 一起印出。
Asynchronous Callback
for(var i = 0; i < 3; i++) {
void function(x) {
setTimeout(function() {
console.log(x);
}, 1000 * x);
}(i);
}
setTimeout() 只是在 event queue 註冊 1000ms,因此在 1000ms 後同時執行,為了要有先後順序,要分別以 1000 * i 註冊,才能分別在 1000ms、2000ms 與 3000ms 後執行。

可發現 0 1 2 是依序印出,這才是我們所要的。
ECMAScript 2015
Arrow Function
for(var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 1000);
}
在 ES6 則有不同解法,setTimeout() 的 callback 可改用 arrow function。

但兩大問題依舊。
let
for(let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 1000);
}
ES5 的 var 是以 function scope,而 ES6 的 let 則以 {} 為 scope,也因此 setTimeout() 有自己的 scope,不再需要 IIFE 了。

let 能印出 0 1 2,但仍都是在 1000ms 一起印出。
Promise & Await
let sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
for(let i = 0; i < 3; i++) {
await sleep(1000);
console.log(i);
}
第 1 行
let sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
ES6 提出了 promise 取代 asyncronous callback,因此可自行定義 sleep() 在 1000ms 後回傳 promise。
Promise 搭配 await 後,就會如同預期看起來以 synchronous 執行。

ES6 使用 arrow function + let + promise + await 後,可謂功德圓滿,不只結果如預期,且 code 可讀性也很高,接近 synchronous 思維。
forEach()
let sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
[0, 1, 2].forEach(async x => {
await sleep(1000);
console.log(x);
});
ES5 就有 Array.prototype.forEach(),也可使用 forEach() 取代 for loop。

forEach() 能印出 0 1 2,但仍都是在 1000ms 一起印出,why ?
forEach() 本質是 synchronous function,但 sleep() 是 asynchronous function,當 synchronuos 執行 asynchronous 時,會有不可預期結果。
asyncForEach()
let sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
Array.prototype.asyncForEach = async function(cb) {
for(let i = 0; i < this.length; i++) {
await cb(this[i], i, this);
}
};
[0, 1, 2].asyncForEach(async x => {
await sleep(1000);
console.log(x);
});
第 3 行
Array.prototype.asyncForEach = async function(cb) {
for(let i = 0; i < this.length; i++) {
await cb(this[i], i, this);
}
};
若仍堅持要在 callback 使用 await,則必須自行實作 asyncForEach(),此為 asynchronous function,因此可正常執行 asynchronous 的 sleep()。

Conclusion
- 若 ES5,可用 IIFE + asynchronous callback 解決
- 若 ES6,可用 arrow function + let + promise + await 解決
- await 只能放在
forloop,不能放在forEach()內,除非自行實作出asyncForEach()
Reference
許國政 (Kuro), 8 天重新認識 JavaScript