Event Loop 是 JavaScript 非同步程式設計的核心思想,也是前端進階必須跨越的一關。同時,它又是面試的必考點,特別是在 Promise 出現之後,各種各樣的面試題層出不窮,花樣百出。這篇文章從現實生活中的例子入手,讓你徹底理解 Event Loop 的原理和機制,並能遊刃有餘的解決此類面試題。
宇宙條那道爛大街的筆試題鎮樓
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。這就決定了它只能是單執行緒的,否則會帶來很複雜的同步問題。
假設 JavaScript 有兩個執行緒,一個執行緒在某個 DOM 節點上新增內容,另一個執行緒刪除了這個節點,這時瀏覽器應該以哪個執行緒為準?
既然 Javascript 是單執行緒的,它就像是隻有一個視窗的銀行,客戶不得不排隊一個一個的等待辦理。同理 JavaScript 的任務也要一個接一個的執行,如果某個任務(比如載入高清圖片)是個耗時任務,那瀏覽器豈不得一直卡著?為了防止主執行緒的阻塞,JavaScript 有了 同步
和 非同步
的概念。
同步和非同步
同步
如果在一個函式返回的時候,呼叫者就能夠得到預期結果,那麼這個函式就是同步的。也就是說同步方法呼叫一旦開始,呼叫者必須等到該函式呼叫返回後,才能繼續後續的行為。下面這段段程式碼首先會彈出 alert 框,如果你不點選 確定
按鈕,所有的頁面互動都被鎖死,並且後續的 console
語句不會被列印出來。
alert('Yancey');
console.log('is');
console.log('the');
console.log('best');
複製程式碼
非同步
如果在函式返回的時候,呼叫者還不能夠得到預期結果,而是需要在將來通過一定的手段得到,那麼這個函式就是非同步的。比如說發一個網路請求,我們告訴主程式等到接收到資料後再通知我,然後我們就可以去做其他的事情了。當非同步完成後,會通知到我們,但是此時可能程式正在做其他的事情,所以即使非同步完成了也需要在一旁等待,等到程式空閒下來才有時間去看哪些非同步已經完成了,再去執行。
這也就是定時器並不能精確在指定時間後輸出回撥函式結果的原因。
setTimeout(() => {
console.log('yancey');
}, 1000);
for (let i = 0; i < 100000000; i += 1) {
// todo
}
複製程式碼
執行棧和任務佇列
複習下資料結構吧
-
棧 (stack): 後進先出,儲存基本資料型別和物件的指標,有 push() 和 pop() 這兩個方法
-
佇列 (stack): 後進後出,有 shift() 和 unshift() 這兩個方法
-
堆 (heap): 儲存物件
如上圖所示,JavaScript 中的記憶體分為 堆記憶體 (heap)
和 棧記憶體 (stack)
,
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'] -
接著將
p
的 then() 方法放到微任務佇列,此時微任務佇列為 ['2', '1'] -
然後列印出同步任務
4
和3
-
最後依次列印微任務
2
和1
當 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 end
和promise2
-
最後列印出巨集任務
settimeout
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 例項
下面用一個階乘的例子淺談 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