庖丁解牛之瀏覽器事件環

瀏覽器事件環

用實例和知識點描述帶您清晰的瞭解瀏覽器事件環的每一步;javascript

棧和隊列

在計算機內存中存取數據, 基本的數據結構分爲棧和隊列php

  • 棧(Stack)是一種後進先出的數據結構; 棧的特色是 操做只在一端進行, 通常來講, 棧的操做只有兩種: 進棧和出棧; 第一個進棧的數據老是最後一個纔出來html

  • 隊列(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是單線程的, 這裏所謂的單線程指的是主線程是單線程;java

  • 爲何不是多線程呢? JavaScript最初設計是運行在瀏覽器中的, 假定是多線程, 有多個線程同時操做DOM, 豈不很混亂! 那會以哪一個爲準呢?面試

  • JavaScript爲單線程, 在一個線程中代碼會一行一行往下走,直到程序執行完畢; 若執行期間遇到較爲費時的操做, 那隻能等待了;promise

  • 單線程的設計使得語言的執行效率變差, 爲了利用多核CPU的性能,javascript語言支持異步代碼; 當有較爲費時的操做時, 可將任務寫爲異步; 主線程在執行過程當中遇到異步代碼, 會先將該異步任務掛起, 繼續執行後面的同步代碼, 待同步執行完畢再回過頭來, 檢查是否有異步任務, 若是有異步任務就執行它;瀏覽器

PS: Java君加班有點累, 他想燒水衝一杯咖啡, 若是採用同步執行方式,那他就傻傻地等待,等水開了再衝咖啡;bash

PS: Java君加班有點累, 他想燒水衝一杯咖啡, 若是採用異步執行方式,那麼他在等待水燒開以前,他能夠聽聽歌,刷刷抖音啥的,等水開了再衝咖啡;數據結構

(-很明顯異步的方式效率會高一些);

JavaScript是怎麼執行的

JavaScript代碼是在棧裏執行的, 不管是同步仍是異步; 代碼分爲同步代碼和異步代碼, 異步代碼又分爲: {宏任務} 和 [微任務]

JavaScript是解釋型語言,它的執行過程是這樣的:

  1. 從上到下依次解釋每一條js語句
  2. 如果同步任務, 則將其壓入一個棧(主線程); 若是是異步任務,就放到一個任務隊列裏面;
  3. 開始執行棧裏面的同步任務,直到將棧裏的全部任務都走完, 此時棧被清空;
  4. 回頭檢查異步隊列,若是有異步任務完成了,就生成一個事件並註冊回調(將異步的回調放到隊列裏面), 再將回調函數取出壓入棧中執行;
  5. 棧中的異步回調執行完成後再去檢查,直到異步隊列都清空,程序運行結束

從以上步驟能夠看出,不論同步仍是異步, 都是在棧裏執行的, 棧裏的任務執行完成後一遍又一遍地回頭檢查隊列,這種方式就是所謂的"事件環"

事件隊列

// 先看個demo吧
	console.log('start');
	
	setTimeout(()=>{
	    console.log('hello');
	}, 1000);
	
	console.log('end');
	// start end hello 上面代碼執行後, 輸出'start' 'end', 大約1s以後輸出'hello'
	// ? 爲何'hello'不在end以前輸出呢
複製代碼
  • 解析
    1. setTimeout是一個異步函數, 也就是說當咱們設置一個延遲函數的時候, setTimeout異步函數並不會阻塞代碼執行, 程序仍是會往下執行; 與此同時,它會在瀏覽事件列表中進行標記;
    2. 當延遲時間結束以後(準確說應該是當異步完成後), 事件列表會將標記的異步函數【異步函數的回調函數】添加到事件隊列(Task Queue)中
    3. 當主棧中的代碼執行完畢, 棧爲空時, JS引擎便檢查事件隊列, 若是不爲空的話,事件隊列便將第一個任務壓入執行棧中運行;
console.log('start');
	setTimeout(() => {
	    console.log('hello');
	},0);
	
	console.log('end');
	// start end hello
	// 將上例微微調整,發現輸出結果仍是同樣的 
	// 由於setTimeout的回調函數只是會被添加到(事件)隊列中,而不會當即執行。 再回頭
複製代碼
  • 解析:
    1. 由於setTimeout是異步函數, 首先它會被(事件列表)標記(即掛起);
    2. setTimeout的延遲時間0並不是真正是0, 在瀏覽器應該是4ms;
    3. 延遲時間到達(即異步任務完成),setTimeout的回調會被放入事件隊列(靜靜地等待主棧中的同步代碼執行);
    4. 當執行棧(即主棧)中的任務(同步任務)執行完畢, 執行棧爲空; // 輸出了 start end
    5. 執行棧爲空後, 回頭檢查事件隊列, (發現隊列裏面有任務[函數]待執行)將隊列中註冊的任務(即:異步函數完成後的回調函數)壓入執行棧執行; // 輸出 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'
複製代碼
  • 解析:
    1. new Promise()實例時的函數參數(執行器excutor)會當即執行; // 輸出 'Promise'
    2. promise.then是異步函數, 它會被先放入事件隊列;
    3. 同步任務console.log('Hello World!');執行完畢後主棧被清空 // 輸出'Hello World!'
    4. 回頭檢查事件隊列,發現隊列裏面有任務, 將其壓入主棧執行; // 輸出'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
複製代碼
  • 解析:
    1. 主棧開始執行, 遇到同步代碼console.log(1);,將其執行, 輸出 1
    2. 主棧繼續往下執行, 遇到異步函數setTimeout(()=>{ console.log(2); }, 0), 將其放入宏任務隊列,此時宏任務隊列:[s1]
    3. 主棧繼續往下執行, 遇到異步函數promise.then將其放入微任務隊列, 此時微任務隊列[p1(打印3,返回3+1)]
    4. 主棧繼續往下執行, 遇到異步函數promise.then將其放入微任務隊列, 此時微任務隊列[p1, p2(打印4)]
    5. 主棧繼續往下執行, 遇到異步函數promise.then將其放入微任務隊列, 此時微任務隊列[p1, p2, p3]
    6. 主棧的同步代碼執行完畢後, 棧裏面的任務已空, 回頭檢查發現有宏任務隊列[s1]、微任務隊列[p1, p2, p3]
    7. 清空微任務隊列(即微任務隊列中的任務挨個的執行,直到所有執行完畢爲止) 清空微任務流程
    8. 把微任務隊列裏面的p1拿到主棧執行; // 輸出 3, 將data + 1(4)做爲下一個then的成功值返回
    9. 把微任務隊列裏面的p2拿到主棧執行; // 輸出 4
    10. 在執行p2時遇到了setTimeout(()=>{ console.log(data+1); return data + 1; }),將其放入宏任務隊列(先標記,1s後異步執行完成後再將異步函數的回調放入隊列), 此時宏任務隊列:[s1,s2]
    11. 主棧繼續往下執行, 把微任務隊列裏面的p3拿到主棧執行, 由於上一個then未顯示的返回任何值, 所以data爲undefined, 執行完畢後輸出 undefined
    12. 主棧繼續往下執行, 發現微任務隊列已被清空, 此時提取宏任務隊列中的第一個s1放到主棧裏面執行, 執行後輸出 2
    13. s1在輸出2以後, 遇到了異步函數promise.then, 將其放入微任務隊列, 此時微任務隊列[p4]
    14. 第一個宏任務執行完畢後, 發現微任務隊列有任務p4, 再去執行清空微任務操做
    15. 把微任務隊列裏面的p4拿到主棧執行; // 輸出 6
    16. 主棧繼續往下執行, 發現微任務隊列已被清空, 此時提取宏任務隊列中的第一個s2放到主棧裏面執行, 執行後輸出 5

瀏覽器中的事件環

  1. 全部同步任務都在主線程上執行,造成一個執行棧;
  2. 主線程以外,還存在一個任務隊列; 只要異步任務有了運行結果,就在任務隊列中放置一個事件(任務);
  3. 一旦執行棧中的全部同步任務執行完畢, 系統就會讀取任務隊列,將隊列中的事件放到執行棧中依次執行;
  4. 主線程從任務隊列中讀取事件,這個過程是循環不斷的 整個這種運行機制又被稱爲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
/*
	// 執行順序: 微任務會先執行
	// 默認先執行主棧中的代碼,執行後完清空微任務;
	// 以後微任務執行完畢,取出第一個宏任務到主棧中執行
	// 第一個宏任務執行完後,若是有微任務會再次去清空微任務,以後再去取宏任務,這樣就造成事件環;
*/
複製代碼
  • 解析:

    1. 主棧中的代碼從上往下執行, 遇到第一個定時器, 先將其掛起(s1) -> 繼續往下
    2. 遇到了Promise.then, 它是一個微任務, 將其放在微任務隊列 -> 繼續往下
    3. 遇到同步代碼console.log('start'), 執行後輸出: start -> 繼續往下
    4. 棧裏面的(同步)任務執行完畢後, 查看異步隊列, 發現微任務隊列有then(p1), 會把這個微任務拿到棧裏面執行,執行後輸出: 3(微任務要先於宏任務執行)
    5. 接下來往下執行又遇到一個定時器(宏任務), 又將其掛起(s2)
    6. 微任務執行完成後,發現微任務隊列已清空,而後執行宏任務; 由於s1先於s2放到異步的回調隊列, 將s1拿到棧裏面執行, 執行後輸出: 1
    7. console.log(1)執行完畢後又遇到一個微任務then, 將其放到微任務隊列(p2), 宏任務完成後再次清空微任務隊列, 此時發現微任務p2, 將p2拿到主棧執行, 執行後輸出: 2
    8. 微任務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
複製代碼
  • 解析

    1. 首先輸出 5, 由於console.log(5)是同步代碼
    2. 接下來將兩個setTimeout和最後的Promise放入異步隊列(將setTimeout放入宏任務隊列[s1, s2],將Promise.then放入微任務隊列[p1]);
    3. 執行完同步代碼後,發現微任務隊列和宏任務隊列都有代碼, 按瀏覽器事件環機制, 優先執行微任務
    4. 將微任務隊列中的p1拿到棧裏執行, 執行完成後輸出 4
    5. 微任務p1執行完後發現微任務隊列已清空, 接下來執行宏任務
    6. 將宏任務隊列中的s1拿到棧裏面執行, 執行完成後輸出 1
    7. 宏任務s1執行過程當中發現promise.then, 將其加入微任務隊列[p2]
    8. 宏任務s1執行完成後, 要再次清空微任務隊列, 將微任務隊列中的p2拿到主棧執行, 執行完成後輸出2
    9. 微任務p2執行完成後, 發現微任務隊列已清空, 此時宏任務隊列有s2
    10. 將宏任務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

複製代碼
  • 解析:

    1. 首先setTimeout(()=>{ console.log('A'); },0)被加入到宏任務事件隊列中,此時宏任務中有[s1(輸出A)];
    2. obj.func()執行時,setTimeout(()=>{console.log('B'); },0)被加入到宏任務事件隊列中,此時宏任務中有[s1,s2(輸出B)];
    3. 接着return一個Promise對象,new Promise實例時,Promise構造函數中的函數參數會當即執行, 執行console.log('C'); 此時打印了 'C'
    4. 接下來遇到then方法,將其回調函數加入到微隊列,此時微任務隊列中有[p1];
    5. 主棧中的代碼繼續執行, 遇到同步任務console.log('E'),執行後輸出 'E'
    6. 此時全部同步任務執行完畢, 開始檢查異步隊列,先檢查微任務隊列, 發現了p1, 執行微任務p1,輸出'D'
    7. p1執行完成後,發現微任務隊列已清空, 發現宏任務隊列依然有任務,取出第一個宏任務s1壓到主棧執行, 執行完成後輸出'A'
    8. s1執行完畢後,檢查發現微任務列表已清空, 而宏任務列表還有一個任務,接着取出下一個宏任務s2
    9. s2執行完畢後輸出 'B'

小結

磕磕絆絆終因而理解了這一塊的知識點, 之前只是在不斷的搬磚, 卻從未停下來思考、認真學習, GET到以後感受解開了很多疑惑;

在寫文檔時候發現本身的語言描述能力竟然如此的不堪, 囉裏囉嗦寫了不少; 這大抵是成長的必經之路吧;

參考了一些朋友的文章, 從中學習到很多, 有知識點的學習也有大佬對知識點巧妙的描述技巧; 向大佬致敬!

參考文章:

  1. 筆試題——JavaScript事件循環機制(event loop、macrotask、microtask【做者:立志搬磚造福生活】
  2. Javascript事件環該如何理解?
  3. 談談Node中的常見概念【做者:凌晨夏沫】(做者是前端大佬一枚,可關注一下)
相關文章
相關標籤/搜索