?
本文檔使用 php中文網(wǎng)手冊 發(fā)布
JavaScript代碼嵌入網(wǎng)頁的方法
直接添加代碼塊
加載外部腳本
行內(nèi)代碼
外部腳本的加載
網(wǎng)頁底部加載
多個腳本的加載
defer屬性
async屬性
腳本的動態(tài)嵌入
加載使用的協(xié)議
JavaScript虛擬機
單線程模型
Event Loop
任務隊列
參考鏈接
瀏覽器通過內(nèi)置的JavaScript引擎,讀取網(wǎng)頁中的代碼,對其處理后運行。
在網(wǎng)頁中嵌入JavaScript代碼有多種方法。
通過script標簽,可以直接將JavaScript代碼嵌入網(wǎng)頁。
<script> // some JavaScript code </script>
script標簽也可以指定加載外部的腳本文件。
<script src="example.js"></script>
如果腳本文件使用了非英語字符,還應該注明編碼。
<script charset="utf-8" src="example.js"></script>
加載外部腳本和直接添加代碼塊,這兩種方法不能混用。下面代碼的console.log語句直接被忽略。
<script charset="utf-8" src="example.js"> console.log('Hello World!'); </script>
除了上面兩種方法,HTML語言允許在某些元素的事件屬性和a元素的href屬性中,直接寫入JavaScript。
<div onclick="alert('Hello')"></div> <a href="javascript:alert('Hello')"></a>
這種寫法將HTML代碼與JavaScript代碼混寫在一起,非常不利于代碼管理,不建議使用。
正常的網(wǎng)頁加載流程是這樣的。
瀏覽器一邊下載HTML網(wǎng)頁,一邊開始解析
解析過程中,發(fā)現(xiàn)script標簽
暫停解析,下載script標簽中的外部腳本
下載完成,執(zhí)行腳本
恢復往下解析HTML網(wǎng)頁
也就是說,加載外部腳本時,瀏覽器會暫停頁面渲染,等待腳本下載并執(zhí)行完成后,再繼續(xù)渲染。如果加載時間很長(比如一直無法完成下載),就會造成網(wǎng)頁長時間失去響應,瀏覽器就會呈現(xiàn)“假死”狀態(tài),失去響應,這被稱為“阻塞效應”。這樣設(shè)計是因為JavaScript代碼可能會修改頁面,所以必須等它執(zhí)行完才能繼續(xù)渲染。為了避免這種情況,較好的做法是將script標簽都放在頁面底部,而不是頭部。當然,如果某些腳本代碼非常重要,一定要放在頁面頭部的話,最好直接將代碼嵌入頁面,而不是連接外部腳本文件,這樣能縮短加載時間。
將腳本文件都放在網(wǎng)頁尾部加載,還有一個好處。在DOM結(jié)構(gòu)生成之前就調(diào)用DOM,JavaScript會報錯,如果腳本都在網(wǎng)頁尾部加載,就不存在這個問題,因為這時DOM肯定已經(jīng)生成了。
<head> <script> console.log(document.body.innerHTML); </script> </head>
上面代碼執(zhí)行時會報錯,因為此時body元素還未生成。
一種解決方法是設(shè)定DOMContentLoaded事件的回調(diào)函數(shù)。
<head> <script> document.addEventListener("DOMContentLoaded", function(event) { console.log(document.body.innerHTML); }); </script> </head>
另一種解決方法是,使用script標簽的onload屬性。當script標簽指定的外部腳本文件下載和解析完成,會觸發(fā)一個load事件,可以為這個事件指定回調(diào)函數(shù)。
<script src="jquery.min.js" onload="console.log(document.body.innerHTML)"> </script>
但是,如果將腳本放在頁面底部,就可以完全按照正常的方式寫,上面兩種方式都不需要。
<body> <!-- 其他代碼 --> <script> console.log(document.body.innerHTML); </script> </body>
如果有多個script標簽,比如下面這樣。
<script src="1.js"></script> <script src="2.js"></script>
瀏覽器會同時平行下載1.js和2.js,但是執(zhí)行時會保證先執(zhí)行1.js,然后再執(zhí)行2.js,即使后者先下載完成,也是如此。也就是說,腳本的執(zhí)行順序由它們在頁面中的出現(xiàn)順序決定,這是為了保證腳本之間的依賴關(guān)系不受到破壞。
當然,加載這兩個腳本都會產(chǎn)生“阻塞效應”,必須等到它們都加載完成,瀏覽器才會繼續(xù)頁面渲染。
此外,對于來自同一個域名的資源,比如腳本文件、樣式表文件、圖片文件等,瀏覽器一般最多同時下載六個。如果是來自不同域名的資源,就沒有這個限制。所以,通常把靜態(tài)文件放在不同的域名之下,以加快下載速度。
為了解決腳本文件下載阻塞網(wǎng)頁渲染的問題,一個方法是加入defer屬性。
<script src="1.js" defer></script> <script src="2.js" defer></script>
defer屬性的運行過程是這樣的。
瀏覽器開始解析HTML網(wǎng)頁
解析過程中,發(fā)現(xiàn)帶有defer屬性的script標簽
瀏覽器繼續(xù)往下解析HTML網(wǎng)頁,同時并行下載script標簽中的外部腳本
瀏覽器完成解析HTML網(wǎng)頁,此時再執(zhí)行下載的腳本
有了defer屬性,瀏覽器下載腳本文件的時候,不會阻塞頁面渲染。下載的腳本文件在DOMContentLoaded事件觸發(fā)前執(zhí)行(即剛剛讀取完標簽),而且可以保證執(zhí)行順序就是它們在頁面上出現(xiàn)的順序。但是,瀏覽器對這個屬性的支持不夠理想,IE(<=9)還有一個bug,無法保證2.js一定在1.js之后執(zhí)行。如果需要支持老版本的IE,且腳本之間有依賴關(guān)系,建議不要使用defer屬性。
對于內(nèi)置而不是連接外部腳本的script標簽,以及動態(tài)生成的script標簽,defer屬性不起作用。
解決“阻塞效應”的另一個方法是加入async屬性。
<script src="1.js" async></script> <script src="2.js" async></script>
async屬性的運行過程是這樣的。
瀏覽器開始解析HTML網(wǎng)頁
解析過程中,發(fā)現(xiàn)帶有async屬性的script標簽
瀏覽器繼續(xù)往下解析HTML網(wǎng)頁,同時并行下載script標簽中的外部腳本
腳本下載完成,瀏覽器暫停解析HTML網(wǎng)頁,開始執(zhí)行下載的腳本
腳本執(zhí)行完畢,瀏覽器恢復解析HTML網(wǎng)頁
async屬性可以保證腳本下載的同時,瀏覽器繼續(xù)渲染。需要注意的是,一旦采用這個屬性,就無法保證腳本的執(zhí)行順序。哪個腳本先下載結(jié)束,就先執(zhí)行那個腳本。使用async屬性的腳本文件中,不應該使用document.write方法。IE 10支持async屬性,低于這個版本的IE都不支持。
defer屬性和async屬性到底應該使用哪一個?一般來說,如果腳本之間沒有依賴關(guān)系,就使用async屬性,如果腳本之間有依賴關(guān)系,就使用defer屬性。如果同時使用async和defer屬性,后者不起作用,瀏覽器行為由async屬性決定。
除了用靜態(tài)的script標簽,還可以動態(tài)嵌入script標簽。
['1.js', '2.js'].forEach(function(src) { var script = document.createElement('script'); script.src = src; document.head.appendChild(script); });
這種方法的好處是,動態(tài)生成的script標簽不會阻塞頁面渲染,也就不會造成瀏覽器假死。但是問題在于,這種方法無法保證腳本的執(zhí)行順序,哪個腳本文件先下載完成,就先執(zhí)行哪個。
如果想避免這個問題,可以設(shè)置async屬性為false。
['1.js', '2.js'].forEach(function(src) { var script = document.createElement('script'); script.src = src; script.async = false; document.head.appendChild(script); });
上面的代碼依然不會阻塞頁面渲染,而且可以保證2.js在1.js后面執(zhí)行。不過需要注意的是,在這段代碼后面加載的腳本文件,會因此都等待2.js執(zhí)行完成后再執(zhí)行。
我們可以把上面的寫法,封裝成一個函數(shù)。
(function() { var script, scripts = document.getElementsByTagName('script')[0]; function load(url) { script = document.createElement('script'); script.async = true; script.src = url; scripts.parentNode.insertBefore(script, scripts); } load('//apis.google.com/js/plusone.js'); load('//platform.twitter.com/widgets.js'); load('//s.thirdpartywidget.com/widget.js'); }());
此外,動態(tài)嵌入還有一個地方需要注意。動態(tài)嵌入必須等待CSS文件加載完成后,才會去下載外部腳本文件。靜態(tài)加載就不存在這個問題,script標簽指定的外部腳本文件,都是與CSS文件同時并發(fā)下載的。
如果不指定協(xié)議,瀏覽器默認采用HTTP協(xié)議下載。
<script src="example.js"></script>
上面的example.js默認就是采用http協(xié)議下載,如果要采用HTTPs協(xié)議下載,必需寫明(假定服務器支持)。
<script src="https://example.js"></script>
但是有時我們會希望,根據(jù)頁面本身的協(xié)議來決定加載協(xié)議,這時可以采用下面的寫法。
<script src="//example.js"></script>
JavaScript是一種解釋型語言,也就是說,它不需要編譯,可以由解釋器實時運行。這樣的好處是運行和修改都比較方便,刷新頁面就可以重新解釋;缺點是每次運行都要調(diào)用解釋器,系統(tǒng)開銷較大,運行速度慢于編譯型語言。為了提高運行速度,目前的瀏覽器都將JavaScript進行一定程度的編譯,生成類似字節(jié)碼(bytecode)的中間代碼,以提高運行速度。
早期,瀏覽器內(nèi)部對JavaScript的處理過程如下:
讀取代碼,進行詞法分析(Lexical analysis),將代碼分解成詞元(token)。
對詞元進行語法分析(parsing),將代碼整理成“語法樹”(syntax tree)。
使用“翻譯器”(translator),將代碼轉(zhuǎn)為字節(jié)碼(bytecode)。
使用“字節(jié)碼解釋器”(bytecode interpreter),將字節(jié)碼轉(zhuǎn)為機器碼。
逐行解釋將字節(jié)碼轉(zhuǎn)為機器碼,是很低效的。為了提高運行速度,現(xiàn)代瀏覽器改為采用“即時編譯”(Just In Time compiler,縮寫JIT),即字節(jié)碼只在運行時編譯,用到哪一行就編譯哪一行,并且把編譯結(jié)果緩存(inline cache)。通常,一個程序被經(jīng)常用到的,只是其中一小部分代碼,有了緩存的編譯結(jié)果,整個程序的運行速度就會顯著提升。
不同的瀏覽器有不同的編譯策略。有的瀏覽器只編譯最經(jīng)常用到的部分,比如循環(huán)的部分;有的瀏覽器索性省略了字節(jié)碼的翻譯步驟,直接編譯成機器碼,比如chrome瀏覽器的V8引擎。
字節(jié)碼不能直接運行,而是運行在一個虛擬機(Virtual Machine)之上,一般也把虛擬機稱為JavaScript引擎。因為JavaScript運行時未必有字節(jié)碼,所以JavaScript虛擬機并不完全基于字節(jié)碼,而是部分基于源碼,即只要有可能,就通過JIT(just in time)編譯器直接把源碼編譯成機器碼運行,省略字節(jié)碼步驟。這一點與其他采用虛擬機(比如Java)的語言不盡相同。這樣做的目的,是為了盡可能地優(yōu)化代碼、提高性能。下面是目前最常見的一些JavaScript虛擬機:
[Chakra](http://en.wikipedia.org/wiki/Chakra_(JScript_engine))(Microsoft(Microsoft) Internet Explorer)
Nitro/JavaScript Core (Safari)
Carakan (Opera)
SpiderMonkey(Firefox)
[V8](http://en.wikipedia.org/wiki/V8_(JavaScript_engine)) (Chrome, Chromium)
JavaScript采用單線程模型,也就是說,所有的任務都在一個線程里運行。這意味著,一次只能運行一個任務,其他任務都必須在后面排隊等待。
JavaScript之所以采用單線程,而不是多線程,跟歷史有關(guān)系。JavaScript從誕生起就是單線程,原因是不想讓瀏覽器變得太復雜,因為多線程需要共享資源、且有可能修改彼此的運行結(jié)果,對于一種網(wǎng)頁腳本語言來說,這就太復雜了。比如,假定JavaScript同時有兩個線程,一個線程在某個DOM節(jié)點上添加內(nèi)容,另一個線程刪除了這個節(jié)點,這時瀏覽器應該以哪個線程為準?所以,為了避免復雜性,從一誕生,JavaScript就是單線程,這已經(jīng)成了這門語言的核心特征,將來也不會改變。
為了利用多核CPU的計算能力,HTML5提出Web Worker標準,允許JavaScript腳本創(chuàng)建多個線程,但是子線程完全受主線程控制,且不得操作DOM。所以,這個新標準并沒有改變JavaScript單線程的本質(zhì)。
單線程模型帶來了一些問題,主要是新的任務被加在隊列的尾部,只有前面的所有任務運行結(jié)束,才會輪到它執(zhí)行。如果有一個任務特別耗時,后面的任務都會停在那里等待,造成瀏覽器失去響應,又稱“假死”。為了避免“假死”,當某個操作在一定時間后仍無法結(jié)束,瀏覽器就會跳出提示框,詢問用戶是否要強行停止腳本運行。
如果排隊是因為計算量大,CPU忙不過來,倒也算了,但是很多時候CPU是閑著的,因為IO設(shè)備(輸入輸出設(shè)備)很慢(比如Ajax操作從網(wǎng)絡讀取數(shù)據(jù)),不得不等著結(jié)果出來,再往下執(zhí)行。JavaScript語言的設(shè)計者意識到,這時CPU完全可以不管IO設(shè)備,掛起處于等待中的任務,先運行排在后面的任務。等到IO設(shè)備返回了結(jié)果,再回過頭,把掛起的任務繼續(xù)執(zhí)行下去。這種機制就是JavaScript內(nèi)部采用的Event Loop。
所謂Event Loop,指的是一種內(nèi)部循環(huán),用來排列和處理事件,以及執(zhí)行函數(shù)。Wikipedia的定義是:“Event Loop是一個程序結(jié)構(gòu),用于等待和發(fā)送消息和事件。(a programming construct that waits for and dispatches events or messages in a program.)”
所有任務可以分成兩種,一種是同步任務(synchronous),另一種是異步任務(asynchronous)。同步任務指的是,在主線程上排隊執(zhí)行的任務,只有前一個任務執(zhí)行完畢,才能執(zhí)行后一個任務;異步任務指的是,不進入主線程、而進入“任務隊列”(task queue)的任務,只有“任務隊列”通知主線程,某個異步任務可以執(zhí)行了,該任務才會進入主線程執(zhí)行。
以Ajax操作為例,它可以當作同步任務處理,也可以當作異步任務處理,由開發(fā)者決定。如果是同步任務,主線程就等著Ajax操作返回結(jié)果,再往下執(zhí)行;如果是異步任務,該任務直接進入“任務隊列”,主線程跳過Ajax操作,直接往下執(zhí)行,等到Ajax操作有了結(jié)果,主線程再執(zhí)行對應的回調(diào)函數(shù)。
想要理解Event Loop,就要從程序的運行模式講起。運行以后的程序叫做"進程"(process),一般情況下,一個進程一次只能執(zhí)行一個任務。如果有很多任務需要執(zhí)行,不外乎三種解決方法。
排隊。因為一個進程一次只能執(zhí)行一個任務,只好等前面的任務執(zhí)行完了,再執(zhí)行后面的任務。
新建進程。使用fork命令,為每個任務新建一個進程。
新建線程。因為進程太耗費資源,所以如今的程序往往允許一個進程包含多個線程,由線程去完成任務。
如果某個任務很耗時,比如涉及很多I/O(輸入/輸出)操作,那么線程的運行大概是下面的樣子。
上圖的綠色部分是程序的運行時間,紅色部分是等待時間??梢钥吹?,由于I/O操作很慢,所以這個線程的大部分運行時間都在空等I/O操作的返回結(jié)果。這種運行方式稱為"同步模式"(synchronous I/O)。
如果采用多線程,同時運行多個任務,那很可能就是下面這樣。
上圖表明,多線程不僅占用多倍的系統(tǒng)資源,也閑置多倍的資源,這顯然不合理。
上圖主線程的綠色部分,還是表示運行時間,而橙色部分表示空閑時間。每當遇到I/O的時候,主線程就讓Event Loop線程去通知相應的I/O程序,然后接著往后運行,所以不存在紅色的等待時間。等到I/O程序完成操作,Event Loop線程再把結(jié)果返回主線程。主線程就調(diào)用事先設(shè)定的回調(diào)函數(shù),完成整個任務。
可以看到,由于多出了橙色的空閑時間,所以主線程得以運行更多的任務,這就提高了效率。這種運行方式稱為"異步模式"(asynchronous I/O)。
這正是JavaScript語言的運行方式。單線程模型雖然對JavaScript構(gòu)成了很大的限制,但也因此使它具備了其他語言不具備的優(yōu)勢。如果部署得好,JavaScript程序是不會出現(xiàn)堵塞的,這就是為什么node.js平臺可以用很少的資源,應付大流量訪問的原因。
如果有大量的異步任務(實際情況就是這樣),它們會在“任務隊列”中注冊大量的事件。這些事件排成隊列,等候進入主線程。本質(zhì)上,“任務隊列”就是一個事件“先進先出”的數(shù)據(jù)結(jié)構(gòu)。比如,點擊鼠標就產(chǎn)生一些列事件,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