JS引擎也可以叫做JS解釋器
瀏覽器的組成
瀏覽器的核心是兩部分:渲染引擎和JavaScript解釋器(又稱JavaScript引擎)。
(1)渲染引擎
渲染引擎的主要作用是,將網頁從代碼“渲染”為用戶視覺上可以感知的平面文檔。不同的瀏覽器有不同的渲染引擎。
以上四步並非嚴格按順序執行,往往第一步還沒完成,第二步和第三步就已經開始了。所以,會看到這種情況:網頁的HTML代碼還沒下載完,但瀏覽器已經顯示出內容了。
(2)JavaScript引擎
JavaScript引擎的主要作用是,讀取網頁中的JavaScript代碼,對其處理后運行。
本節主要介紹JavaScript引擎的工作方式。
<script>
標簽的工作原理
正常的網頁加載流程是這樣的。
- 瀏覽器一邊下載HTML網頁,一邊開始解析
- 解析過程中,發現
<script>
標簽 - 暫停解析,網頁渲染的控制權轉交給JavaScript引擎
- 如果
<script>
標簽引用了外部腳本,就下載該腳本,否則就直接執行 - 執行完畢,控制權交還渲染引擎,恢復往下解析HTML網頁
也就是說,加載外部腳本時,瀏覽器會暫停頁面渲染,等待腳本下載並執行完成后,再繼續渲染。原因是JavaScript可以修改DOM(比如使用document.write
方法),所以必須把控制權讓給它,否則會導致復雜的線程競賽的問題。
如果外部腳本加載時間很長(比如一直無法完成下載),就會造成網頁長時間失去響應,瀏覽器就會呈現“假死”狀態,這被稱為“阻塞效應”。
為了避免這種情況,較好的做法是將<script>
標簽都放在頁面底部,而不是頭部。這樣即使遇到腳本失去響應,網頁主體的渲染也已經完成了,用戶至少可以看到內容,而不是面對一張空白的頁面。
如果某些腳本代碼非常重要,一定要放在頁面頭部的話,最好直接將代碼嵌入頁面,而不是連接外部腳本文件,這樣能縮短加載時間。
將腳本文件都放在網頁尾部加載,還有一個好處。在DOM結構生成之前就調用DOM,JavaScript會報錯,如果腳本都在網頁尾部加載,就不存在這個問題,因為這時DOM肯定已經生成了。
<head><script>console.log(document.body.innerHTML); </script></head><body></body>
上面代碼執行時會報錯,因為此時document.body
元素還未生成。
一種解決方法是設定DOMContentLoaded
事件的回調函數。
下面是一個window.requestAnimationFrame()
對比效果的例子。
// 重繪代價高functiondoubleHeight(element) { varcurrentHeight=element.clientHeight; element.style.height= (currentHeight*2) +'px'; } all_my_elements.forEach(doubleHeight); // 重繪代價低functiondoubleHeight(element) { varcurrentHeight=element.clientHeight; window.requestAnimationFrame(function () { element.style.height= (currentHeight*2) +'px'; }); } all_my_elements.forEach(doubleHeight);
JavaScript虛擬機
JavaScript是一種解釋型語言,也就是說,它不需要編譯,可以由解釋器實時運行。這樣的好處是運行和修改都比較方便,刷新頁面就可以重新解釋;缺點是每次運行都要調用解釋器,系統開銷較大,運行速度慢於編譯型語言。為了提高運行速度,目前的瀏覽器都將JavaScript進行一定程度的編譯,生成類似字節碼(bytecode)的中間代碼,以提高運行速度。
早期,瀏覽器內部對JavaScript的處理過程如下:
- 讀取代碼,進行詞法分析(Lexical analysis),將代碼分解成詞元(token)。
- 對詞元進行語法分析(parsing),將代碼整理成“語法樹”(syntax tree)。
- 使用“翻譯器”(translator),將代碼轉為字節碼(bytecode)。
- 使用“字節碼解釋器”(bytecode interpreter),將字節碼轉為機器碼。
逐行解釋將字節碼轉為機器碼,是很低效的。為了提高運行速度,現代瀏覽器改為采用“即時編譯”(Just In Time compiler,縮寫JIT),即字節碼只在運行時編譯,用到哪一行就編譯哪一行,並且把編譯結果緩存(inline cache)。通常,一個程序被經常用到的,只是其中一小部分代碼,有了緩存的編譯結果,整個程序的運行速度就會顯著提升。
不同的瀏覽器有不同的編譯策略。有的瀏覽器只編譯最經常用到的部分,比如循環的部分;有的瀏覽器索性省略了字節碼的翻譯步驟,直接編譯成機器碼,比如chrome瀏覽器的V8引擎。
字節碼不能直接運行,而是運行在一個虛擬機(Virtual Machine)之上,一般也把虛擬機稱為JavaScript引擎。因為JavaScript運行時未必有字節碼,所以JavaScript虛擬機並不完全基於字節碼,而是部分基於源碼,即只要有可能,就通過JIT(just in time)編譯器直接把源碼編譯成機器碼運行,省略字節碼步驟。這一點與其他采用虛擬機(比如Java)的語言不盡相同。這樣做的目的,是為了盡可能地優化代碼、提高性能。下面是目前最常見的一些JavaScript虛擬機:
- Chakra(Microsoft Internet Explorer)
- Nitro/JavaScript Core (Safari)
- Carakan (Opera)
- SpiderMonkey (Firefox)
- V8 (Chrome, Chromium)
單線程模型
含義
首先,明確一個觀念:JavaScript只在一個線程上運行,不代表JavaScript引擎只有一個線程。事實上,JavaScript引擎有多個線程,其中單個腳本只能在一個線程上運行,其他線程都是在后台配合。JavaScript腳本在一個線程里運行。這意味着,一次只能運行一個任務,其他任務都必須在后面排隊等待。
JavaScript之所以采用單線程,而不是多線程,跟歷史有關系。JavaScript從誕生起就是單線程,原因是不想讓瀏覽器變得太復雜,因為多線程需要共享資源、且有可能修改彼此的運行結果,對於一種網頁腳本語言來說,這就太復雜了。比如,假定JavaScript同時有兩個線程,一個線程在某個DOM節點上添加內容,另一個線程刪除了這個節點,這時瀏覽器應該以哪個線程為准?所以,為了避免復雜性,從一誕生,JavaScript就是單線程,這已經成了這門語言的核心特征,將來也不會改變。
為了利用多核CPU的計算能力,HTML5提出Web Worker標准,允許JavaScript腳本創建多個線程,但是子線程完全受主線程控制,且不得操作DOM。所以,這個新標准並沒有改變JavaScript單線程的本質。
單線程模型帶來了一些問題,主要是新的任務被加在隊列的尾部,只有前面的所有任務運行結束,才會輪到它執行。如果有一個任務特別耗時,后面的任務都會停在那里等待,造成瀏覽器失去響應,又稱“假死”。為了避免“假死”,當某個操作在一定時間后仍無法結束,瀏覽器就會跳出提示框,詢問用戶是否要強行停止腳本運行。
如果排隊是因為計算量大,CPU忙不過來,倒也算了,但是很多時候CPU是閑着的,因為IO設備(輸入輸出設備)很慢(比如Ajax操作從網絡讀取數據),不得不等着結果出來,再往下執行。JavaScript語言的設計者意識到,這時CPU完全可以不管IO設備,掛起處於等待中的任務,先運行排在后面的任務。等到IO設備返回了結果,再回過頭,把掛起的任務繼續執行下去。這種機制就是JavaScript內部采用的Event Loop。
消息隊列
JavaScript運行時,除了一根運行線程,系統還提供一個消息隊列(message queue),里面是各種需要當前程序處理的消息。新的消息進入隊列的時候,會自動排在隊列的尾端。
運行線程只要發現消息隊列不為空,就會取出排在第一位的那個消息,執行它對應的回調函數。等到執行完,再取出排在第二位的消息,不斷循環,直到消息隊列變空為止。
每條消息與一個回調函數相聯系,也就是說,程序只要收到這條消息,就會執行對應的函數。另一方面,進入消息隊列的消息,必須有對應的回調函數。否則這個消息就會遺失,不會進入消息隊列。舉例來說,鼠標點擊就會產生一條消息,報告click
事件發生了。如果沒有回調函數,這個消息就遺失了。如果有回調函數,這個消息進入消息隊列。等到程序收到這個消息,就會執行click事件的回調函數。
另一種情況是setTimeout
會在指定時間向消息隊列添加一條消息。如果消息隊列之中,此時沒有其他消息,這條消息會立即得到處理;否則,這條消息會不得不等到其他消息處理完,才會得到處理。因此,setTimeout
指定的執行時間,只是一個最早可能發生的時間,並不能保證一定會在那個時間發生。
一旦當前執行棧空了,消息隊列就會取出排在第一位的那條消息,傳入程序。程序開始執行對應的回調函數,等到執行完,再處理下一條消息。
Event Loop
所謂Event Loop,指的是一種內部循環,用來一輪又一輪地處理消息隊列之中的消息,即執行對應的回調函數。Wikipedia的定義是:“Event Loop是一個程序結構,用於等待和發送消息和事件(a programming construct that waits for and dispatches events or messages in a program)”。可以就把Event Loop理解成動態更新的消息隊列本身。
下面是一些常見的JavaScript任務。
- 執行JavaScript代碼
- 對用戶的輸入(包含鼠標點擊、鍵盤輸入等等)做出反應
- 處理異步的網絡請求
所有任務可以分成兩種,一種是同步任務(synchronous),另一種是異步任務(asynchronous)。同步任務指的是,在JavaScript執行進程上排隊執行的任務,只有前一個任務執行完畢,才能執行后一個任務;異步任務指的是,不進入JavaScript執行進程、而進入“任務隊列”(task queue)的任務,只有“任務隊列”通知主進程,某個異步任務可以執行了,該任務(采用回調函數的形式)才會進入JavaScript進程執行。
以Ajax操作為例,它可以當作同步任務處理,也可以當作異步任務處理,由開發者決定。如果是同步任務,主線程就等着Ajax操作返回結果,再往下執行;如果是異步任務,該任務直接進入“任務隊列”,JavaScript進程跳過Ajax操作,直接往下執行,等到Ajax操作有了結果,JavaScript進程再執行對應的回調函數。
也就是說,雖然JavaScript只有一根進程用來執行,但是並行的還有其他進程(比如,處理定時器的進程、處理用戶輸入的進程、處理網絡通信的進程等等)。這些進程通過向任務隊列添加任務,實現與JavaScript進程通信。
想要理解Event Loop,就要從程序的運行模式講起。運行以后的程序叫做“進程”(process),一般情況下,一個進程一次只能執行一個任務。如果有很多任務需要執行,不外乎三種解決方法。
-
排隊。因為一個進程一次只能執行一個任務,只好等前面的任務執行完了,再執行后面的任務。
-
新建進程。使用fork命令,為每個任務新建一個進程。
-
新建線程。因為進程太耗費資源,所以如今的程序往往允許一個進程包含多個線程,由線程去完成任務。
如果某個任務很耗時,比如涉及很多I/O(輸入/輸出)操作,那么線程的運行大概是下面的樣子。
上圖的綠色部分是程序的運行時間,紅色部分是等待時間。可以看到,由於I/O操作很慢,所以這個線程的大部分運行時間都在空等I/O操作的返回結果。這種運行方式稱為”同步模式”(synchronous I/O)。
如果采用多線程,同時運行多個任務,那很可能就是下面這樣。
上圖表明,多線程不僅占用多倍的系統資源,也閑置多倍的資源,這顯然不合理。
上圖主線程的綠色部分,還是表示運行時間,而橙色部分表示空閑時間。每當遇到I/O的時候,主線程就讓Event Loop線程去通知相應的I/O程序,然后接着往后運行,所以不存在紅色的等待時間。等到I/O程序完成操作,Event Loop線程再把結果返回主線程。主線程就調用事先設定的回調函數,完成整個任務。
可以看到,由於多出了橙色的空閑時間,所以主線程得以運行更多的任務,這就提高了效率。這種運行方式稱為”異步模式“(asynchronous I/O)。
這正是JavaScript語言的運行方式。單線程模型雖然對JavaScript構成了很大的限制,但也因此使它具備了其他語言不具備的優勢。如果部署得好,JavaScript程序是不會出現堵塞的,這就是為什么node.js平台可以用很少的資源,應付大流量訪問的原因。
如果有大量的異步任務(實際情況就是這樣),它們會在“消息隊列”中產生大量的消息。這些消息排成隊,等候進入主線程。本質上,“消息隊列”就是一個“先進先出”的數據結構。比如,點擊鼠標就產生一系列消息(各種事件),mousedown
事件排在mouseup
事件前面,mouseup
事件又排在click
事件的前面。
參考鏈接
John Dalziel, The race for speed part 2: How JavaScript compilers work
Jake Archibald,Deep dive into the murky waters of script loading
Mozilla Developer Network, window.setTimeout
Remy Sharp, Throttling function calls
Ayman Farhat, An alternative to Javascript’s evil setInterval
Ilya Grigorik, Script-injected “async scripts” considered harmful
Axel Rauschmayer, ECMAScript 6 promises (1/2): foundations
Daniel Imms, async vs defer attributes
Craig Buckler, Load Non-blocking JavaScript with HTML5 Async and Defer
Domenico De Felice, How browsers work為這個值。