討論event loop要做到以下兩點
- 首先要確定好上下文,nodejs和瀏覽器的event loop是兩個有明確區分的事物,不能混為一談。
- 其次,討論一些js非同步程式碼的執行順序時候,要基於node的原始碼而不是自己的臆想。
簡單來講:
- nodejs的event是基於libuv,而瀏覽器的event loop則在html5的規範中明確定義。
- libuv已經對event loop作出了實現,而html5規範中只是定義了瀏覽器中event loop的模型,具體實現留給了瀏覽器廠商。
瀏覽器中的event loop
瀏覽器事件環中js分為兩部分,一個叫heap(堆),一個叫stack(棧)。物件放在heap(堆)裡,常見的基礎型別和函式放在stack(棧)裡,函式執行的時候在棧裡執行。棧裡函式執行的時候可能會調一些Dom操作,ajax操作和setTimeout定時器,這時候要等stack(棧)裡面的所有程式先走(注意:棧裡的程式碼是先進後出),走完後再走WebAPIs,WebAPIs執行後的結果放在callback queue(回撥的佇列裡,注意:佇列裡的程式碼先放進去的先執行),也就是當棧裡面的程式走完之後,再從任務佇列中讀取事件,將佇列中的事件放到執行棧中依次執行,這個過程是迴圈不斷的。
簡單來講:
- 1.所有同步任務都在主執行緒上執行,形成一個執行棧
- 2.主執行緒之外,還存在一個任務佇列。只要非同步任務有了執行結果,就在任務佇列之中放置一個事件。
- 3.一旦執行棧中的所有同步任務執行完畢,系統就會讀取任務佇列,將佇列中的事件放到執行棧中依次執行
- 4.主執行緒從任務佇列中讀取事件,這個過程是迴圈不斷的
整個的這種執行機制又稱為Event Loop(事件迴圈)
概念中首先要明白是:stack(棧)和queue(佇列)的區別,它們是怎麼去執行的?
棧方法LIFO(Last In First Out):先進後出(先進的後出),典型的就是函式呼叫。
//執行上下文棧 作用域
var a = "aa";
function one(){
let a = 1;
two();
function two(){
let b = 2;
three();
function three(){
console.log(b)
}
}
}
console.log(a);
one();
複製程式碼
aa
2
複製程式碼
圖解執行原理:
執行棧裡面最先放的是全域性作用域(程式碼執行有一個全域性文字的環境),然後再放one, one執行再把two放進來,two執行再把three放進來,一層疊一層。那麼怎麼出呢,怎麼銷燬的呢?
最先走的肯定是three,因為two要是先銷燬了,那three的程式碼b就拿不到了,所以是先進後出(先進的後出),所以,three最先出,然後是two出,再是one出。
佇列方法FIFO(First In First Out)
(隊頭)[1,2,3,4](隊尾) 進的時候從隊尾依次進1,2,3,4 出的時候從對頭依次出1,2,3,4
瀏覽器事件環中程式碼執行都是按棧的結果去執行的,但是我們呼叫完多執行緒的方法(WebAPIs),這些多執行緒的方法是放在佇列裡的,也就是先放到佇列裡的方法先執行。
那什麼時候WebAPIs裡的方法會再執行呢?
比如:stack(棧)裡面都走完之後,就會依次讀取任務佇列,將佇列中的事件放到執行棧中依次執行,這個時候棧中又出現了事件,這個事件又去呼叫了WebAPIs裡的非同步方法,那這些非同步方法會在再被呼叫的時候放在佇列裡,然後這個主執行緒(也就是stack)執行完後又將從任務佇列中依次讀取事件,這個過程是迴圈不斷的。
下面通過列子來說明:
例子1
console.log(1);
console.log(2);
setTimeout(function(){
console.log(3);
})
setTimeout(function(){
console.log(4);
})
console.log(5);
複製程式碼
// 結果
1
2
5
3
4
複製程式碼
1、首先執行棧裡面的同步程式碼
1
2
5
2、棧裡面的setTimeout事件會依次放到任務佇列中,當棧裡面都執行完之後,再依次從從任務佇列中讀取事件往棧裡面去執行。
3
4
例子2
console.log(1);
console.log(2);
setTimeout(function(){
console.log(3);
setTimeout(function(){
console.log(6);
})
})
setTimeout(function(){
console.log(4);
setTimeout(function(){
console.log(7);
})
})
console.log(5)
複製程式碼
// 結果
1
2
5
3
4
6
7
複製程式碼
1、首先執行棧裡面的同步程式碼
1
2
5
2、棧裡面的setTimeout事件會依次放到任務佇列中,當棧裡面都執行完之後,再依次從從任務佇列中讀取事件往棧裡面去執行。
3
4
3、當執行棧開始依次執行setTimeout時,會將setTimeout裡面的巢狀setTimeout依次放入佇列中,然後當執行棧中的setTimeout執行完畢後,再依次從從任務佇列中讀取事件往棧裡面去執行。
6
7
例子3
console.log(1);
console.log(2);
setTimeout(function(){
console.log(3);
setTimeout(function(){
console.log(6);
})
},400)
setTimeout(function(){
console.log(4);
setTimeout(function(){
console.log(7);
})
},100)
console.log(5)
複製程式碼
// 結果
1
2
5
4
7
3
6
複製程式碼
在例子2的基礎上,如果設定了setTimeout的時間,那就是按setTimeout的成功時間依次執行。
如上:這裡的順序是1,2,5,4,7,3,6。也就是隻要兩個set時間不一樣的時候 ,就set時間短的先走完,包括set裡面的回撥函式,再走set時間慢的。(因為只有當時間到了的時候,才會把set放到佇列裡面去,這一點跟nodejs中的set設定了時間的機制差不多,可以看nodejs中的例子6,也是會先走完時間短,再走時間慢的。)
例子4
當觸發回撥函式時,會將回撥函式放到佇列中。永遠都是棧裡面執行完後再從任務佇列中讀取事件往棧裡面去執行。
setTimeout(function(){
console.log('setTimeout')
},4)
for(var i = 0;i<10;i++){
console.log(i)
}
複製程式碼
// 結果
0
1
2
3
4
5
6
7
8
9
setTimeout
複製程式碼
在學習nodejs事件環之前,我們先了解一下巨集任務和微任務在瀏覽器中的執行機制。也是面試中經常會被問到的。
巨集任務和微任務
任務可分為巨集任務和微任務,巨集任務和微任務都是佇列
- macro-task(巨集任務): setTimeout, setInterval, setImmediate, I/O
- micro-task(微任務): process.nextTick, 原生Promise(有些實現的promise將then方法放到了巨集任務中),Object.observe(已廢棄), MutationObserver不相容的,MessageChannel(訊息通道,類似worker)
Promise.then(原始碼見到Promise就用setTimeout),then方法不應該放到巨集任務中(原始碼中寫setTimeout是迫不得已的),預設瀏覽器的實現這個then放到了微任務中。例如:
console.log(1)
let promise = new Promise(function(resolve,reject){
console.log(3)
resolve(100)
}).then(function(data){
console.log(100)
})
console.log(2)
複製程式碼
1
3
2
100
複製程式碼
先走console.log(1),這裡的new Promise()是立即執行的,所以是同步的,由於這個then在console.log(2)後面執行的,所以不是同步,是非同步的。
那這跟巨集任務和微任務有什麼關係?
我們可以加一個setTimeout(巨集任務)對比一下:
console.log(1)
setTimeout(function(){
console.log('setTimeout')
},0)
let promise = new Promise(function(resolve,reject){
console.log(3)
resolve(100)
}).then(function(data){
console.log(100)
})
console.log(2)
複製程式碼
1
3
2
100
setTimeout
複製程式碼
結論:在瀏覽器事件環機制中,同步程式碼先執行 執行是在棧中執行的,然後微任務會先執行,再執行巨集任務
MutationObserver例子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<!-- 當dom載入完畢後,來一句渲染完成 -->
<script>
console.log(1)
let observe = new MutationObserver(function(){
console.log('渲染完成')
});
<!--監控app的節點列表是否渲染完成-->
observe.observe(app,{
childList:true
})
for(var i = 0;i<100;i++){
let p = document.createElement('p');
document.getElementById('app').appendChild(p);
}
for(var i = 0;i<100;i++){
let p = document.createElement('p');
document.getElementById('app').appendChild(p);
}
console.log(2)
</script>
</body>
</html>
複製程式碼
// 結果
1
2
渲染完成
複製程式碼
MessageChannel例子
vue中nextTick的實現原理就是通過這個方法實現的
console.log(1);
let channel = new MessageChannel();
let port1 = channel.port1;
let port2 = channel.port2;
port1.onmessage = function(e){
console.log(e.data);
}
console.log(2);
port2.postMessage(100);
console.log(3)
複製程式碼
// 瀏覽器中console結果 會等所有同步程式碼執行完再執行,所以是微任務晚於同步的
1
2
3
100
複製程式碼
nodejs中的event loop
node的特點:非同步 非阻塞i/o node通過LIBUV這個庫自己實現的非同步,預設的情況下是沒有非同步的方法的。
nodejs中的event loop有6個階段,這裡我們重點關注poll階段(fs的i/o操作,對檔案的操作,i/o裡面的回撥函式都放在這個階段)
event loop的每一次迴圈都需要依次經過上述的階段。 每個階段都有自己的callback佇列,每當進入某個階段,都會從所屬的佇列中取出callback來執行,當佇列為空或者被執行callback的數量達到系統的最大數量時,進入下一階段。這六個階段都執行完畢稱為一輪迴圈。下面通過列子來說明:
例子1
console.log(1);
console.log(2);
setTimeout(function(){
console.log('setTimeout1')
Promise.resolve().then(function(){
console.log('Promise')
})
})
setTimeout(function(){
console.log('setTimeout2')
})
複製程式碼
-> node eventloop.js
1
2
setTimeout1
setTimeout2
Promise
複製程式碼
圖解執行原理:
1、首先執行完棧裡面的程式碼
console.log(1);
console.log(2);
2、從棧進入到event loop的timers階段,由於nodejs的event loop是每個階段的callback執行完畢後才會進入下一個階段,所以會列印出timers階段的兩個setTimeout的回撥
setTimeout1
setTimeout2
3、由於node event中微任務不在event loop的任何階段執行,而是在各個階段切換的中間執行,即從一個階段切換到下個階段前執行。所以當times階段的callback執行完畢,準備切換到下一個階段時,執行微任務(列印出Piromise),
Promise
如果例子1看懂了,以下例子2-例子6自己走一遍。需要注意的是例子6,當setTimeout設定了時間,優先按時間順序執行(瀏覽器事件環中例子3差不多)。例子7,例子8是重點。
例子2
console.log(1);
console.log(2);
setTimeout(function(){
console.log('setTimeout1')
Promise.resolve().then(function(){
console.log('Promise')
})
},1000)
setTimeout(function(){
console.log('setTimeout2')
},1000)
複製程式碼
-> node eventloop.js
1
2
setTimeout1
Promise
setTimeout2
複製程式碼
例子3
console.log(1);
console.log(2);
setTimeout(function(){
console.log('setTimeout1')
Promise.resolve().then(function(){
console.log('Promise')
})
},2000)
setTimeout(function(){
console.log('setTimeout2')
},1000)
複製程式碼
-> node eventloop.js
1
2
setTimeout2
setTimeout1
Promise
複製程式碼
例子4
console.log(1);
console.log(2);
setTimeout(function(){
console.log('setTimeout1')
Promise.resolve().then(function(){
console.log('Promise1')
})
})
setTimeout(function(){
console.log('setTimeout2')
Promise.resolve().then(function(){
console.log('Promise2')
})
})
複製程式碼
-> node eventloop.js
1
2
setTimeout1
setTimeout2
Promise1
Promise2
複製程式碼
例子5
console.log(1);
console.log(2);
setTimeout(function(){
console.log('setTimeout1')
Promise.resolve().then(function(){
console.log('Promise1')
})
},1000)
setTimeout(function(){
console.log('setTimeout2')
Promise.resolve().then(function(){
console.log('Promise2')
})
},1000)
複製程式碼
-> node eventloop.js
1
2
setTimeout1
setTimeout2
Promise1
Promise2
複製程式碼
例子6
console.log(1);
console.log(2);
setTimeout(function(){
console.log('setTimeout1')
Promise.resolve().then(function(){
console.log('Promise1')
})
},2000)
setTimeout(function(){
console.log('setTimeout2')
Promise.resolve().then(function(){
console.log('Promise2')
})
},1000)
複製程式碼
-> node eventloop.js
1
2
setTimeout2
Promise2
setTimeout1
Promise1
複製程式碼
例子7:setImmediate() vs setTimeout()
- setImmediate 設計在poll階段完成時執行,即check階段;
- setTimeout 設計在poll階段為空閒時,且設定時間到達後執行;但其在timer階段執行
其二者的呼叫順序取決於當前event loop的上下文,如果他們在非同步i/o callback之外呼叫,其執行先後順序是不確定的
setTimeout(function timeout () {
console.log('timeout');
},0);
setImmediate(function immediate () {
console.log('immediate');
});
複製程式碼
-> node eventloop.js
timeout
immediate
-> node eventloop.js
immediate
timeout
複製程式碼
但當二者在非同步i/o callback內部呼叫時,總是先執行setImmediate,再執行setTimeout
這是因為fs.readFile callback執行完後,程式設定了timer 和 setImmediate,因此poll階段不會被阻塞進而進入check階段先執行setImmediate,後進入timer階段執行setTimeout
var fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
複製程式碼
$ node eventloop.js
immediate
timeout
複製程式碼
例子8:process.nextTick()
process.nextTick()不在event loop的任何階段執行,而是在各個階段切換的中間執行,即從一個階段切換到下個階段前執行。
function Fn(){
this.arrs;
process.nextTick(()=>{
this.arrs();
})
}
Fn.prototype.then = function(){
this.arrs = function(){console.log(1)}
}
let fn = new Fn();
fn.then();
複製程式碼
-> node eventloop.js
1
複製程式碼
不加process.nextTick,new Fn()的時候,this.arrs是undefind,this.arrs()執行會報錯;
加了process.nextTick,new Fn()的時候,this.arrs()不會執行(因為process.nextTick是微任務,只有在各個階段切換的中間執行,所以它會等到同步程式碼執行完之後才會執行)這個時候同步程式碼fn.then()執行=>this.arrs = function(){console.log(1)},this.arrs變成了一個函式,同步執行完後再去執行process.nextTick(()=>{this.arrs();})就不會報錯。
需要注意的是:nextTick千萬不要寫遞迴,可以放一些比setTimeout優先執行的任務
// 死迴圈,會一直執行微任務,卡機
function nextTick(){
process.nextTick(function(){
nextTick();
})
}
nextTick()
setTimeout(function(){
},499)
複製程式碼
最後再來段程式碼加深理解
var fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
process.nextTick(()=>{
console.log('nextTick3');
})
});
process.nextTick(()=>{
console.log('nextTick1');
})
process.nextTick(()=>{
console.log('nextTick2');
})
});
複製程式碼
-> node eventloop.js
nextTick1
nextTick2
setImmediate
nextTick3
setTimeout
複製程式碼
1、從poll —> check階段,先執行process.nextTick,
nextTick1
nextTick2
2、然後進入check,setImmediate,
setImmediate
3、執行完setImmediate後,出check,進入close callback前,執行process.nextTick
nextTick3
4、最後進入timer執行setTimeout
setTimeout
結論:在nodejs事件環機制中,微任務是在各個階段切換的中間去執行的。
最後
-
在瀏覽器的事件環機制中,我們需要了解的是棧和佇列是怎麼去執行的。
棧:先進後出;佇列:先進先出。
所有程式碼在棧中執行,棧中的DOM,ajax,setTimeout會依次進入到佇列中,當棧中程式碼執行完畢後,有微任務先會將微任務依次從佇列中取出放到執行棧中執行,最後再依次將佇列中的事件放到執行棧中依次執行。
-
在nodejs的事件環機制中,我們需要了解的是node的執行機制是階段型的,微任務不屬於任何階段,而是在各個階段切換的中間執行。nodejs把事件環分成了6階段,這裡需要注意的是,當執行棧裡的同步程式碼執行完畢切換到node的event loop時也屬於階段切換,這時候也會先去清空微任務。
-
微任務和巨集任務
macro-task(巨集任務): setTimeout, setInterval, setImmediate, I/O
micro-task(微任務): process.nextTick, 原生Promise(有些實現的promise將then方法放到了巨集任務中),Object.observe(已廢棄), MutationObserver不相容的
問題
如果在執行巨集任務的過程中又發現了回撥中有微任務,會把這個微任務提前到所有巨集任務之前,等到這個微任務完成後再繼續執行巨集任務嗎?
console.log(1);
setTimeout(function(){
console.log(2);
Promise.resolve(1).then(function(){
console.log('promise1')
})
})
setTimeout(function(){
console.log(3)
Promise.resolve(1).then(function(){
console.log('promise2')
})
})
setTimeout(function(){
console.log(4)
Promise.resolve(1).then(function(){
console.log('promise3')
})
})
複製程式碼
// node中 每個階段切換中間執行微任務
1
2
3
4
promise1
promise2
promise3
複製程式碼
// 瀏覽器中 先走微任務
1
VM59:3 2
VM59:5 promise1
VM59:9 3
VM59:11 promise2
VM59:15 4
VM59:17 promise3
複製程式碼
以下例子也可以看看
// 例子1
console.log(1);
setTimeout(function(){
console.log(2);
Promise.resolve(1).then(function(){
console.log('promise1')
setTimeout(function(){
console.log(3)
Promise.resolve(1).then(function(){
console.log('promise2')
})
})
})
})
複製程式碼
// node
1
2
promise1
3
promise2
複製程式碼
// 瀏覽器
1
VM70:3 2
VM70:5 promise1
VM70:8 3
VM70:10 promise2
複製程式碼
// 例子2
console.log(11);
setTimeout(function(){
console.log(2);
Promise.resolve(1).then(function(){
console.log('promise1')
})
setTimeout(function(){
console.log(3)
Promise.resolve(1).then(function(){
console.log('promise2')
})
})
})
複製程式碼
// node
11
2
promise1
3
promise2
複製程式碼
// 瀏覽器
11
VM73:4 2
VM73:6 promise1
VM73:9 3
VM73:11 promise2
複製程式碼