壹 ❀ 引
通過結果倒推過程是我們常用的思考模式,我在上一篇學習promise筆記中,有少量關於promise執行順序的例子,通過倒推,我成功讓自己對於js執行機制的理解一塌糊塗,js事件機制,事件循環是面試常考的點,弄懂它們是賊有必要的。
回顧下我學習promise的心理歷程:
let p = Promise.resolve(1); p.then(resp => console.log(resp)); console.log(2); //2 //1
哦,原來如此,同步代碼會先執行,先輸出2,所以then回調是異步。
let p1 = Promise.resolve(1); p1.then(resp => console.log(resp)); let p2 = Promise.resolve(2); p2.then(resp => console.log(resp)); //1 //2
哦!多個異步,先注冊的回調先執行,原來如此。
setTimeout(() => console.log(2),0); let p1 = Promise.resolve(1); p1.then(resp => console.log(resp)); //1 //2
嗯????不是先注冊的異步先執行?為啥這里先輸出1,promise學習下來,成功讓自己懵逼。
理解JS執行機制是很重要的,它會讓你的代碼調試更符合自己的預期,其次對於面試也非常有幫助。
介紹js執行機制的文章挺多了,這里只是做個個人思路的整理,那么開始。
貳 ❀ JavaScript中的同步異步
JavaScript是一門單線程非阻塞語言,在同一時間只能專心做一件事,如果前面的事情沒做,后面的事情就得耐心的等着,這就是所謂的同步。
你會想,為什么要同步?
JavaScript本身是一門瀏覽器腳本語言,更多負責用戶的交互,dom操作之類;假設JS並非單線程,我讓兩個行為同時操作一個dom對象,那豈不是亂套了。想想我們排隊取餐吃飯,如果不排隊,往往容易引發爭吵,編程也是現實行為的抽象。
也許你會說,不是有web worker嗎,但web worker屬於瀏覽器的解決方法,並非JavaScript;瀏覽器雖然可以開多個線程,但每個線程仍然是單線程,而且也不被允許操作dom,這依舊沒改變JS是單線程語言的事實。
let funA = () => { let NUM = 10000; while (NUM) { NUM--; }; console.log(1); }; let funB = () => console.log(2); funA(); //1 funB(); //2
在上述代碼中,讓10000進行自減如果讓我們腦補這個過程是很費時的,但是對於強大的js引擎來說並不是事,也要不了太多時間;
可在開發中我們得處理大量的網絡請求,我們知道請求可能存在延遲,服務器查詢也得耗時,一次請求受諸多不確定因素影響,我們不可能讓一次網絡請求堵塞后面的程序。
也正因如此異步誕生了,對於不確定的網絡請求,定時器之類,咱先備注一下有這些需要處理,就接着去忙同步的事情了,等手頭上同步的處理完了,再來解決先前備注的異步事件。
想想我們排隊取餐吃飯,前面的哥們大聲說道,牛肉面不要面只要牛肉,多蔥多蒜少辣不吃香菜半小時后來取,老板也不會等他半小時把面取了再做后面顧客的生意,那真要這樣,店子早倒閉了。
那么說完同步異步,我們大概有了個抽象的概念,js會先執行同步,萬一遇到異步,就先備注下有這個異步,等同步跑完了咱再來處理異步的后續操作,那么站在js角度這個過程是什么樣的,我們接着說。
叄 ❀ 執行棧與任務隊列
我們都知道,當一個方法被調用時,JavaScript會生成一個屬於此方法的執行環境,也叫執行上下文,這個上下文中存放着方法依賴的參數,變量以及作用域等等。
什么是執行棧呢?當調用一個方法A時,這個方法可能也會調用另一個方法B,B還可能調用方法C,而JS只能同時一件事,所以方法B、C沒執行完之前,方法A也不能被釋放,那總得找個地方把這些方法按順序存一存吧,存放的地方就是執行棧。
關於執行上下文,執行棧,具體可以閱讀這篇文章 一篇文章看懂JS執行上下文 這里我們就只做簡單闡述了。
執行棧是存放同步方法調用的地方,遵從先進后出的規則:
let A = () => { B() console.log(1); }; let B = () => { C() console.log(2); }; let C = () => { console.log(3); }; A();//3 2 1
上述代碼站在執行機制角度來看,是這樣的,你應該也能理解遞歸處理不好陷入死循環后爆棧是個什么情況了:
憑直覺來想,異步任務不可能直接在執行棧中執行,不然絕對存在堵塞的問題,那先存放在哪呢?放在了任務隊列中。
那么到這里我們又有了一個模糊的概念,同步任務與異步任務存放的地方不同,有個問題,JavaScript怎么知道什么時候去執行異步任務呢?那就不得不說事件循環。
肆 ❀ 事件循環 (Event Loop)
當一個任務被執行,js會判斷是否為同步任務,如果是同步,壓入主線程立即執行;但如果是異步任務,移到異步處理模塊(Task Table),當異步任務有了結果,就將異步任務的回調函數注入到任務隊列中等待。
當主線程的同步任務執行完畢執行棧為空,js引擎就會讀取任務隊列中的第一個任務加入到執行棧執行,當此任務完成,繼續重復此類操作,這也就是事件循環了,任務隊列滿足先進先出的特性。
那么到這里,我們知道js引擎會利用事情循環機制來處理同步異步問題;那么問題又來了,還記得文章開頭第三個例子嗎,定時器和promise都是異步,為什么后面的promise反而比前面的定時器先執行,難道異步任務也有自己的先后順序?這里就得引出宏任務與微任務了。
伍 ❀ 宏任務與微任務
我們先對宏任務微任務做個大概分類:
macro-task(宏任務):script環境 setTimeout、setInterval、I/O、事件、postMessage、 MessageChannel、setImmediate (Node.js)
micro-task(微任務):Promise,process.nextTick,MutaionObserver
很多面孔沒見過,沒關系,好歹我們知道了定時器是宏任務,new Promise是微任務。我把上面的例子搬下來:
setTimeout(() => console.log('我第一'), 1000); let p1 = Promise.resolve('我第二'); p1.then(resp => console.log(resp)); //我第二 //我第一
明明是定時器先進的異步處理模塊,結果promise.then還要早於定時器先執行,為什么呢?
這是因為,異步任務中又分為宏任務與微任務兩種,當執行棧為空,JS引擎會優先處理微任務隊列的任務,等到微任務隊列處理完成,才會處理宏任務隊列的任務。
setTimeout(() => console.log('我第一'), 2000); let p1 = Promise.resolve('我第二'); p1.then(resp => console.log(resp)); setTimeout(() => console.log('我第三'), 1000); let p2 = Promise.resolve('我第四'); p2.then(resp => console.log(resp)); //我第二 //我第四 //我第三 //我第一
上述代碼中,不管你異步代碼是怎么個順序,我們可以明確的是微任務優先級總是高於宏任務。
但需要注意的是,script整體環境都是一個宏任務,所以微任務由宏任務執行過程中產生,除去同步代碼執行完畢后,微任務執行優先級總是要優於剩余的異步宏任務。這里引用一張圖:
上圖中,宏任務運行過程中可能會產生微任務,若有微任務,執行所有微任務(前期是同步代碼跑完了),微任務優先級始終高於宏任務(拋開同步代碼)。
對於任務隊列具有先進先出的特性,你肯定要噴我了,如果先進先出上述代碼中等待2000ms的定時器比等待1000ms的定時器晚執行?那這里就得聊聊定時器時間的具體意義了。
陸 ❀ 有趣的定時器
定期器分為一次性定時器setTimeout與周期性定時器setInterval,前者是等待N秒之后執行回調一次沒了,后者是每隔N秒執行回調一次。
有這么一個定時器:
setTimeout(() => console.log('我第一'), 3000);
站在宏觀思想上理解,這行代碼的意思是這個定時器將在三秒后觸發,但站在微觀的角度上,3000ms並不代表執行時間,而是將回調函數加入任務隊列的時間,這也是為何存在定時器執行與所設置等待時間不符的問題所在。
setTimeout(() => console.log('我第一'), 3000);
setTimeout(() => console.log('我第二'), 3000);
你猜這兩個定時器怎么執行?先等三秒打印“我第一”,再等三秒打印“我第二”嗎?其實不是,真正執行是是等待三秒后幾乎無間隔的同時打印2個結果。
我們可以腦補下執行順序,首先遇到第一個定時器,告訴異步處理模塊,等待三秒后將回調加入任務隊列,然后又調用了第二個定時器,同樣是3秒后將回調加入任務隊列。
等到執行棧為空,去任務隊列拿任務,執行第一個console,這要不了多久,於是幾乎無時差的又去任務隊列拿第二個任務,這也導致了為什么2次輸出幾乎在同時進行。
兩個定時器等待時間相同,但第一個定時器回調還是先進入任務隊列,所以先觸發,這也印證了任務隊列先進先出的規則。
所以當我們使用周期定時器setInterval時,也會遇到執行間隔與所設時間不符的情況,比如前面有個賊復雜的操作,導致周期定時器按時間不停給任務隊列加入回調,等到前面任務跑完,這時你會發現前面所積累的回調像憋久了一樣一下全部一起執行了。
看到這大家應該對於JS執行機制有一定了解了,不妨閱讀下博主 從一道看似簡單的面試題重新理解JS執行機制與定時器 這篇文章,通過面試題來鞏固下自己的理解程度。
如果大家對JS執行上下文有興趣,歡迎閱讀博主 一篇文章看懂JS執行上下文這篇文章,一定會有所收獲。
那么本文到這里就結束了。
柒 ❀ 參考