這次徹底了解JavaScript執(zhí)行機制
無論你是 JavaScript 新手還是老手,無論你是在面試工作,還是只是做常規(guī)的開發(fā)工作,通常會發(fā)現給定幾行代碼,你需要知道要輸出什么以及以什么順序輸出 . 由于 JavaScript 是一種單線程語言,我們可以得出以下結論:
let a = '1';
console.log(a);
let b = '2';
console.log(b);
然而,JavaScript 實際上是這樣的:
setTimeout(function(){
console.log('start')
});
new Promise(function(resolve){
console.log('start for');
for(var i = 0; i < 10000; i++){
i == 99 && resolve();
}
}).then(function(){
console.log('start then')
});
console.log('end');
// Following the idea that JS executes in the order in which the statements appear, I confidently write down the output:
// start
// start for
// start then
// end
在 Chrome 上查看它是完全錯誤的??
一、 關于 JavaScript
JavaScript 是一種單線程語言。Web-worker 是在最新的 HTML5 中提出的,但 JavaScript 是單線程的核心保持不變。所以所有 JavaScript 版本的“多線程”都是用單線程模擬的,所有的 JavaScript 多線程都是紙老虎!
二、JavaScript 事件循環(huán)
由于 JavaScript 是單線程的,它就像一個只有一個窗口的銀行。客戶需要一一排隊辦理業(yè)務。
同樣,JavaScript 任務也需要一個一個地執(zhí)行。如果一項任務花費的時間太長,則下一項也必須等待。
那么問題來了,如果我們想瀏覽新聞,但新聞中包含加載緩慢的超高清圖像,我們的網頁是否應該一直卡住直到圖像完全顯示?所以聰明的程序員將任務分為兩類:
- 同步任務
- 異步任務
當我們打開一個網站時,頁面的渲染過程是很多同步任務,比如渲染頁面骨架和頁面元素。
需要大量時間的任務,比如加載圖片和音樂,都是異步任務。這部分有嚴格的文字定義,但本文的目的是以最小的學習成本徹底理解實現機制,所以我們用一張圖來說明:
文字要表達的內容:
同步和異步任務去不同的執(zhí)行“地方”,同步任務去主線程,異步任務去事件表和注冊函數。
當指定的事件完成時,事件表將此函數移至事件隊列。
如果執(zhí)行后主線程中的任務為空,事件隊列會讀取相應的函數,進入主線程執(zhí)行。
這個過程一遍又一遍地重復,稱為事件循環(huán)。
我們怎么知道主線程棧是空的?JavaScript 引擎有一個監(jiān)控進程,不斷檢查主線程堆棧是否為空,如果是,則檢查 Event Queue 以查看是否有任何函數等待調用。
說了這么多,不如直接寫一段代碼:
let data = [];
$.ajax({
url:www.javascript.com,
data:data,
success:() => {
console.log('success!');
}
})
console.log('end');
這是一個簡單的ajax請求代碼:
- ajax 去事件表并注冊回調函數成功。
- 執(zhí)行 console.log(‘success’)。
- ajax Event 完成,回調函數success 進入Event Queue。
- 主線程從事件隊列中讀取成功并執(zhí)行。
相信通過上面的文字和代碼,你對JS的執(zhí)行順序有了初步的了解。接下來,我們來看看進階話題:setTimeout。
三、 愛恨交加超時
著名的 setTimeout 無需進一步解釋。setTimeout 的第一印象是異步執(zhí)行可以延遲,我們經常這樣實現:
setTimeout(() => {
console.log(‘Delay 3 seconds’);
},3000)
當 setTimeout 用得越來越多時,問題也出現了。有時函數會在 3 秒的書面延遲后 5 或 6 秒內執(zhí)行。怎么了?
讓我們從一個例子開始:
setTimeout(() => {
task();
},3000)
console.log('console');
按照我們之前的結論,setTimeout是異步的,應該先執(zhí)行console.log。
//console
//task()
去看看吧!這是正確的!然后我們修改之前的代碼:
setTimeout(() => {
task()
},3000)
sleep(10000000)
控制臺上的 task() 在 Chrome 中執(zhí)行需要超過 3 秒的時間。
此時,我們需要重新思考setTimeout的定義。
先說上面的代碼是如何執(zhí)行的:
- task() 進入事件表并注冊,定時器啟動。
- 執(zhí)行sleep,非常慢,非常慢,計時繼續(xù)。
- task()進入Event Queue,但是,sleep太慢無法執(zhí)行。
- sleep終于結束了,task()終于從Event Queue執(zhí)行到主線程。
上述過程完成后,我們知道setTimeout是一個在指定時間后將任務添加到Event Queue(本例中為task())的函數。
而且,由于是單線程任務,需要一個一個執(zhí)行,如果上一個任務耗時過長,我們只能等待。導致實際延遲超過 3 秒。
SetTimeout(fn,0) 是我們經常遇到的另一個代碼??梢粤⒓赐瓿蓡??
SetTimeout (fn,0) 指定任務將在主線程上最早可用的空閑時間執(zhí)行。這意味著一旦堆棧中的所有同步任務完成并且堆棧為空,主線程將立即執(zhí)行。例如:
//code1
console.log('one');
setTimeout(() => {
console.log('two')
},0);
// result
// one
// two
//code2
console.log('one');
setTimeout(() => {
console.log('two')
},3000);
// result
// one
// ... 3s later
// two
關于 setTimeout 要補充的一點是,即使主線程是空的,0 毫秒實際上也是無法到達的。根據 HTML 標準,最小值為 4 毫秒。有興趣的同學可以自行了解。
四、 恨與愛setInterval
說了 setTimeout,你不能錯過它的孿生兄弟 setInterval。它們是相似的,只是后者是循環(huán)執(zhí)行。對于執(zhí)行順序,setInterval 將按指定的時間間隔將注冊的函數放入事件隊列中。如果上一個任務耗時過長,也需要等待。
唯一需要注意的是,對于 setInterval(fn,ms),我們已經知道不是每 ms 秒執(zhí)行一次 fn,而是每 ms 秒進入 Event Queue。一旦 setInterval 的回調 fn 花費的時間超過了延遲 ms,時間間隔就完全不可見了。請讀者細細品味這句話。
五、 Promise 和 process.nextTick(callback)
我們已經看過傳統的計時器,然后,我們將探討 Promise 與 process.Nexttick(回調)的性能。
Promise 的定義和功能這里就不介紹了,process.nexttick(回調)類似于node.js 版本的“setTimeout”,在事件循環(huán)的下一次迭代中調用回調函數。
我們開始談正事吧。除了廣義的同步和異步任務,我們對任務有更詳細的定義:
- 宏任務:包括整個代碼腳本、setTimeout 和 setInterval
- 微任務:Promise、process.nexttick
不同類型的任務會進入對應的Event Queue。例如,setTimeout 和 setInterval 將進入同一個事件隊列。
事件循環(huán)的順序決定了 JS 代碼的執(zhí)行順序。輸入整體代碼(宏任務)后,第一個循環(huán)開始。然后,執(zhí)行所有微任務。然后再從宏任務開始,找一個任務隊列完成,然后,執(zhí)行所有的微任務。如果聽起來有點繞,我們用本文開頭的代碼來說明:
setTimeout(function() {
console.log('setTimeout');
})
new Promise(function(resolve) {
console.log('promise');
}).then(function() {
console.log('then');
})
console.log('console');
- 此代碼作為宏任務進入主線程。
- 當遇到 setTimeout 時,將其回調函數注冊并分發(fā)到宏任務 Event Queue。(注冊過程同上,下面不再贅述)。
- 然后,遇到一個 Promise,立即執(zhí)行 New Promise,然后將 then 函數分派到微任務事件隊列中。如果遇到console.log(),立即執(zhí)行。
- 好的,整個腳本作為第一個宏任務執(zhí)行。什么是微任務?我們發(fā)現 then 是在 microtask Event Queue 中執(zhí)行的。
- 好了,第一輪的 Event loop 已經結束了,讓我們開始第二輪,當然是從宏任務 Event Queue 開始。我們在宏任務Event Queue中找到setTimeout對應的回調函數,立即執(zhí)行。
- 結束。
事件循環(huán)、宏任務和微任務的關系如下圖所示:
讓我們看一些更復雜的代碼,看看你是否真的了解 JS 的工作原理:
console.log('1');
setTimeout(function() {
console.log('2');
process.nextTick(function() {
console.log('3');
})
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() {
console.log('5')
})
})
process.nextTick(function() {
console.log('6');
})
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8')
})
setTimeout(function() {
console.log('9');
process.nextTick(function() {
console.log('10');
})
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() {
console.log('12')
})
})
第一輪事件循環(huán)流程分析如下:
整個腳本作為第一個宏任務進入主線程,遇到console.log,打印1。
- 當遇到 setTimeout 時,它的回調函數被調度到宏任務事件隊列。我們稱之為 setTimeout1。
- 當遇到 process.nexttick() 時,將其回調函數調度到微任務事件隊列中。我們稱它為 process1。
- 如果遇到 Promise,直接執(zhí)行新的 Promise,打印 7,然后,分發(fā)到微任務 Event Queue,讓我們稱之為then1。
- 再次遇到setTimeout,它的回調函數被分發(fā)到宏任務Event Queue中,我們稱之為setTimeout2。
- 上表展示了第一輪Event loop的宏任務結束時各個Event Queue的情況。這時候已經輸出了1和7。
- 我們找到了兩個微任務 process1 和 then1。
- 執(zhí)行process1,輸出6。
- 執(zhí)行 then1 print 8。
好了,第一輪事件循環(huán)正式結束,本輪結果輸出1,7,6,8。所以第二個時間循環(huán)從 setTimeout1 宏任務開始:
首先,print2。接下來是process.nexttick(),它也被分派到微任務事件隊列中,稱為process2。新的 Promise 立即執(zhí)行輸出 4,然后也被分發(fā)到微任務事件隊列中,記為 then2。
- 第二輪事件循環(huán)宏任務完成,我們發(fā)現 process2 和 then2 微任務可以執(zhí)行。
- 3的輸出。
- 5的輸出。
- 第二個事件循環(huán)結束,第二輪輸出2,4,3,5。
- 第三個事件循環(huán)開始,此時只剩下setTimeout2,執(zhí)行。
- 所以它只print 9。
- 將 process.nexttick() 分發(fā)到微任務事件隊列。記得process 3。
- 只需執(zhí)行 new Promise,print 11。
- then 分配 microtask Event Queue,記為 then3。
- 第三輪事件循環(huán)宏任務執(zhí)行完成,執(zhí)行兩個微任務process3和then3。
- 10 的輸出。
- 12的輸出。
- 第三輪事件循環(huán)結束。第三輪輸出9,11,10,12。
整個代碼,一共經過了3次事件循環(huán),完整的輸出為1,7,6,8,2,4,3,5,9,11,10,12。
六、寫在最后
1、異步 JavaScript
我們從一開始就說過 JavaScript 是單線程語言,無論什么新的框架和語法實現被稱為異步,實際上都是以同步的方式模擬的,所以牢牢掌握單線程很重要。
2、事件循環(huán)
事件循環(huán)是實現異步 JavaScript 的一種方法,也是 JavaScript 的執(zhí)行機制。
3、JavaScript的執(zhí)行和運行
在 Node.js、瀏覽器、Ringo 等不同的環(huán)境中執(zhí)行和運行 JavaScript 是有很大區(qū)別的。雖然運行多指 JavaScript 解析引擎,但它是統一的。
4、立即設置
還有許多其他類型的微任務和宏任務,例如 setImmediate,它們不進行中介。
5、在一天結束時
JavaScript 是一種單線程語言。事件循環(huán)是 JavaScript 的執(zhí)行機制。
牢牢把握兩個基本點,以認真學習JavaScript為中心,早日實現成為前端高手的偉大夢想!??