點燈坊

失くすものさえない今が強くなるチャンスよ

如何在 For Loop 使用 setTimeout() ?

Sam Xiao's Avatar 2019-09-30

若從其他程式語言跳來學習 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 後印出 02000s 後印出 13000s 後印出 2

這個問題在 ES5 與 ES6 有不同解法,先討論 ES5。

直覺會使用 for loop,使用 setTimeout()1000ms 後印出 0,然後再繼續 1000ms 後印出 1,最後再繼續 1000ms 後印出 2,這符合 synchronous 思維。

timeout000

但結果出乎意外:

  • 都印出 3
  • 3 3 3 是一次印出,非每隔 1000ms 印出

原本我們預期是 synchronous 執行方式:

  1. setTimeout() 會等 1000ms 印出 i,也就是 0
  2. 然後 setTimeout() 會再等 1000ms 印出 i,也就是 1
  3. 最後 setTimeout() 會再等 1000ms 印出 i,也就是 2

但因為 setTimeout() 為 asynchronous function,實際以 asynchronous 執行:

  1. setTimeout() 將 callback 丟到 callback queue,1000ms 後再執行
  2. setTimeout() 將 callback 丟到 callback queue,1000ms 後再執行
  3. setTimeout() 將 callback 丟到 callback queue,1000ms 後再執行
  4. for loop 執行完 i3
  5. 1000ms 後同時執行 3 個 callback,此時 i3,所以都印出 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 累積所影響。

timeout001

如此已經能印出 0 1 2,但仍都是在 1000ms 一起印出。

for(var i = 0; i < 3; i++) {
  void function(x) {
    setTimeout(function() {
      console.log(x);
    }, 1000);
  }(i);
}

IIFE 也可以使用 void 實現。

timeout002

如此也經能印出 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 註冊,才能分別在 1000ms2000ms3000ms 後執行。

timeout003

可發現 0 1 2 是依序印出,這才是我們所要的。

ECMAScript 2015

Arrow Function

for(var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 1000);
}

在 ES6 則有不同解法,setTimeout() 的 callback 可改用 arrow function。

timeout004

但兩大問題依舊。

let

for(let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 1000);
}

ES5 的 var 是以 function scope,而 ES6 的 let 則以 {} 為 scope,也因此 setTimeout() 有自己的 scope,不再需要 IIFE 了。

timeout005

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 執行。

timeout006

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。

timeout007

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()

timeout008

Conclusion

  • 若 ES5,可用 IIFE + asynchronous callback 解決
  • 若 ES6,可用 arrow function + let + promise + await 解決
  • await 只能放在 for loop,不能放在 forEach() 內,除非自行實作出 asyncForEach()

Reference

許國政 (Kuro), 8 天重新認識 JavaScript