庖丁解牛之瀏覽器事件環

風雪中的兔子發表於2019-04-02

瀏覽器事件環

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

棧和佇列

在計算機記憶體中存取資料, 基本的資料結構分為棧和佇列

  • 棧(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是解釋型語言,它的執行過程是這樣的:

  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中的常見概念【作者:凌晨夏沫】(作者是前端大佬一枚,可關注一下)

相關文章