現(xiàn)如今,web開發(fā)者(我們更喜歡被叫做前端工程師)用一門腳本語言就能做任何事情苛谷,從提供瀏覽器中的交互辅鲸,到開發(fā)電腦游戲、桌面工具腹殿、跨平臺移動應用独悴,甚至可以在服務端部署(如最流行的Node.js)來連結任意數(shù)據(jù)庫例书。因此,了解Javascript的內(nèi)部構造很重要刻炒,這樣才能更優(yōu)更高效的使用它决采。這也是本文的主旨所在。
Javascript的生態(tài)正在變得越來越復雜坟奥。要構建一個現(xiàn)代web應用树瞭,會不可避免的用到Webpack、Babel爱谁、ESLint晒喷、Mocha、Karma访敌、Grunt……我該用哪個凉敲?這些都是干嘛的?我找到了這個漫畫寺旺,它完美詮釋了如今的web開發(fā)者的水深火熱:
Javascript疲勞癥——學習Javascript是什么感覺
在一頭扎進框架和庫的海洋之前爷抓,每個Javascript開發(fā)者首先需要了解Javascript在底層是如何實現(xiàn)的。差不多每個JS開發(fā)者都聽過“V8”這個術語迅涮,但有些人可能根本不知道這個詞到底什么意思废赞、干嘛用的。在我職業(yè)開發(fā)生涯的第一年里叮姑,我對這些花里胡哨的術語所知甚少唉地,我更關心先完成工作。但這樣并不能滿足我的好奇心传透,我好奇Javascript是他喵的怎么能做到這一切的耘沼。我決定要深挖一番,我翻遍Google朱盐,找到一些優(yōu)秀的博客群嗤,包括Philip Roberts的a great talk at JSConf on the event loop。所以我決定總結我的學習經(jīng)驗并分享出來兵琳。鑒于有太多東西要了解狂秘,我把本文分為兩個部分。這一部分會介紹常用術語躯肌,第二部分則會闡述這些術語之間的關聯(lián)者春。
Javascript是一個單線程單并發(fā)的語言,也就是說它一次只能處理一個任務清女,執(zhí)行一條代碼钱烟。它的調(diào)用棧連同堆、隊列一起構成了Javascript并發(fā)模型(在V8中實現(xiàn))。讓我們一個個地看這幾個詞拴袭。
Visual Representation of JS Model
- 調(diào)用棧(Call Stack):它是記錄我們在程序中調(diào)用函數(shù)的數(shù)據(jù)結構读第。假如我們調(diào)用一個函數(shù)來執(zhí)行,就是在把某種記錄推入到調(diào)用棧的頂端拥刻;當我們從一個函數(shù)中返回出來怜瞒,就從調(diào)用棧頂端彈出記錄。
JS Stack Visualization
當我們運行上圖中的代碼般哼,我們會先尋找所有執(zhí)行的開端——主函數(shù)盼砍。在上例中,一系列執(zhí)行開始于console.log(bar(6))逝她,那么這一次執(zhí)行就被推入調(diào)用棧中浇坐,它上面一層就是函數(shù)bar及其參數(shù),函數(shù)bar轉(zhuǎn)而調(diào)用函數(shù)foo黔宛,foo也被推入棧中近刘;而foo隨即return了某個值,所以被彈出調(diào)用棧臀晃;類似地觉渴,bar隨后彈出,最后console語句打印了結果并彈出徽惋。所有這些舉動都依次發(fā)生在須臾之間案淋。
你們肯定都在瀏覽器控制臺見過那個又長又紅的報錯棧,它用一種從上到下的恰如棧的方式险绘,簡單表明了調(diào)用棧的當前狀態(tài)以及在函數(shù)中何處報錯(見下圖)踢京。
Error stack trace
有時候,當我們以遞歸的形式多次調(diào)用一個函數(shù)宦棺,就會陷入無限循環(huán)中瓣距,而對于Chrome瀏覽器來說,它對調(diào)用棧的大小的限制是16000層代咸,超出限制就會終止程序并拋出達到棧上限錯誤(見下圖)蹈丸。
- 堆:對象會被分配到堆——內(nèi)存中的松散結構。所有的針對變量和對象的內(nèi)存分配都在堆中進行呐芥。
- 隊列:一種Javascript運行時逻杖,包含了一個消息隊列,這個隊列就是一系列將被處理的信息和要執(zhí)行的相關回調(diào)函數(shù)思瘟。當調(diào)用棧有足夠空間荸百,就從隊列中取出一條消息并進行處理,該消息調(diào)用相關聯(lián)的函數(shù)(并因此產(chǎn)生一個初始化棧層)潮太。當棧再次清空時管搪,消息處理也就結束了。簡單說铡买,這些消息被排成隊列更鲁,指定回調(diào)函數(shù)來響應外部異步事件(例如鼠標點擊或HTTP請求的響應)。諸如用戶點擊按鈕而沒有相應回調(diào)函數(shù)的情況奇钞,就不會有消息放入隊列中澡为。
事件循環(huán)(event loop)
當我們評估JS代碼的性能時,要知道調(diào)用棧中的函數(shù)會讓程序或快或慢景埃,console.log()會很快媒至,但用for或while迭代成千上萬次就會慢一些,并且讓調(diào)用棧一直被占用被阻塞著谷徙。這就叫做阻塞腳本拒啰,你可能在Webpage Speed Insights中見過。
網(wǎng)絡請求會慢完慧,圖片請求會慢谋旦,但萬幸,服務請求可以通過AJAX這種異步函數(shù)完成屈尼。假如那些網(wǎng)絡請求用同步函數(shù)來完成册着,將會如何?網(wǎng)絡請求發(fā)送到服務器——服務器也就是某處的某種機器罷了脾歧,現(xiàn)在假設服務器返回響應可能會緩慢甲捏,此時,如果我點擊一些CTA(call-to-action)按鈕鞭执,或者其他一些需要完成的渲染司顿,就不會有什么反應,因為調(diào)用棧還被之前的網(wǎng)絡請求阻塞著兄纺。在Ruby等多線程語言中免猾,這種情況可以控制,但像Javascript這種單線程語言囤热,除非調(diào)用棧中的函數(shù)返回值猎提,否則就一直堵著。瀏覽器沒有任何反應旁蔼,網(wǎng)頁就會崩潰锨苏。這樣我們可沒辦法為最終用戶提供流暢的用戶界面。那我們怎么辦棺聊?
“JS中的并發(fā)——一次只做一件事伞租,異步回調(diào)除外”
最早的解決方案就是用異步回調(diào),這意味著我們給某部分代碼加一個回調(diào)限佩,該回調(diào)會在這段代碼執(zhí)行完成后執(zhí)行葵诈。我們肯定都遇到過諸如AJAX請求用的$.get()裸弦、setTimeout()、setInterval()作喘、Promises的異步回調(diào)理疙。Node都是基于異步函數(shù)執(zhí)行的。所有那些異步回調(diào)不會像console.log()等同步函數(shù)那樣立刻運行泞坦,而是在之后的某個時刻運行窖贤,所以不會立刻就推到調(diào)用棧中去。那它們到底去哪里了贰锁?怎么控制它們赃梧?
如上例,若一個網(wǎng)絡請求在Javascript中運行:
<pre style="margin: 0px; padding: 0px; border: 0px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-variant-numeric: inherit; font-variant-east-asian: inherit; font-weight: 400; font-stretch: inherit; font-size: 18px; line-height: inherit; font-family: inherit; vertical-align: baseline; word-break: break-word; color: rgb(93, 93, 93); letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial;">
1\. 請求函數(shù)被執(zhí)行豌熄,給`onreadystatechange`事件傳一個匿名函數(shù)作為回調(diào)授嘀,用來在將來響應就緒的時候執(zhí)行。
2\. “Script call done锣险!”立刻輸出到控制臺粤攒。
3\. 后續(xù)某時刻,響應被返回囱持,回調(diào)被執(zhí)行夯接,響應體被輸出到控制臺。
</pre>
在等待異步操作完成并解除回調(diào)執(zhí)行之時纷妆,響應的解耦調(diào)用允許Javascript運行時做別的事盔几。瀏覽器插入進來調(diào)用了它的API,這是用C++實現(xiàn)的API掩幢,用來創(chuàng)建線程以控制諸如DOM事件逊拍、http請求、setTimeout等異步事件际邻。
那些web接口不能自己把執(zhí)行代碼推入調(diào)用棧芯丧,如果能,那么該接口會隨機出現(xiàn)在你的代碼中(執(zhí)行順序不可控)世曾。上面討論過的消息回調(diào)隊列說明了這一點缨恒。任何web接口在執(zhí)行完畢后,都會把回調(diào)推入這個隊列轮听。事件循環(huán)此時就要負責控制隊列中的回調(diào)的執(zhí)行骗露,并在棧空時把回調(diào)推入棧中血巍。事件循環(huán)的基本工作就是監(jiān)聽調(diào)用棧和任務隊列萧锉,當它看到棧空了述寡,就把隊列中第一個任務推入棧柿隙。每個消息或者回調(diào)都在上一個任務處理完再開始處理叶洞。
<pre style="margin: 0px; padding: 0px; border: 0px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-variant-numeric: inherit; font-variant-east-asian: inherit; font-weight: 400; font-stretch: inherit; font-size: 18px; line-height: inherit; font-family: inherit; vertical-align: baseline; word-break: break-word; color: rgb(93, 93, 93); letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial;">
while (queue.waitForMessage()) {
queue.processNextMessage();
}
</pre>
Javascript Event Loop Visual Representation
在web瀏覽器中,一旦某事件發(fā)生并綁定了事件監(jiān)聽器禀崖,消息就立即添加到隊列中衩辟。如果沒有監(jiān)聽器,那就意味著事件丟失了帆焕。因此點擊一個綁定了點擊事件處理器,就會新增一個消息不恭,其他事件亦如此叶雹。對其回調(diào)的調(diào)用將會是調(diào)用棧中的初始層,而由于Javascript是單線程的换吧,在調(diào)用棧中所有調(diào)用都return之前折晦,后續(xù)的消息的輪詢和處理就暫停了。之后的(同步的)函數(shù)調(diào)用會向調(diào)用棧中增加新的調(diào)用層沾瓦。
在下一部分满着,我會通過一個動畫來展示上述過程的代碼執(zhí)行,深入解釋什么是不同類型的異步函數(shù)贯莺、隊列中誰優(yōu)先執(zhí)行风喇,以及諸如零延遲等功能的技巧。