1. 程式人生 > 其它 >深挖【let, for與定時器】引發的疑惑

深挖【let, for與定時器】引發的疑惑

建議您在閱覽此文之前學完W3school - JS Tutorial章節所有內容

經典的問題

在一些文章中或者工作面試問題上,會遇見這種看似簡單的經典問題。

for(var i = 0; i < 5; i++) {
  setTimeout(function () {
    console.log(i);
  });
}
console.log('hello word');
/*output
'hello word'
5
5
5
5
5
*/

新手第一次看到這個問題由於沒有深入瞭解setTimeout方法的執行機制就會得到錯誤的結果。

/*output
0
1
2
3
4
'hello world'
*/

對於老鳥來說這種問題不足掛齒,但是如果你是新手正在學習js的路上如火如荼或是剛好遇到了此類問題一知半解,那麼這篇文章將帶給你視野和解答。 小小問題背後實則包含豐富有趣的學問。

認識單執行緒、任務佇列和事件迴圈

單執行緒

JS是典型的單執行緒語言,所謂單執行緒就是隻能同時執行一個任務。
之所以是單執行緒而不是多執行緒,是為了避免多執行緒對同一DOM物件操作的衝突。比如a執行緒創造一div元素而b執行緒同時想要刪除這個div元素那麼就會出現矛盾。所以單執行緒是JS的核心特徵。

知識延申:作業系統的程序和執行緒:

對於作業系統來說,一個任務就是一個程序(Process),比如開啟一個瀏覽器就是啟動一個瀏覽器程序,開啟一個記事本就啟動了一個記事本程序,開啟兩個記事本就啟動了兩個記事本程序,開啟一個Word就啟動了一個Word程序。

有些程序還不止同時幹一件事,比如Word,它可以同時進行打字、拼寫檢查、列印等事情。在一個程序內部,要同時幹多件事,就需要同時執行多個“子任務”,我們把程序內的這些“子任務”稱為執行緒(Thread)。

一個程序至少有一個執行緒,複雜的程序有多個執行緒。作業系統通過多核cpu快速交替執行這些執行緒就給人一種同時執行的感覺。

任務佇列

單執行緒就意味著,所有任務需要排隊,前一個任務結束,後一個任務才會執行。前面的任務耗時過長,後面的任務也得硬著頭皮等待。而任務執行慢通常不是cpu效能不行,而是I/O裝置操作耗時長比如Ajax操作從網路讀取資料)。

JS設計者意識到,遇到這種情況主執行緒可以完全不管I/O裝置的結果,先掛起等待結果的任務,然後執行排在後面的任務。直到I/O裝置返回了結果,再回過來執行先前掛起的任務。

所以,設計者把計算機的程式任務可以分為兩種,同步任務和非同步任務。同步任務:直接進入主執行緒執行的任務。前面的任務執行完,後面的才能執行,按順序一個接一個的執行;非同步任務:不會直接進入主執行緒,而是通過“任務佇列”(task queue)通知“主執行緒佇列”準備就緒才會進入主執行緒執行。

具體來說整個機制如下:

  1. 所有同步任務都在主執行緒上執行,生成一個執行棧(execution context stack)。
  2. 主執行緒外單獨劃分出一個任務佇列(task queue)。非同步任務在同步任務執行時也不會“偷懶”,同時執行得到結果。然後非同步任務會生成一個對應的通知事件放置於“任務佇列”。
  3. 執行棧中的同步任務執行完畢,系統就會讀取任務佇列中的通知事件,通知事件所對應的非同步任務就會結束等待狀態進入執行棧開始執行。並且通知事件遵循先進先出原則。
  4. 主執行緒會不斷重複以上三步。
    機制流程示意圖:
    執行棧一空就會讀取任務佇列,如此往復,這就是JS的執行機制。

事件和回撥函式的關係
任務佇列中的通知事件包括了I/O裝置事、使用者點選、頁面滾動等等。只要指定了回撥函式(callback)這些事件就會進入任務佇列,等待主執行緒讀取。

回撥函式(callback)的程式碼會被任務佇列掛起。所以需要非同步執行某個程式時就請使用回撥函式,主執行緒讀取任務佇列時會先檢查通知事件是否包含【定時器】確認執行時間之類的。

事件迴圈

主執行緒讀取任務佇列事件是往復迴圈的,整個機制被稱之為事件迴圈(event loop)。
接下來參考Philip Roberts的演講《Help, I'm stuck in an event-loop》深挖事件迴圈

從上面的圖示我們能夠看到,主執行緒執行時產生兩個事物,分別是(heap)和(stack),棧會呼叫各種外部的WebAPI,它們在"任務佇列"中加入各種事件(click,load,done)。只要棧中的程式碼執行完畢,主執行緒就會去讀取任務佇列,依次執行那些事件所對應的回撥函式。

定時器[setTimeout]

回過頭來看文章開頭那段程式碼

for(var i = 0; i < 5; i++) {
  setTimeout(function () {
    console.log(i);
  });
}
console.log('hello word');

從前面【事件迴圈】小節我們知道了,setTimeout屬於非同步任務,它會生成一個事件(對應指定的回撥函式)放進任務佇列掛起,直到中的同步任務都執行完畢後,系統讀取任務佇列拿到通知事件對應的回撥函式再放進執行並返回結果。

所以實質上可以看作(取巧方便理解,非實質):

// 同步執行
for(var i = 0; i < 5; i++) {
}
// 同步執行
console.log('hello word');

// 非同步執行
console.log(i);
console.log(i);
console.log(i);
console.log(i);
console.log(i);

作用域 + 閉包

作用域簡單的說就是js程式當前執行的語境,或者值和表示式可訪問和引用的語境。物件在這個語境中才能才能訪問和引用這個語境中的其他物件。子作用域的物件可以訪問和引用父作用域中的物件,反之不行。
特殊的是一個函式物件在JS中被建立的時候同時建立了閉包閉包是由該函式物件和它所在的語境而構成的一個組合。通常返回一個函式的引用。

// 一個典型的閉包
function makeFunc() {
  var text = "hello world";
  function displayName() {
      console.log(text);
  }
  return displayName;
}
var myFunc = makeFunc();
myFunc();

回過頭來看文章開頭那段程式碼,我們就可以利用閉包的原理讓定時器打印出0, 1, 2, 3, 4

for(var i = 0; i < 5; i++) {
  ((i) => {
    setTimeout(function () {
    console.log(i);
	});
  })(i);
}
console.log('hello word');

在上面的程式碼中,使用了一個技巧 立即函式 給計時器單獨提供了一個新的作用域,加上裡面的計時器就剛好組成了一個非同步的閉包組合,而且是立刻呼叫的。

通過上面的手段就可以很好的避免var宣告的迴圈變數暴露在全域性作用域帶來的問題。從而打印出0, 1, 2, 3, 4

另外通過let宣告迴圈變數也是很好的解決手段,let允許你宣告一個被限制在塊作用域中的變數、語句或者表示式,這個就是塊級作用域

for (let i = 0; i < 5; i++) {
  setTimeout(function () {
    console.log(i);
  });
}
console.log('hello word');

let是ES6語法,而塊級作用域的出現解決了var迴圈變數洩露為全域性變數的問題和變數覆蓋的問題。

回到上面的程式碼,著重說下let是如何做到每次迴圈能夠記憶當前i的值並傳給下次迴圈的:

  1. 首先,在for迴圈中,設定迴圈變數的括號實質上是一個父作用域,而迴圈體是子作用域。
  2. let聲明瞭該父作用域是塊級作用域而不是全域性作用域,每次迴圈i的值只對當前迴圈的塊級作用域有效,就像是塊級作用域是一支捕蟲網,捕獲迴圈更新的i值。迴圈一次就會更新塊級作用域以及變數i,好比拿新的捕蟲網來捕獲新i
  3. 說白了,每次迴圈變數i會重新宣告初始化i。實質上是JS引擎內部會記憶上一輪迴圈的值,初始化本輪的變數i時,就在上一輪迴圈的基礎上進行計算。
  4. 迴圈體內部函式會先訪問本身的塊級作用域,沒有i就繼續向上查詢迴圈體作用域,沒有i向上查詢父作用域拿到當前迴圈記憶(捕獲)的i值最後打印出來。
  5. 細心的朋友其實已經發現了,迴圈體內部函式 + 往上查詢的塊級作用域語境剛好組成了類似閉包的組合。

對於不能相容ES6的瀏覽器,我們也可以使用ES5try...catch...語句,形成類似閉包的效果。

for(var i = 0; i < 5; i++) {
  try {
    throw(i)
  } catch(j) {
    setTimeout(function () {
    console.log(j);
	});
  }
}
console.log('hello word');

參考引用:
JavaScript 執行機制詳解:再談Event Loop
阮一峰ES6文件-let 和 const 命令