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 生來做爲瀏覽器腳本語言,主要用來處理與用戶的交互、網絡以及操做 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
最後依次打印微任務 2
和 1
咱們知道,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 end
和 promise2
最後打印出宏任務 settimeout
關於這道題的爭議:文章發表了大概有兩天的時間,陸陸續續收到了小夥伴的評論。大多都是
async1 end
和promise2
的順序問題。我在Chrome 73.0.3683.103 for MAC
和Node.js v8.15.1
測試是async1 end
先於promise2
,在FireFox 66.0.3 for MAC
測試是async1 end
後於promise2
。
Node.js 在升級到 11.x 後,Event Loop 運行原理髮生了變化,一旦執行一個階段裏的一個宏任務(setTimeout,setInterval 和 setImmediate) 就馬上執行微任務隊列,這點就跟瀏覽器端一致。
關於 11.x 版本以前 Node.js 與 瀏覽器環境下事件循環的區別,能夠參考 @浪裏行舟 大佬的 《瀏覽器與 Node 的事件循環(Event Loop)有何區別?》,這裏就很少廢話了。
須要強調的是,Worker 是瀏覽器 (即宿主環境) 的功能,實際上和 JavaScript 語言自己幾乎沒有什麼關係。也就是說,JavaScript 當前並無任何支持多線程執行的功能。
因此,JavaScript 是一門單線程的語言!JavaScript 是一門單線程的語言!JavaScript 是一門單線程的語言!
瀏覽器能夠提供多個 JavaScript 引擎實例
,各自運行在本身的線程上,這樣你能夠在每一個線程上運行不一樣的程序。程序中每個這樣的的獨立的多線程部分被稱爲一個 Worker。這種類型的並行化被稱爲 任務並行
,由於其重點在於把程序劃分爲多個塊來併發運行。下面是 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
接着將 then11
,promise2
添加到微任務隊列,此時微任務隊列爲 ['then11', 'promise2']
打印出 promise3
,將 then31
添加到微任務隊列,此時微任務隊列爲 ['then11', 'promise2', 'then31']
依次打印出 then11
,promise2
,then31
,此時微任務隊列爲空
將 then21
和 then12
添加到微任務隊列,此時微任務隊列爲 ['then21', 'then12']
依次打印出 then21
,then12
,此時微任務隊列爲空
將 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