摘要:由于是單線程的,這些方法就會按順序被排列在一個多帶帶的地方,這個地方就是所謂執行棧。事件隊列每次僅執行一個任務,在該任務執行完畢之后,再執行下一個任務。
宇宙條那道爛大街的筆試題鎮樓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): 棧是遵循后進先出 (LIFO) 原則的有序集合,新添加或待刪除的元素都保存在同一端,稱為棧頂,另一端叫做棧底。在棧里,新元素都靠近棧頂,舊元素都接近棧底。棧在編程語言的編譯器和內存中存儲基本數據類型和對象的指針、方法調用等.
隊列 (queue): 隊列是遵循先進先出 (FIFO) 原則的有序集合,隊列在尾部添加新元素,并在頂部移除元素,最新添加的元素必須排在隊列的末尾。在計算機科學中,最常見的例子就是打印隊列。
堆 (heap): 堆是基于樹抽象數據類型的一種特殊的數據結構。
如上圖所示,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
當 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 與 瀏覽器環境下事件循環的區別關于這道題的爭議:文章發表了大概有兩天的時間,陸陸續續收到了小伙伴的評論。大多都是 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)有何區別"); 淺談 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
這一次,徹底弄懂 JavaScript 執行機制
從一道題淺說 JavaScript 的事件循環
微任務、宏任務與 Event-Loop
前端基礎進階:詳細圖解 JavaScript 內存空間
詳解 JavaScript 中的 Event Loop(事件循環)機制
Eventloop 不可怕,可怕的是遇上 Promise
圖解搞懂 JavaScript 引擎 Event Loop
JavaScript 線程機制與事件機制
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/7399.html
摘要:網頁單位絕對單位,代表屏幕中每個點。相對單位,每個元素透過倍數乘以根元素的值。和就是固定百分比為單位,為父層的,為父層的。 對于繪圖和印刷而言,單位相當重要,然而在網頁排版里,單位也是同樣具有重要性,在CSS3普及以來,更支持了一些方便好用的單位(px、em、rem…等),這篇文章將整理這些常用的CSS單位,也幫助自己未來在使用上能更加得心應手。 網頁和印刷的單位若要把單位做區隔,最簡...
摘要:檢查宏任務隊列,發現有的回調函數立即執行回調函數輸出。接著遇到它的作用是在后將回調函數放到宏任務隊列中這個任務在再下一次的事件循環中執行。 為什么會寫這篇博文呢? 前段時間,和頭條的小伙伴聊天問頭條面試前端會問哪些問題,他稱如果是他面試的話,event-loop肯定是要問的。那天聊了蠻多,event-loop算是給我留下了很深的印象,原因很簡單,因為之前我從未深入了解過,如果是面試的時...
摘要:前言以異步和事件驅動的特性著稱但異步是怎么實現的呢其中核心的一部分就是下文中內容基本來自于文檔有不準確地方請指出什么是能讓的操作表現得無阻塞盡管是單線程的但通過盡可能的將操作放到操作系統內核由于現在大多數內核都是多線程的它們可以在后臺執行多 前言 Node.js以異步I/O和事件驅動的特性著稱,但異步I/O是怎么實現的呢?其中核心的一部分就是event loop,下文中內容基本來自于N...
摘要:同步一次執行一件事,同步引擎一次只執行一行,是同步的。調用函數將其推入堆棧并從函數返回將其彈出堆棧。執行上下文當函數放入到調用堆棧時由創建的環境。執行結果它會立即被推到回調隊列,但它仍然會等待調用堆棧為空才會執行。 為了保證可讀性,本文采用意譯而非直譯。 想閱讀更多優質文章請猛戳GitHub博客,一年百來篇優質文章等著你! 一些名詞 JS引擎 — 一個讀取代碼并運行的引擎,沒有單一的J...
閱讀 3456·2023-04-26 00:39
閱讀 4059·2021-09-22 10:02
閱讀 2544·2021-08-09 13:46
閱讀 1102·2019-08-29 18:40
閱讀 1447·2019-08-29 18:33
閱讀 775·2019-08-29 17:14
閱讀 1517·2019-08-29 12:40
閱讀 2979·2019-08-28 18:07