瀏覽器事件環
用例項和知識點描述帶您清晰的瞭解瀏覽器事件環的每一步;
棧和佇列
在計算機記憶體中存取資料, 基本的資料結構分為棧和佇列
-
棧(Stack)是一種後進先出的資料結構; 棧的特點是 操作只在一端進行, 一般來說, 棧的操作只有兩種: 進棧和出棧; 第一個進棧的資料總是最後一個才出來
-
佇列(Queue)和棧類似, 但是它是先進先出的資料結構,它的特點是 操作在佇列兩端進行, 從一端進入再從另一端出來; 先進入(從A端)的總是先出來(從B端)
名稱 | 進出特點 | 端的數量 |
---|---|---|
棧 | 後進先出 | 進出都在同一端 |
佇列 | 先進先出 | 進出是在不同端 |
- 佇列好比一條隧道, (車)從隧道的一端(入口)進入, 從隧道的另一端(出口)出來
// 佇列執行時按照放置的順序依次執行
setTimeout(function(){
console.log(1)
});
setTimeout(function(){
console.log(2)
});
setTimeout(function(){
console.log(3)
});
// => 1 2 3
複製程式碼
- 棧好比樓梯, 上樓時第一個踩的樓梯也就是下樓時最後踩的一個樓梯
// 在JavaScript中函式的執行就是一個典型的入棧與出棧的過程
function a(){
console.log('a')
function b(){
console.log('b');
function c(){
console.log('c');
}
c();
}
b();
}
a();
// => a b c
// 函式呼叫順序是a b c, 而作用域銷燬的過程依次是c b a
複製程式碼
單執行緒和非同步
JavaScript是單執行緒的, 這裡所謂的單執行緒指的是主執行緒是單執行緒;
-
為什麼不是多執行緒呢? JavaScript最初設計是執行在瀏覽器中的, 假定是多執行緒, 有多個執行緒同時操作DOM, 豈不很混亂! 那會以哪個為準呢?
-
JavaScript為單執行緒, 在一個執行緒中程式碼會一行一行往下走,直到程式執行完畢; 若執行期間遇到較為費時的操作, 那隻能等待了;
-
單執行緒的設計使得語言的執行效率變差, 為了利用多核CPU的效能,javascript語言支援非同步程式碼; 當有較為費時的操作時, 可將任務寫為非同步; 主執行緒在執行過程中遇到非同步程式碼, 會先將該非同步任務掛起, 繼續執行後面的同步程式碼, 待同步執行完畢再回過頭來, 檢查是否有非同步任務, 如果有非同步任務就執行它;
PS: Java君加班有點累, 他想燒水衝一杯咖啡, 如果採用同步執行方式,那他就傻傻地等待,等水開了再衝咖啡;
PS: Java君加班有點累, 他想燒水衝一杯咖啡, 如果採用非同步執行方式,那麼他在等待水燒開之前,他可以聽聽歌,刷刷抖音啥的,等水開了再衝咖啡;
(-很明顯非同步的方式效率會高一些);
JavaScript是怎麼執行的
JavaScript程式碼是在棧裡執行的, 不論是同步還是非同步; 程式碼分為同步程式碼和非同步程式碼, 非同步程式碼又分為: {巨集任務} 和 [微任務]
JavaScript是解釋型語言,它的執行過程是這樣的:
- 從上到下依次解釋每一條js語句
- 若是同步任務, 則將其壓入一個棧(主執行緒); 如果是非同步任務,就放到一個任務佇列裡面;
- 開始執行棧裡面的同步任務,直到將棧裡的所有任務都走完, 此時棧被清空;
- 回頭檢查非同步佇列,如果有非同步任務完成了,就生成一個事件並註冊回撥(將非同步的回撥放到佇列裡面), 再將回撥函式取出壓入棧中執行;
- 棧中的非同步回撥執行完成後再去檢查,直到非同步佇列都清空,程式執行結束
從以上步驟可以看出,不論同步還是非同步, 都是在棧裡執行的, 棧裡的任務執行完成後一遍又一遍地回頭檢查佇列,這種方式就是所謂的"事件環"
事件佇列
// 先看個demo吧
console.log('start');
setTimeout(()=>{
console.log('hello');
}, 1000);
console.log('end');
// start end hello 上面程式碼執行後, 輸出'start' 'end', 大約1s之後輸出'hello'
// ? 為什麼'hello'不在end之前輸出呢
複製程式碼
- 解析
- setTimeout是一個非同步函式, 也就是說當我們設定一個延遲函式的時候, setTimeout非同步函式並不會阻塞程式碼執行, 程式還是會往下執行; 與此同時,它會在瀏覽事件列表中進行標記;
- 當延遲時間結束之後(準確說應該是當非同步完成後), 事件列表會將標記的非同步函式【非同步函式的回撥函式】新增到事件佇列(Task Queue)中
- 當主棧中的程式碼執行完畢, 棧為空時, JS引擎便檢查事件佇列, 如果不為空的話,事件佇列便將第一個任務壓入執行棧中執行;
console.log('start');
setTimeout(() => {
console.log('hello');
},0);
console.log('end');
// start end hello
// 將上例微微調整,發現輸出結果還是一樣的
// 因為setTimeout的回撥函式只是會被新增到(事件)佇列中,而不會立即執行。 再回頭
複製程式碼
- 解析:
- 因為setTimeout是非同步函式, 首先它會被(事件列表)標記(即掛起);
- setTimeout的延遲時間0並非真正是0, 在瀏覽器應該是4ms;
- 延遲時間到達(即非同步任務完成),setTimeout的回撥會被放入事件佇列(靜靜地等待主棧中的同步程式碼執行);
- 當執行棧(即主棧)中的任務(同步任務)執行完畢, 執行棧為空; // 輸出了 start end
- 執行棧為空後, 回頭檢查事件佇列, (發現佇列裡面有任務[函式]待執行)將佇列中註冊的任務(即:非同步函式完成後的回撥函式)壓入執行棧執行; // 輸出 hello
let promise = new Promise(function(resolve, reject) {
console.log('Promise');
resolve('Sucess');
});
promise.then((data)=>{
console.log(data);
});
console.log('Hello World!');
// 'Promise' 'Hello World!' 'Sucess'
複製程式碼
- 解析:
- new Promise()例項時的函式引數(執行器excutor)會立即執行; // 輸出 'Promise'
- promise.then是非同步函式, 它會被先放入事件佇列;
- 同步任務console.log('Hello World!');執行完畢後主棧被清空 // 輸出'Hello World!'
- 回頭檢查事件佇列,發現佇列裡面有任務, 將其壓入主棧執行; // 輸出'Sucess'
微任務與巨集任務
之前說到,非同步任務又分為: 巨集任務和微任務, 那他們是怎樣執行的呢?
- 在瀏覽器的執行環境中,總是先執行小的,再執行大的; 也就是說先執行微任務再執行巨集任務;
- 巨集任務有: setImmediate(IE) > setTimeout setInterval
- 微任務有: promise.then > MutationObserver > MessageChannel
- 任務佇列中,在每一次事件迴圈中,巨集任務只會提取一個執行, 而微任務會一直提取,直到微任務佇列為空為止;
- 如果某個微任務被推入到執行棧中,那麼當主執行緒任務執行完成後,會迴圈呼叫該佇列任務中的下一個任務來執行,直到該任務佇列到最後一個任務為止;
- 事件迴圈每次只會入棧一個巨集任務,主執行緒執行完成該任務後又會檢查微任務佇列,並完成裡面所有的任務後再執行巨集任務
記憶
- js程式碼執行順序:同步程式碼會先於非同步程式碼; 非同步任務的微任務會比非同步任務巨集任務先執行
- js程式碼預設先執行主棧中的程式碼,主棧中的任務執行完後, 開始執行清空微任務操作
- 清空微任務執行完畢,取出第一個巨集任務到主棧中執行
- 第一個巨集任務執行完後,如果有微任務會再次去執行清空微任務操作,之後再去取巨集任務
上述步驟就形成事件環
// 檢視setTimeout和Promise.then的不同
console.log(1);
setTimeout(()=>{
console.log(2);
Promise.resolve().then(()=>{
console.log(6);
});
}, 0);
Promise.resolve(3).then((data)=>{
console.log(data); // 3
return data + 1;
}).then((data)=>{
console.log(data) // 4
setTimeout(()=>{
console.log(data+1) // 5
return data + 1;
}, 1000)
}).then((data)=>{
console.log(data); // 上一個then沒有任何返回值, 所以為undefined
});
// 1 3 4 undefined 2 6 5
複製程式碼
- 解析:
- 主棧開始執行, 遇到同步程式碼
console.log(1);
,將其執行, 輸出 1 - 主棧繼續往下執行, 遇到非同步函式
setTimeout(()=>{ console.log(2); }, 0)
, 將其放入巨集任務佇列,此時巨集任務佇列:[s1] - 主棧繼續往下執行, 遇到非同步函式promise.then將其放入微任務佇列, 此時微任務佇列[p1(列印3,返回3+1)]
- 主棧繼續往下執行, 遇到非同步函式promise.then將其放入微任務佇列, 此時微任務佇列[p1, p2(列印4)]
- 主棧繼續往下執行, 遇到非同步函式promise.then將其放入微任務佇列, 此時微任務佇列[p1, p2, p3]
- 主棧的同步程式碼執行完畢後, 棧裡面的任務已空, 回頭檢查發現有巨集任務佇列[s1]、微任務佇列[p1, p2, p3]
- 清空微任務佇列(即微任務佇列中的任務挨個的執行,直到全部執行完畢為止) 清空微任務流程
- 把微任務佇列裡面的p1拿到主棧執行; // 輸出 3, 將data + 1(4)作為下一個then的成功值返回
- 把微任務佇列裡面的p2拿到主棧執行; // 輸出 4
- 在執行p2時遇到了
setTimeout(()=>{ console.log(data+1); return data + 1; })
,將其放入巨集任務佇列(先標記,1s後非同步執行完成後再將非同步函式的回撥放入佇列), 此時巨集任務佇列:[s1,s2] - 主棧繼續往下執行, 把微任務佇列裡面的p3拿到主棧執行, 因為上一個then未顯示的返回任何值, 因此data為undefined, 執行完畢後輸出 undefined
- 主棧繼續往下執行, 發現微任務佇列已被清空, 此時提取巨集任務佇列中的第一個s1放到主棧裡面執行, 執行後輸出 2
- s1在輸出2之後, 遇到了非同步函式promise.then, 將其放入微任務佇列, 此時微任務佇列[p4]
- 第一個巨集任務執行完畢後, 發現微任務佇列有任務p4, 再去執行清空微任務操作
- 把微任務佇列裡面的p4拿到主棧執行; // 輸出 6
- 主棧繼續往下執行, 發現微任務佇列已被清空, 此時提取巨集任務佇列中的第一個s2放到主棧裡面執行, 執行後輸出 5
- 主棧開始執行, 遇到同步程式碼
瀏覽器中的事件環
- 所有同步任務都在主執行緒上執行,形成一個執行棧;
- 主執行緒之外,還存在一個任務佇列; 只要非同步任務有了執行結果,就在任務佇列中放置一個事件(任務);
- 一旦執行棧中的所有同步任務執行完畢, 系統就會讀取任務佇列,將佇列中的事件放到執行棧中依次執行;
- 主執行緒從任務佇列中讀取事件,這個過程是迴圈不斷的 整個這種執行機制又被稱為Event Loop(事件迴圈)
面試題分析
setTimeout(()=>{
console.log(1);
Promise.resolve().then(data => {
console.log(2);
});
}, 0);
Promise.resolve().then(data=>{
console.log(3);
setTimeout(()=>{
console.log(4)
}, 0);
});
console.log('start');
// start -> 3 1 2 4
// 給方法分類: 巨集任務 微任務
// 巨集任務: setTimeout
// 微任務: then
/*
// 執行順序: 微任務會先執行
// 預設先執行主棧中的程式碼,執行後完清空微任務;
// 之後微任務執行完畢,取出第一個巨集任務到主棧中執行
// 第一個巨集任務執行完後,如果有微任務會再次去清空微任務,之後再去取巨集任務,這樣就形成事件環;
*/
複製程式碼
-
解析:
- 主棧中的程式碼從上往下執行, 遇到第一個定時器, 先將其掛起(s1) -> 繼續往下
- 遇到了Promise.then, 它是一個微任務, 將其放在微任務佇列 -> 繼續往下
- 遇到同步程式碼console.log('start'), 執行後輸出: start -> 繼續往下
- 棧裡面的(同步)任務執行完畢後, 檢視非同步佇列, 發現微任務佇列有then(p1), 會把這個微任務拿到棧裡面執行,執行後輸出: 3(微任務要先於巨集任務執行)
- 接下來往下執行又遇到一個定時器(巨集任務), 又將其掛起(s2)
- 微任務執行完成後,發現微任務佇列已清空,然後執行巨集任務; 因為s1先於s2放到非同步的回撥佇列, 將s1拿到棧裡面執行, 執行後輸出: 1
- console.log(1)執行完畢後又遇到一個微任務then, 將其放到微任務佇列(p2), 巨集任務完成後再次清空微任務佇列, 此時發現微任務p2, 將p2拿到主棧執行, 執行後輸出: 2
- 微任務p2執行完成後,再取巨集任務,發現巨集任務佇列有s2, 將其放到主棧裡面執行, 執行後輸出: 4
setTimeout(function () {
console.log(1);
Promise.resolve().then(function () {
console.log(2);
}); // p2
}); // s1
setTimeout(function () {
console.log(3);
}); // s2
Promise.resolve().then(function () {
console.log(4);
}); // p1
console.log(5); // 5 4 1 2 3
複製程式碼
-
解析
- 首先輸出 5, 因為console.log(5)是同步程式碼
- 接下來將兩個setTimeout和最後的Promise放入非同步佇列(將setTimeout放入巨集任務佇列[s1, s2],將Promise.then放入微任務佇列[p1]);
- 執行完同步程式碼後,發現微任務佇列和巨集任務佇列都有程式碼, 按瀏覽器事件環機制, 優先執行微任務
- 將微任務佇列中的p1拿到棧裡執行, 執行完成後輸出 4
- 微任務p1執行完後發現微任務佇列已清空, 接下來執行巨集任務
- 將巨集任務佇列中的s1拿到棧裡面執行, 執行完成後輸出 1
- 巨集任務s1執行過程中發現promise.then, 將其加入微任務佇列[p2]
- 巨集任務s1執行完成後, 要再次清空微任務佇列, 將微任務佇列中的p2拿到主棧執行, 執行完成後輸出2
- 微任務p2執行完成後, 發現微任務佇列已清空, 此時巨集任務佇列有s2
- 將巨集任務s2拿到棧裡面執行, 執行完成後輸出 3
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');
// C E D A B
複製程式碼
-
解析:
- 首先
setTimeout(()=>{ console.log('A'); },0)
被加入到巨集任務事件佇列中,此時巨集任務中有[s1(輸出A)]; - obj.func()執行時,
setTimeout(()=>{console.log('B'); },0)
被加入到巨集任務事件佇列中,此時巨集任務中有[s1,s2(輸出B)]; - 接著return一個Promise物件,new Promise例項時,Promise建構函式中的函式引數會立即執行, 執行console.log('C'); 此時列印了 'C'
- 接下來遇到then方法,將其回撥函式加入到微佇列,此時微任務佇列中有[p1];
- 主棧中的程式碼繼續執行, 遇到同步任務
console.log('E')
,執行後輸出 'E' - 此時所有同步任務執行完畢, 開始檢查非同步佇列,先檢查微任務佇列, 發現了p1, 執行微任務p1,輸出'D'
- p1執行完成後,發現微任務佇列已清空, 發現巨集任務佇列依然有任務,取出第一個巨集任務s1壓到主棧執行, 執行完成後輸出'A'
- s1執行完畢後,檢查發現微任務列表已清空, 而巨集任務列表還有一個任務,接著取出下一個巨集任務s2
- s2執行完畢後輸出 'B'
- 首先
小結
磕磕絆絆終於是理解了這一塊的知識點, 以前只是在不斷的搬磚, 卻從未停下來思考、認真學習, GET到之後感覺解開了不少疑惑;
在寫文件時候發現自己的語言描述能力居然如此的不堪, 囉裡囉嗦寫了很多; 這大抵是成長的必經之路吧;
參考了一些朋友的文章, 從中學習到不少, 有知識點的學習也有大佬對知識點巧妙的描述技巧; 向大佬致敬!
參考文章: