最後一次搞懂 Event Loop

Event Loop 是 JavaScript 異步編程的核心思想,也是前端進階必須跨越的一關。同時,它又是面試的必考點,特別是在 Promise 出現以後,各類各樣的面試題層出不窮,花樣百出。這篇文章從現實生活中的例子入手,讓你完全理解 Event Loop 的原理和機制,並能遊刃有餘的解決此類面試題。javascript

宇宙條那道爛大街的筆試題鎮樓

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}
async function async2() {
  console.log('async2');
}
console.log('script start');
setTimeout(function() {
  console.log('setTimeout');
}, 0);
async1();
new Promise(function(resolve) {
  console.log('promise1');
  resolve();
}).then(function() {
  console.log('promise2');
});
console.log('script end');
複製代碼

爲何 JavaScript 是單線程的?

咱們都知道 JavaScript 是一門 單線程 語言,也就是說同一時間只能作一件事。這是由於 JavaScript 生來做爲瀏覽器腳本語言,主要用來處理與用戶的交互、網絡以及操做 DOM。這就決定了它只能是單線程的,不然會帶來很複雜的同步問題。html

假設 JavaScript 有兩個線程,一個線程在某個 DOM 節點上添加內容,另外一個線程刪除了這個節點,這時瀏覽器應該以哪一個線程爲準?前端

既然 Javascript 是單線程的,它就像是隻有一個窗口的銀行,客戶不得不排隊一個一個的等待辦理。同理 JavaScript 的任務也要一個接一個的執行,若是某個任務(好比加載高清圖片)是個耗時任務,那瀏覽器豈不得一直卡着?爲了防止主線程的阻塞,JavaScript 有了 同步異步 的概念。java

同步和異步

同步

若是在一個函數返回的時候,調用者就可以獲得預期結果,那麼這個函數就是同步的。也就是說同步方法調用一旦開始,調用者必須等到該函數調用返回後,才能繼續後續的行爲。下面這段段代碼首先會彈出 alert 框,若是你不點擊 肯定 按鈕,全部的頁面交互都被鎖死,而且後續的 console 語句不會被打印出來。git

alert('Yancey');
console.log('is');
console.log('the');
console.log('best');
複製代碼

異步

若是在函數返回的時候,調用者還不可以獲得預期結果,而是須要在未來經過必定的手段獲得,那麼這個函數就是異步的。好比說發一個網絡請求,咱們告訴主程序等到接收到數據後再通知我,而後咱們就能夠去作其餘的事情了。當異步完成後,會通知到咱們,可是此時可能程序正在作其餘的事情,因此即便異步完成了也須要在一旁等待,等到程序空閒下來纔有時間去看哪些異步已經完成了,再去執行。程序員

這也就是定時器並不能精確在指定時間後輸出回調函數結果的緣由。github

setTimeout(() => {
  console.log('yancey');
}, 1000);

for (let i = 0; i < 100000000; i += 1) {
  // todo
}
複製代碼

執行棧和任務隊列

複習下數據結構吧

  • 棧 (stack): 棧是遵循後進先出 (LIFO) 原則的有序集合,新添加或待刪除的元素都保存在同一端,稱爲棧頂,另外一端叫作棧底。在棧裏,新元素都靠近棧頂,舊元素都接近棧底。棧在編程語言的編譯器和內存中存儲基本數據類型和對象的指針、方法調用等.面試

  • 隊列 (queue): 隊列是遵循先進先出 (FIFO) 原則的有序集合,隊列在尾部添加新元素,並在頂部移除元素,最新添加的元素必須排在隊列的末尾。在計算機科學中,最多見的例子就是打印隊列。編程

  • 堆 (heap): 堆是基於樹抽象數據類型的一種特殊的數據結構。promise

棧/隊列

如上圖所示,JavaScript 中的內存分爲 堆內存棧內存,

JavaScript 中引用類型值的大小是不固定的,所以它們會被存儲到 堆內存 中,由系統自動分配存儲空間。JavaScript 不容許直接訪問堆內存中的位置,所以咱們不能直接操做對象的堆內存空間,而是操做 對象的引用

而 JavaScript 中的基礎數據類型都有固定的大小,所以它們被存儲到 棧內存 中。咱們能夠直接操做保存在棧內存空間的值,所以基礎數據類型都是 按值訪問。此外,棧內存還會存儲 對象的引用 (指針) 以及 函數執行時的運行空間

下面比較一下兩種存儲方式的不一樣。

棧內存 堆內存
存儲基礎數據類型 存儲引用數據類型
按值訪問 按引用訪問
存儲的值大小固定 存儲的值大小不定,可動態調整
由系統自動分配內存空間 由程序員經過代碼進行分配
主要用來執行程序 主要用來存放對象
空間小,運行效率高 空間大,可是運行效率相對較低
先進後出,後進先出 無序存儲,可根據引用直接獲取

執行棧

當咱們調用一個方法的時候,JavaScript 會生成一個與這個方法對應的執行環境,又叫執行上下文(context)。這個執行環境中保存着該方法的私有做用域、上層做用域(做用域鏈)、方法的參數,以及這個做用域中定義的變量和 this 的指向,而當一系列方法被依次調用的時候。因爲 JavaScript 是單線程的,這些方法就會按順序被排列在一個單獨的地方,這個地方就是所謂執行棧。

任務隊列

事件隊列是一個存儲着 異步任務 的隊列,其中的任務嚴格按照時間前後順序執行,排在隊頭的任務將會率先執行,而排在隊尾的任務會最後執行。事件隊列每次僅執行一個任務,在該任務執行完畢以後,再執行下一個任務。執行棧則是一個相似於函數調用棧的運行容器,當執行棧爲空時,JS 引擎便檢查事件隊列,若是事件隊列不爲空的話,事件隊列便將第一個任務壓入執行棧中運行。

事件循環

咱們注意到,在異步代碼完成後仍有可能要在一旁等待,由於此時程序可能在作其餘的事情,等到程序空閒下來纔有時間去看哪些異步已經完成了。因此 JavaScript 有一套機制去處理同步和異步操做,那就是事件循環 (Event Loop)。

下面就是事件循環的示意圖。

事件循環示意圖

用文字描述的話,大體是這樣的:

  • 全部同步任務都在主線程上執行,造成一個執行棧 (Execution Context Stack)。

  • 而異步任務會被放置到 Task Table,也就是上圖中的異步處理模塊,當異步任務有了運行結果,就將該函數移入任務隊列。

  • 一旦執行棧中的全部同步任務執行完畢,引擎就會讀取任務隊列,而後將任務隊列中的第一個任務壓入執行棧中運行。

主線程不斷重複第三步,也就是 只要主線程空了,就會去讀取任務隊列,該過程不斷重複,這就是所謂的 事件循環

宏任務和微任務

微任務、宏任務與 Event-Loop 這篇文章用了頗有趣的例子來解釋宏任務和微任務,下面 copy 一下。

仍是以去銀行辦業務爲例,當 5 號窗口櫃員處理完當前客戶後,開始叫號來接待下一位客戶,咱們將每一個客戶比做 宏任務接待下一位客戶 的過程也就是讓下一個 宏任務 進入到執行棧。

因此該窗口全部的客戶都被放入了一個 任務隊列 中。任務隊列中的都是 已經完成的異步操做的,而不是註冊一個異步任務就會被放在這個任務隊列中(它會被放到 Task Table 中)。就像在銀行中排號,若是叫到你的時候你不在,那麼你當前的號牌就做廢了,櫃員會選擇直接跳過進行下一個客戶的業務處理,等你回來之後還須要從新取號。

在執行宏任務時,是能夠穿插一些微任務進去。好比你大爺在辦完業務以後,順便問了下櫃員:「最近 P2P 暴雷很嚴重啊,有沒有其餘穩妥的投資方式」。櫃員暗爽:「又有傻子上鉤了」,而後嘰裏咕嚕說了一堆。

咱們分析一下這個過程,雖然大爺已經辦完正常的業務,但又諮詢了一下理財信息,這時候櫃員確定不能說:「您再上後邊取個號去,從新排隊」。因此只要是櫃員可以處理的,都會在響應下一個宏任務以前來作,咱們能夠把這些任務理解成是 微任務

大爺聽罷,揚起 45 度微笑,說:「我就問問。」

櫃員 OS:「艹...」

這個例子就說明了:你大爺永遠是你大爺 在當前微任務沒有執行完成時,是不會執行下一個宏任務的!

總結一下,異步任務分爲 宏任務(macrotask)微任務 (microtask)。宏任務會進入一個隊列,而微任務會進入到另外一個不一樣的隊列,且微任務要優於宏任務執行。

常見的宏任務和微任務

宏任務:script(總體代碼)、setTimeout、setInterval、I/O、事件、postMessage、 MessageChannel、setImmediate (Node.js)

微任務:Promise.then、 MutaionObserver、process.nextTick (Node.js)

來作幾道題

看看下面這道題你能不能作出來。

setTimeout(() => {
  console.log('A');
}, 0);
var obj = {
  func: function() {
    setTimeout(function() {
      console.log('B');
    }, 0);
    return new Promise(function(resolve) {
      console.log('C');
      resolve();
    });
  },
};
obj.func().then(function() {
  console.log('D');
});
console.log('E');
複製代碼
  • 第一個 setTimeout 放到宏任務隊列,此時宏任務隊列爲 ['A']

  • 接着執行 obj 的 func 方法,將 setTimeout 放到宏任務隊列,此時宏任務隊列爲 ['A', 'B']

  • 函數返回一個 Promise,由於這是一個同步操做,因此先打印出 'C'

  • 接着將 then 放到微任務隊列,此時微任務隊列爲 ['D']

  • 接着執行同步任務 console.log('E');,打印出 'E'

  • 由於微任務優先執行,因此先輸出 'D'

  • 最後依次輸出 'A''B'

再來看一道阮一峯老師出的題目,其實也不難。

let p = new Promise(resolve => {
  resolve(1);
  Promise.resolve().then(() => console.log(2));
  console.log(4);
}).then(t => console.log(t));
console.log(3);
複製代碼
  • 首先將 Promise.resolve() 的 then() 方法放到微任務隊列,此時微任務隊列爲 ['2']

  • 而後打印出同步任務 4

  • 接着將 p 的 then() 方法放到微任務隊列,此時微任務隊列爲 ['2', '1']

  • 打印出同步任務 3

  • 最後依次打印微任務 21

當 Event Loop 遇到 async/await

咱們知道,async/await 僅僅是生成器的語法糖,因此不要怕,只要把它轉換成 Promise 的形式便可。下面這段代碼是 async/await 函數的經典形式。

async function foo() {
  // await 前面的代碼
  await bar();
  // await 後面的代碼
}

async function bar() {
  // do something...
}

foo();
複製代碼

其中 await 前面的代碼 是同步的,調用此函數時會直接執行;而 await bar(); 這句能夠被轉換成 Promise.resolve(bar())await 後面的代碼 則會被放到 Promise 的 then() 方法裏。所以上面的代碼能夠被轉換成以下形式,這樣是否是就很清晰了?

function foo() {
  // await 前面的代碼
  Promise.resolve(bar()).then(() => {
    // await 後面的代碼
  });
}

function bar() {
  // do something...
}

foo();
複製代碼

回到開篇宇宙條那道爛大街的題目,咱們"重構"一下代碼,再作解析,是否是很輕鬆了?

function async1() {
  console.log('async1 start'); // 2

  Promise.resolve(async2()).then(() => {
    console.log('async1 end'); // 6
  });
}

function async2() {
  console.log('async2'); // 3
}

console.log('script start'); // 1

setTimeout(function() {
  console.log('settimeout'); // 8
}, 0);

async1();

new Promise(function(resolve) {
  console.log('promise1'); // 4
  resolve();
}).then(function() {
  console.log('promise2'); // 7
});
console.log('script end'); // 5
複製代碼
  • 首先打印出 script start

  • 接着將 settimeout 添加到宏任務隊列,此時宏任務隊列爲 ['settimeout']

  • 而後執行函數 async1,先打印出 async1 start,又由於 Promise.resolve(async2()) 是同步任務,因此打印出 async2,接着將 async1 end 添加到微任務隊列,,此時微任務隊列爲 ['async1 end']

  • 接着打印出 promise1,將 promise2 添加到微任務隊列,,此時微任務隊列爲 ['async1 end', promise2]

  • 打印出 script end

  • 由於微任務優先級高於宏任務,因此先依次打印出 async1 endpromise2

  • 最後打印出宏任務 settimeout

關於這道題的爭議:文章發表了大概有兩天的時間,陸陸續續收到了小夥伴的評論。大多都是 async1 endpromise2 的順序問題。我在 Chrome 73.0.3683.103 for MACNode.js v8.15.1 測試是 async1 end 先於 promise2,在 FireFox 66.0.3 for MAC 測試是 async1 end 後於 promise2

Node.js 與 瀏覽器環境下事件循環的區別

Node.js 在升級到 11.x 後,Event Loop 運行原理髮生了變化,一旦執行一個階段裏的一個宏任務(setTimeout,setInterval 和 setImmediate) 就馬上執行微任務隊列,這點就跟瀏覽器端一致。

關於 11.x 版本以前 Node.js 與 瀏覽器環境下事件循環的區別,能夠參考 @浪裏行舟 大佬的 《瀏覽器與 Node 的事件循環(Event Loop)有何區別?》,這裏就很少廢話了。

淺談 Web Workers

須要強調的是,Worker 是瀏覽器 (即宿主環境) 的功能,實際上和 JavaScript 語言自己幾乎沒有什麼關係。也就是說,JavaScript 當前並無任何支持多線程執行的功能。

因此,JavaScript 是一門單線程的語言!JavaScript 是一門單線程的語言!JavaScript 是一門單線程的語言!

瀏覽器能夠提供多個 JavaScript 引擎實例,各自運行在本身的線程上,這樣你能夠在每一個線程上運行不一樣的程序。程序中每個這樣的的獨立的多線程部分被稱爲一個 Worker。這種類型的並行化被稱爲 任務並行,由於其重點在於把程序劃分爲多個塊來併發運行。下面是 Worker 的運做流圖。

Web Worker 機制

Web Worker 實例

下面用一個階乘的例子淺談 Worker 的用法。

計算階乘的實例

首先新建一個 index.html ,直接上代碼:

<body>
  <fieldset>
    <legend>計算階乘</legend>
    <input id="input" type="number" placeholder="請輸入一個正整數" />
    <button id="btn">計算</button>
    <p>計算結果:<span id="result"></span></p>
  </fieldset>
  <legend></legend>

  <script> const input = document.getElementById('input'); const btn = document.getElementById('btn'); const result = document.getElementById('result'); btn.addEventListener('click', () => { const worker = new Worker('./worker.js'); // 向 Worker 發送消息 worker.postMessage(input.value); // 接收來自 Worker 的消息 worker.addEventListener('message', e => { result.innerHTML = e.data; // 使用完 Worker 後記得關閉 worker.terminate(); }); }); </script>
</body>
複製代碼

在同目錄下新建一個 work.js,內容以下:

function memorize(f) {
  const cache = {};
  return function() {
    const key = Array.prototype.join.call(arguments, ',');
    if (key in cache) {
      return cache[key];
    } else {
      return (cache[key] = f.apply(this, arguments));
    }
  };
}

const factorial = memorize(n => {
  return n <= 1 ? 1 : n * factorial(n - 1);
});

// 監聽主線程發過來的消息
self.addEventListener(
  'message',
  function(e) {
    // 響應主線程
    self.postMessage(factorial(e.data));
  },
  false,
);
複製代碼

以兩道題收尾

下面的兩道題來自 @小美娜娜 的文章 Eventloop 不可怕,可怕的是趕上 Promise。抄一下不會打我吧,嗯。

第一道題

const p1 = new Promise((resolve, reject) => {
  console.log('promise1');
  resolve();
})
  .then(() => {
    console.log('then11');
    new Promise((resolve, reject) => {
      console.log('promise2');
      resolve();
    })
      .then(() => {
        console.log('then21');
      })
      .then(() => {
        console.log('then23');
      });
  })
  .then(() => {
    console.log('then12');
  });

const p2 = new Promise((resolve, reject) => {
  console.log('promise3');
  resolve();
}).then(() => {
  console.log('then31');
});
複製代碼
  • 首先打印出 promise1

  • 接着將 then11promise2 添加到微任務隊列,此時微任務隊列爲 ['then11', 'promise2']

  • 打印出 promise3,將 then31 添加到微任務隊列,此時微任務隊列爲 ['then11', 'promise2', 'then31']

  • 依次打印出 then11promise2then31,此時微任務隊列爲空

  • then21then12 添加到微任務隊列,此時微任務隊列爲 ['then21', 'then12']

  • 依次打印出 then21then12,此時微任務隊列爲空

  • then23 添加到微任務隊列,此時微任務隊列爲 ['then23']

  • 打印出 then23

第二道題

這道題實際在考察 Promise 的用法,當在 then() 方法中返回一個 Promise,p1 的第二個完成處理函數就會掛在返回的這個 Promise 的 then() 方法下,所以輸出順序以下。

const p1 = new Promise((resolve, reject) => {
  console.log('promise1'); // 1
  resolve();
})
  .then(() => {
    console.log('then11'); // 2
    return new Promise((resolve, reject) => {
      console.log('promise2'); // 3
      resolve();
    })
      .then(() => {
        console.log('then21'); // 4
      })
      .then(() => {
        console.log('then23'); // 5
      });
  })
  .then(() => {
    console.log('then12'); //6
  });
複製代碼

最後

歡迎關注個人微信公衆號:進擊的前端

進擊的前端

參考

《你不知道的 JavaScript (中卷)》—— Kyle Simpson

這一次,完全弄懂 JavaScript 執行機制

從一道題淺說 JavaScript 的事件循環

微任務、宏任務與 Event-Loop

前端基礎進階:詳細圖解 JavaScript 內存空間

詳解 JavaScript 中的 Event Loop(事件循環)機制

Eventloop 不可怕,可怕的是趕上 Promise

圖解搞懂 JavaScript 引擎 Event Loop

JavaScript 線程機制與事件機制

相關文章
相關標籤/搜索