單線程 JavaScript 的異步機制與經典 for 循環面試題

從一個經典的 for 循環問題開始

for (var i = 1; i <= 5; i++) {
  setTimeout( function timer() {
    console.log(i);
  }, i*1000)
}

輸出是:每隔1秒,輸出一個6,共5次。

原理

這樣的輸出,是由 JavaScript 的單線程及異步機制決定的。

JavaScript 是單線程的,所有的任務排隊,按順序一一執行。

異步執行可以實現多任務併發

相關必要概念引用

  • 並行: 同一時刻內多任務同時進行,多線程實現;
  • 併發,同一時間段內,多任務同時進行着,但是某一時刻,只有某一任務執行,單線程可實現;
  • 同步任務:在主線程上排隊執行的任務,只有前一個任務執行完畢,才能執行後一個任務;
  • 異步任務,不進入主線程、而進入”任務隊列”(task queue)的任務,只有”任務隊列”通知主線程,某個異步任務可以執行了,該任務纔會進入主線程執行。
  • 堆(heap):內存中某一未被阻止的區域,通常存儲對象(引用類型);
  • 棧(stack):後進先出的順序存儲數據結構,通常存儲函數參數和基本類型值變量(按值訪問);
  • 隊列(queue):先進先出順序存儲數據結構。
  • 任務隊列:一個先進先出的數據結構,排在前面的事件,優先被主線程讀取。主線程的讀取過程基本上是自動的,只要執行棧一清空,”任務隊列”上第一位的事件就自動進入主線程。
    • 除了放置異步任務的事件,”任務隊列”還可以放置定時事件,即指定某些代碼在多少時間之後執行。這叫做”定時器”(timer)功能,也就是定時執行的代碼。如果有定時器,主線程首先要檢查一下執行時間,某些事件只有到了規定的時間,才能返回主線程。
  • 回調函數(callback): 那些會被主線程掛起來的代碼。異步任務必須指定回調函數,當主線程開始執行異步任務,就是執行對應的回調函數。
  • 事件循環: 主線程從”任務隊列”中讀取事件,這個過程是循環不斷的,所以整個的這種運行機制又稱爲事件循環(Event Loop)。

異步機制如圖:

JavaScript事件循環

  • 宿主環境爲JavaScript創建線程時,會創建堆(heap)和棧(stack),
    • 堆內存儲JavaScript對象,
    • 棧內存儲執行上下文
  • 同步任務:

    • 執行棧內,執行上下文的同步任務按序執行
    • 執行完即退棧
  • 異步任務:

    • 異步任務執行時,該異步任務進入等待狀態(不入棧)
    • 與此同時,通知線程“當觸發該異步事件的時候(或該異步操作響應返回時),需向任務隊列插入一個事件”
    • 當實際上異步事件觸發或異步操作響應返回時,線程向任務隊列插入相應的回調事件
    • 當執行棧清空後,線程從任務隊列取出一個事件消息,其對應異步任務(函數)結束等待狀態,進入執行棧,執行回調函數
      • 如果該事件消息未綁定回調,則執行完任務後退棧,這個消息會被丟棄
  • 當線程空閒(即執行棧清空)時繼續拉取消息隊列下一輪消息(next tick,事件循環流轉一次稱爲一次tick)。

回到問題本身

從上面的分析可知:

1 等執行棧內同步的 for 循環執行結束出棧後,線程纔會從任務隊列里拉取異步的定時器的回調函數

這段代碼可以證明:

for (var i = 1; i <= 5; i++) {
  console.log(i);

  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}

輸出:

  • 先同時輸出 12345;
  • 再輸出5個6,每隔一秒輸出一個

2 根據作用域的工作原理,儘管循環中的五個函數是在各個迭代中分別定義的,但是它們都被封閉在一個共享的全局作用域中,因此實際上只有一個 i。

所以,文章開頭的那段代碼等價於

var i = 1;
// 定時器將其回調函數加入任務列表,執行棧清空一秒後執行
var i = 2;
// 定時器將其回調函數加入任務列表,執行棧清空二秒後執行
var i = 3;
// 定時器將其回調函數加入任務列表,執行棧清空三秒後執行
var i = 4;
// 定時器將其回調函數加入任務列表,執行棧清空四秒後執行
var i = 5;
// 定時器將其回調函數加入任務列表,執行棧清空五秒後執行
var i = 6;
// 定時器將其回調函數加入任務列表,執行棧清空六秒後執行

// 循環結束後,線程讀取任務列表,將定時事件對應的異步任務(回調函數)入棧執行
console.log(i);

console.log(i);

console.log(i);

console.log(i);

console.log(i);

console.log(i);

setTimeout(fn,0)的含義是,指定某個任務在主線程最早可得的空閒時間執行,也就是說,儘可能早得執行。它在”任務隊列”的尾部添加一個事件,因此要等到同步任務和”任務隊列”現有的事件都處理完,纔會得到執行。而這個時候,i 就是 6。

HOW TO FIX IT

我知道,閉包!

for (var i = 1; i <= 5; i++) {
  (function() {
    setTimeout( function timer() {
      console.log(i);
    }, i*1000)
  })()
}

輸出:然並卵,依然全是 6,
簡直666

因爲,新加上的 IIFE 作用域是”空的”,它並沒有自己的變量。
執行棧清空後,線程從任務隊列裏讀取回調函數,它們還是引用那個唯一的全局變量i。

正確的閉包姿勢:

通過在閉包作用域中添加自己的變量,從而在每次迭代中,捕獲i的副本。

for (var i = 1; i <= 5; i++) {
  (function() {
    var j = i // 在閉包作用域中,通過添加自己的變量,每次迭代都捕獲i的副本
    setTimeout( function timer() {
      console.log(j);
    }, j*1000)         //至於時間這裏,是i 是j無所謂
  })()
}

更簡潔的姿勢:

for (var i = 1; i <= 5; i++) {
  (function(j) {
    setTimeout( function timer() {
      console.log(j);
    }, j*1000)
  })(i) 
}

由此,能夠輸出:1 2 3 4 5,一秒一個

ES6的打開方式: 塊作用域

for (var i = 1; i <= 5; i++) {

    let j = i
    setTimeout( function timer() {
      console.log(j);
    }, j*1000)

}

或直接在for循環頭部裏,每次迭代都聲明一次。隨後的每個迭代都會使用上一個迭代結束時的值來初始化這個變量。

for (let i = 1; i <= 5; i++) {

    setTimeout( function timer() {
      console.log(i);
    }, i*1000)

}

注:

HTML5標準規定了setTimeout()的第二個參數的最小值(最短間隔),不得低於4毫秒,如果低於這個值,就會自動增加。在此之前,老版本的瀏覽器都將最短間隔設爲10毫秒。另外,對於那些DOM的變動(尤其是涉及頁面重新渲染的部分),通常不會立即執行,而是每16毫秒執行一次。這時使用requestAnimationFrame()的效果要好於setTimeout()。

需要注意的是,setTimeout()只是將事件插入了”任務隊列”,必須等到當前代碼(執行棧)執行完,主線程纔會去執行它指定的回調函數。要是當前代碼耗時很長,有可能要等很久,所以並沒有辦法保證,回調函數一定會在setTimeout()指定的時間執行。

參考文檔:

發佈了49 篇原創文章 · 獲贊 40 · 訪問量 11萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章