若從其他程式語言跳來學習 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
後再執行for
loop 執行完i
為3
1000ms
後同時執行 3 個 callback,此時i
為3
,所以都印出3
我們可以發現 asynchronous 幾個特點:
setTimeout()
並非如預期 synchronous 等 callback 執行完才繼續,而是 asynchronous 將 callback 丟到 callback queue 就繼續for
loop 執行,所以i
不斷累加到3
for
loop 的 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 只能放在
for
loop,不能放在forEach()
內,除非自行實作出asyncForEach()
Reference
許國政 (Kuro), 8 天重新認識 JavaScript