一次性搞懂JavaScript 執行機制

ANFOUNNYSOUL發表於2018-07-20

你是否遭受到這樣的恐嚇?

一次性搞懂JavaScript 執行機制

你是否有過每個表示式前面都console一遍值去找執行順序?

一次性搞懂JavaScript 執行機制

看了很多js執行機制的文章似乎都是似懂非懂,到技術面問的時候,理不清思緒。總結了眾多文章的例子和精華,希望能幫到你們

JavaScript 怎麼執行的?

執行機制——事件迴圈(Event Loop)

通常所說的 JavaScript Engine (JS引擎)負責執行一個個 chunk (可以理解為事件塊)的程式,每個 chunk 通常是以 function 為單位,一個 chunk 執行完成後,才會執行下一個 chunk。下一個 chunk 是什麼呢?取決於當前 Event Loop Queue (事件迴圈佇列)中的隊首。

通常聽到的JavaScript Engine JavaScript runtime 是什麼?

  • Javascript Engine  :Js引擎,負責解釋並編譯程式碼,讓它變成能交給機器執行的程式碼(runnable commands)
  • Javascript runtime :Js執行環境,主要提供一些對外呼叫的介面 。比如瀏覽器環境:windowDOM。還有Node.js環境:require 、export

Event Loop Queue (事件迴圈佇列)中存放的都是訊息,每個訊息關聯著一個函式,JavaScript Engine (以下簡稱JS引擎)就按照佇列中的訊息順序執行它們,也就是執行 chunk

例如

setTimeout( function() {
    console.log('timeout')
}, 1000)複製程式碼

當JS引擎執行的時候,可以分為3步chunk

  1. setTimeout 啟動定時器(1000毫秒)執行
  2. 執行完畢後,得到機會將 callback 放入 Event Loop Queue
  3. 此 callback 執行

每一步都是一個chunk,可以發現,第2步,得到機會很重要,所以說即使延遲1000ms也不一定準的原因。因為如果有其他任務在前面,它至少要等其他訊息對應的程式都完成後才能將callback推入佇列,後面我們會舉個?


像這個一個一個執行chunk的過程就叫做Event Loop(事件迴圈)

按照阮老師的說法:

總體角度:主執行緒執行的時候產生棧(stack)和堆(heap),棧中的程式碼負責呼叫各種API,在任務佇列中加入事件(click,load,done),只要棧中的程式碼執行完畢後,就會去讀取任務佇列,依次執行那些事件所對應的回撥函式。

執行的機制流程

同步直接進入主執行緒執行,如果是非同步的,不進入主執行緒、而進入"任務佇列"(task queue)的任務,只有"任務佇列"通知主執行緒,某個非同步任務可以執行了,該任務才會進入主執行緒執行。主執行緒從"任務佇列"中讀取事件,這個過程是迴圈不斷的,所以整個的這種執行機制又稱為Event Loop(事件迴圈)。

我們都知道,JS引擎 對 JavaScript 程式的執行是單執行緒的,為了防止同時去操作一個資料造成衝突或者是無法判斷,但是 JavaScript Runtime(整個執行環境)並不是單執行緒的;而且幾乎所有的非同步任務都是併發的,例如多個 Job QueueAjaxTimerI/O(Node)等等。

而Node.js會略有不同,在node.js啟動時,建立了一個類似while(true)的迴圈體,每次執行一次迴圈體稱為一次tick,每個tick的過程就是檢視是否有事件等待處理,如果有,則取出事件極其相關的回撥函式並執行,然後執行下一次tick。node的Event Loop和瀏覽器有所不同。Event Loop每次輪詢:先執行完主程式碼,期中遇到非同步程式碼會交給對應的佇列,然後先執行完所有nextTick(),然後在執行其它所有微任務。

任務佇列

任務佇列task queue中有微任務佇列巨集任務佇列

  • 微任務佇列只有一個
  • 巨集任務可以有若干個

根據目前,我們先大概畫個草圖

一次性搞懂JavaScript 執行機制

具體部分後面會講,那先說說同步和非同步

執行機制——同步任務(synchronous)和非同步任務(asynchronous)

事件分為同步和非同步

同步任務

同步任務直接進入主執行緒進行執行
console.log('1');

var sub = 0;
for(var i = 0;i < 1000000000; i++) {
    sub++
}
console.log(sub);

console.log('2');
.....複製程式碼

會點程式設計的都知道,在列印出sub的值之前,系統是不會列印出2的。按照先進先出的順序執行chunk。

如果是Execution Context Stack(執行上下文堆疊)

function log(str) {
    console.log(str);
}
log('a');複製程式碼

從執行順序上,首先log('a')入棧,然後console.log('a')再入棧,執行console.log('a')出棧,log('a')再出棧。

非同步任務

非同步任務必須指定回撥函式,所謂"回撥函式"(callback),就是那些會被主執行緒掛起來的程式碼。非同步任務進入Event Table後,當指定的事情完成了,就將非同步任務加入Event Queue,等待主執行緒上的任務完成後,就執行Event Queue裡的非同步任務,也就是執行對應的回撥函式。

指定的事情可以是setTimeout的time?

var value = 1;
setTimeout(function(){
    value = 2;
}, 0)
console.log(value);  // 1

複製程式碼

從這個例子很容易理解,即使設定時間再短,setTimeout還是要等主執行緒執行完再執行,導致引用還是最初的value

?

console.log('task1');

setTimeout(()=>{ console.log('task2') },0);

var sub = 0;
for(var i = 0;i < 1000000000;i++) {
    sub++
}
console.log(sub);
console.log('task3');複製程式碼

一次性搞懂JavaScript 執行機制

分析一下

  • task1進入主執行緒立即執行
  • task2進入Event Table,註冊完事件setTimeout後進入Event Queue,等待主執行緒執行完畢
  • sub賦值後進入for迴圈自增,主執行緒一直被佔用
  • 計算完畢後列印出sub,主執行緒繼續chunk
  • task3進入主執行緒立即執行
  • 主執行緒佇列已清空,到Event Queue中執行任務,列印task2

不管for迴圈計算多久,只要主執行緒一直被佔用,就不會執行Event Queue佇列裡的任務。除非主線任務執行完畢。所有我們通常說的setTimeouttime是不標準的,準確的說,應該是大於等於這個time

來個?體驗一下結果

var sub = 0;
(function setTime(){
	let start = (new Date()).valueOf();//開始時間
	console.log('執行開始',start)
	setTimeout(()=>{ 
	   console.log('定時器結束',sub,(new Date()).valueOf()-start);//計算差異
	},0);
})();
for(var i = 0;i < 1000000000;i++) {
    sub++
}
console.log('執行結束')複製程式碼

實際上,延遲會遠遠大於預期,達到了3004毫秒

一次性搞懂JavaScript 執行機制

最後的計算結果是根據瀏覽器的執行速度和電腦配置差異而定,這也是setTimeout最容易被坑的一點。

AJAX怎麼算

那ajax怎麼算,作為日常使用最多的一種非同步,我們必須搞清楚它的執行機制。

console.log('start');

$.ajax({
    url:'xxx.com?user=123',
    success:function(res){
        console.log('success')
    }
})
setTimeout(() => {
    console.log('timeout')
},100);

console.log('end');複製程式碼

答案是不肯定的,可能是

start
end
timeout
success複製程式碼

也有可能是

start
end
success
timeout複製程式碼

前兩步沒有疑問,都是作為同步函式執行,問題原因出在ajax身上

前面我們說過,非同步任務必須有callback,ajax的callbacksuccess(),也就是隻有當請求成功後,觸發了對應的callback success()才會被放入任務佇列(Event Queue)等待主執行緒執行。而在請求結果返回的期間,後者的setTimeout很有可能已經達到了指定的條件(執行100毫秒延時完畢)將它的回撥函式放入了任務佇列等主執行緒執行。這時候可能ajax結果仍未返回...

Promise的執行機制

再加點料

console.log('執行開始');

setTimeout(() => {
 console.log('timeout') 
}, 0);

new Promise(function(resolve) {
    console.log('進入')
    resolve();
}).then(res => console.log('Promise執行完畢') )

console.log('執行結束');複製程式碼

先別繼續往下看,假設你是瀏覽器,你會怎麼執行,自我思考十秒鐘

一次性搞懂JavaScript 執行機制

這裡要注意,嚴格的來說,Promise 屬於 Job Queue,只有then才是非同步。

Job Queue是什麼

Job Queue是ES6新增的概念。

Job Queue和Event Loop Queue有什麼區別?

  • JavaScript runtime(JS執行環境)可以有多個Job Queue,但是隻能有一個Event Loop Queue。
  • JS引擎將當前chunk執行完會優先執行所有Job Queue,再去執行Event Loop Queue。
Promise 中的一個個 then 就是一種 Job Queue

分析流程:

  1. 遇到同步任務,進入主執行緒直接執行,列印出"執行開始"
  2. 遇到setTimeout非同步任務放入Event Table執行,滿足條件後放入Event Queue的巨集任務佇列等待主執行緒執行
  3. 執行Promise,放入Job Queue優先執行,執行同步任務列印出"進入"
  4. 返回resolve()觸發then回撥函式,放入Event Queue微任務佇列等待主執行緒執行
  5. 執行同步任務列印出"執行結束"
  6. 主執行緒清空,到Event Queue微任務佇列取出任務開始執行。列印出"Promise執行完畢"
  7. 微任務佇列清空,到巨集任務佇列取出任務執行,列印出"timeout"

? plus 

console.log("start");

setTimeout(() => {
  console.log("setTimeout");
}, 0);

new Promise((resolve) => {
  resolve();
})
.then(() => {
  return console.log("A1");
})
.then(() => {
  return console.log("A2");
});

new Promise((resolve) => {
  resolve();
})
.then(() => {
  return console.log("B1");
})
.then(() => {
  return console.log("B2");
})
.then(() => {
  return console.log("B3");
});

console.log("end");
複製程式碼

列印結果

一次性搞懂JavaScript 執行機制

運用剛剛說說的,分析一遍

  • setTimeout非同步任務,到Event Table執行完畢後將callback放入Event Queue巨集任務佇列等待主執行緒執行
  • Promise 放入Job Queue優先進入主執行緒執行,返回resolve(),觸發A1 then回撥函式放入微任務佇列中等待主執行緒執行
  • 到第二個Promise,同上,放入Job Queue執行,將B1 then回撥函式放入微任務佇列
  • 執行同步函式,直接進入主執行緒執行,列印出"end"
  • 無同步任務,開始從task Queue 也就是 Event Queue裡取出非同步任務開始執行
  • 首先取出隊首的A1 then()回撥函式開始執行,列印出"A1",返回promise觸發A2 then()回撥函式,新增到微任務隊首。此時隊首是B1 then()
  • 從微任務隊首取出B1 then回撥函式,開始執行,返回promise觸發B2 then()回撥函式,新增到微任務隊首,此時隊首是A2 then(),再取出A2 then()執行,這次沒有回撥
  • 繼續到微任務隊首拿回撥執行,重複輪詢列印出B2B3
  • 微任務執行完畢,到巨集任務隊首取出setTimeout的回撥函式放入主執行緒執行,列印出"setTimeout"

這樣的話,Promise應該是搞懂了,但是微任務和巨集任務?很多人對這個可能有點陌生,但是看完這個應該對這兩者區別有所瞭解

非同步任務分為巨集任務和微任務

巨集任務(macrotasks): setTimeout, setInterval, setImmediate(node.js), I/O, UI rendering
微任務(microtasks):process.nextTick(node.js), Promises, Object.observe, MutationObserver

先看一下具有特殊性的API:

process.nextTick

node方法,process.nextTick可以把當前任務新增到執行棧的尾部,也就是在下一次Event Loop(主執行緒讀取"任務佇列")之前執行。也就是說,它指定的任務一定會發生在所有非同步任務之前。和setTimeout(fn,0)很像。

process.nextTick(callback)
複製程式碼

setImmediate

Node.js0.8以前是沒有setImmediate的,在當前"任務佇列"的尾部新增事件,官方稱setImmediate指定的回撥函式,類似於setTimeout(callback,0),會將事件放到下一個事件迴圈中,所以也會比nextTick慢執行,有一點——需要了解setImmediatenextTick的區別。nextTick雖然非同步執行,但是不會給其他io事件執行的任何機會,而setImmediate是執行於下一個event loop。總之process.nextTick()的優先順序高於setImmediate

setImmediate(callback)複製程式碼

MutationObserver

一定發生在setTimeout之前,你可以把它看成是setImmediateMutationObserver是一個構造器,接受一個callback引數,用來處理節點變化的回撥函式,返回兩個引數

  • mutations:節點變化記錄列表(sequence<MutationRecord>)
  • observer:構造MutationObserver物件。
var observe = new MutationObserver(function(mutations,observer){
        // code...
})複製程式碼

在這不說過多,可以去了解下具體用法

Object.observe

Object.observe方法用於為物件指定監視到屬性修改時呼叫的回撥函式

Object.observe(obj, function(changes){
   changes.forEach(function(change) {
        console.log(change,change.oldValue);
    });
});複製程式碼
什麼情況下才會觸發?
  • 原始JavaScript物件中的變化
  • 當屬性被新增、改變、或者刪除時的變化
  • 當陣列中的元素被新增或者刪除時的變化
  • 物件的原型發生的變化

來個大?

總結:

任務優先順序

同步任務 >>>  process.nextTick >>> 微任務(ajax/callback) >>> setTimeout = 巨集任務 ??? setImmediate

setImmediate是要等待下一次事件輪詢,也就是本次結束後執行,所以需要畫???

沒有把Promise的Job Queue放進去是因為可以當成同步任務來進行處理。要明確的一點是,它是嚴格按照這個順序去執行的,每次執行都會把以上的流程走一遍,都會再次輪詢走一遍,然後把處理對應的規則。

拿個別人的?加點料,略微做一下修改,給大家分析一下

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')
    })
}, 1000); //新增了1000ms
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')
    })
})

setImmediate(function(){//新增setImmediate函式
    console.log('13')
})複製程式碼

第一遍Event Loop 

  • 走到1的時候,同步任務直接列印
  • 遇到setTimeout,進入task 執行1000ms延遲,此時未達到,不管它,繼續往下走。
  • 遇到process.nextTick,放入執行棧隊尾(將於非同步任務執行前執行)。
  • 遇到Promise 放入 Job Queue,JS引擎當前無chunk,直接進入主執行緒執行,列印出7
  • 觸發resolve(),將then 8 放入微任務佇列等待主執行緒執行,繼續往下走
  • 遇到setTimeout,執行完畢,將setTimeout 9 的 callback 其放入巨集任務佇列
  • 遇到setImmediate,將其callback放入Event Table,等待下一輪Event Loop執行

第一遍完畢  17

當前佇列 

一次性搞懂JavaScript 執行機制

Number two  Ready Go!

  • 無同步任務,準備執行非同步任務,JS引擎一看:"嘿!好傢伙,還有個process",然後取出process.nextTick的回撥函式執行,列印出6
  • 再繼續去微任務隊首取出then 8,列印出8
  • 微任務佇列清空了,就到巨集任務佇列取出setTimeout 9 callback執行,列印出9
  • 繼續往下執行,又遇到process.nextTick 10,放入Event Queue等待執行
  • 遇到Promise ,將callback 放入 Job Queue,當前無chunk,執行列印出 11
  • 觸發resolve(),新增回撥函式then 12,放入微任務佇列

本次Event Loop還沒有結束,同步任務執行完畢,目前任務佇列

一次性搞懂JavaScript 執行機制

  • 再取出process.nextTick 10,列印出10
  • 去微任務佇列,取出then 12 執行,列印出12
  • 本次Event Loop輪詢結束 ,取出setImmediate列印出13

第二遍輪詢完畢,列印出了 68911101213

當前沒有任務了,過了大概1000ms,之前的setTimeout 延遲執行完畢了,放入巨集任務

  • setTimeout進入主執行緒開始執行。
  • 遇到同步任務,直接執行,列印出2
  • 遇到process.nextTick,callback放入Event Queue,等待同步任務執行完畢
  • 遇到Promise,callback放入Job Queue,當前無chunk,進入主執行緒執行,列印出4
  • 觸發resolve(), 將then 5放入微任務佇列

同步執行完畢,先看下目前的佇列

一次性搞懂JavaScript 執行機制

剩下的就很輕鬆了

  • 取出process.nextTick 3 callback執行,列印出3
  • 取出微任務 then 5,列印出 5
  • over

總體列印順序

1
7
6
8
9
11
10
12
13
2
4
3
5複製程式碼

emmm...可能需要多看幾遍消化一下。

Web Worker

現在有了Web Worker,它是一個獨立的執行緒,但是仍未改變原有的單執行緒,Web Worker只是個額外的執行緒,有自己的記憶體空間(棧、堆)以及 Event Loop Queue。要與這樣的不同的執行緒通訊,只能通過 postMessage。一次 postMessage 就是在另一個執行緒的 Event Loop Queue 中加入一條訊息。說到postMessage可能有些人會聯想到Service Work,但是他們是兩個截然不同

Web Worker和Service Worker的區別

Service Worker:
處理網路請求的後臺服務。完美的離線情況下後臺同步或推送通知的處理方案。不能直接與DOM互動。通訊(頁面和Service Worker之間)得通過postMessage方法 ,有另一篇文章是關於本地儲存,其中運用到頁面離線訪問Service Work of  Google PWA,有興趣的可以看下

Web Worker:
模仿多執行緒,允許複雜的指令碼在後臺執行,所以它們不會阻止其他指令碼的執行。是保持您的UI響應的同時也執行處理器密集型功能的完美解決方案。不能直接與DOM互動。通訊必須通過postMessage方法

如果意猶未盡可以嘗試去深入Promise另一篇文章——一次性讓你懂async/await,解決回撥地獄


相關文章