簡介
本文將說明如何在瀏覽器中載入及執行 JavaScript。
不,等等,請回來!我知道這聽起來很平凡且簡單,但請記住,這發生在瀏覽器中,在理論上簡單的情況下,會變成以舊版為導向的奇怪漏洞。瞭解這些特殊情況,您就能選擇最快、最不干擾的腳本載入方式。如果您行程緊湊,請直接跳到快速參考資料。
首先,以下是規格如何定義指令碼可下載及執行的各種方式:
就像所有 WHATWG 規格一樣,一開始看起來就像是拼字遊戲工廠遭到集束炸彈攻擊後的結果,但讀到第 5 次並擦去眼睛上的血跡後,你會發現這其實很有趣:
我的第一個腳本包含
<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>
啊,簡單就是美好。在這裡,瀏覽器會平行下載這兩個指令碼,盡快執行,並維持其順序。除非「1.js」已執行 (或執行失敗),「2.js」才會執行;除非「1.js」已執行,否則「2.js」不會執行,等等。
很遺憾,在這些作業進行期間,瀏覽器會阻止進一步轉譯網頁。這是因為「網際網路初期」的 DOM API 允許字串附加至剖析器正在處理的內容,例如 document.write
。較新的瀏覽器會繼續在背景掃描或剖析文件,並觸發所需外部內容的下載作業 (js、圖片、css 等),但仍會封鎖轉譯作業。
因此,效能專家建議將指令碼元素放在文件結尾,這樣就能盡可能減少內容遭到封鎖。很抱歉,這表示瀏覽器必須先下載所有 HTML,才能看到指令碼,而這時瀏覽器會開始下載其他內容,例如 CSS、圖片和 iframe。新版瀏覽器相當聰明,會將優先順序設為 JavaScript 而非圖像,但我們可以做得更好。
謝謝 IE!(不,我不是在反串)
<script src="//other-domain.com/1.js" defer></script>
<script src="2.js" defer></script>
Microsoft 發現了這些效能問題,並在 Internet Explorer 4 中導入「延遲」機制。這基本上表示「我保證不得使用 document.write
之類的東西將東西插入剖析器中。如果我違背這個承諾,你可以隨意懲罰我。」這個屬性已納入 HTML4,並出現在其他瀏覽器中。
在上述範例中,瀏覽器會並行下載兩個指令碼,並在 DOMContentLoaded
觸發前執行,以維持指令碼的順序。
就像羊毛工廠發生集束炸彈攻擊一樣,「延後」這個詞也變得一團糟。在「src」和「defer」屬性之間,以及指令碼標記與動態新增的指令碼之間,我們有 6 種新增指令碼的模式。當然,瀏覽器對執行順序的看法並不一致。Mozilla 在 2009 年曾撰寫一篇關於這個問題的優質文章。
WHATWG 明確說明了這項行為,宣告「延遲」對動態新增或缺少「src」的腳本沒有影響。否則,延遲的腳本應在文件剖析完成後依新增順序執行。
謝謝 IE!(好,現在我進來了)
勝利啦。很抱歉,IE4-9 中有一項惱人的錯誤,可能會導致指令碼以非預期的順序執行。以下說明會發生的情況:
1.js
console.log('1');
document.getElementsByTagName('p')[0].innerHTML = 'Changing some content';
console.log('2');
2.js
console.log('3');
假設網頁上有段落,則記錄的預期順序為 [1, 2, 3],但在 IE9 以下版本中,您會看到 [1, 3, 2]。特定 DOM 作業會導致 IE 暫停目前的指令碼執行作業,並在繼續執行前執行其他待處理的指令碼。
但是,即使在執行 IE10 等非錯誤的實作方式下,要等到整份文件下載並剖析完畢之後,指令碼才會執行失敗。如果您打算等待 DOMContentLoaded
,這麼做會很方便,但如果您想積極提升效能,可以提早開始新增事件監聽器和引導程序。
改為使用 HTML5。
<script src="//other-domain.com/1.js" async></script>
<script src="2.js" async></script>
HTML5 給了我們一個新的屬性「async」,這個屬性假設您不打算使用 document.write
,而是要等到文件剖析檔已剖析檔後才執行。瀏覽器會並行下載這兩個指令碼,並盡快執行。
很遺憾,由於這兩個檔案會盡快執行,因此「2.js」可能會在「1.js」之前執行。如果兩者是獨立的檔案,這並無大礙,因為「1.js」可能是與「2.js」無關的追蹤程式碼。但如果「1.js」是「2.js」所依賴的 jQuery CDN 副本,網頁就會充斥錯誤,就像在… 我不知道… 我對這個問題一無所知。
我知道我們需要什麼了,就是 JavaScript 程式庫!
理想情況下,一組指令碼應立即下載,且不會阻斷轉譯,並盡快依新增順序執行。很抱歉,HTML 不支援這麼做。
這個問題已由幾種版本的 JavaScript 解決。有些情況下,您需要修改 JavaScript,將其包裝在程式庫以正確順序呼叫的回呼中 (例如 RequireJS)。其他人會使用 XHR 並以正確順序並行下載,然後再執行 eval()
,但這對其他網域上的指令碼無效,除非指令碼具有 CORS 標頭,且瀏覽器支援該標頭。有些駭客甚至運用了超神奇的駭客技術,例如 LabJS。
這些駭客攻擊會利用欺騙瀏覽器下載資源的方式,讓瀏覽器在完成時觸發事件,但又不想執行該資源。在 LabJS 中,系統會以不正確的 MIME 類型 (例如 <script type="script/cache" src="...">
) 新增指令碼。所有指令碼下載完成後,系統會以正確的類型再次加入這些指令碼,希望瀏覽器能直接從快取中取得這些指令碼,並依序立即執行。這項功能依賴方便但未指定的行為,當 HTML5 宣告瀏覽器不應下載未知類型的指令碼時,就會中斷。值得注意的是, LabJS 已根據這些變更進行調整,本文會同時使用本文所述的方法。
不過,指令碼載入器本身也有效能問題,您必須等到程式庫的 JavaScript 下載並剖析完成,才能開始下載其管理的任何指令碼。另外,要如何載入指令碼載入器?我們要如何載入指令碼,讓指令碼載入器知道要載入哪些內容?誰在觀看守望者?為什麼我會裸露?這些都是很難回答的問題。
基本上,如果您必須先下載額外的指令碼檔案,才能下載其他指令碼,就會輸掉效能競爭。
需要救援的 DOM
答案其實就在 HTML5 規格中,不過隱藏在指令碼載入區段的底部。
我們來翻譯成「地球人」:
[
'//other-domain.com/1.js',
'2.js'
].forEach(function(src) {
var script = document.createElement('script');
script.src = src;
document.head.appendChild(script);
});
動態建立並新增至文件的指令碼預設為非同步,不會阻止轉譯,並在下載後立即執行,這表示指令碼可能會以錯誤順序顯示。不過,我們可以明確將其標示為非非同步:
[
'//other-domain.com/1.js',
'2.js'
].forEach(function(src) {
var script = document.createElement('script');
script.src = src;
script.async = false;
document.head.appendChild(script);
});
這可讓指令碼混合不同行為,而這在純 HTML 中無法達成。由於程式碼「明確」不屬於非同步,因此會加入執行佇列,也就是在第一個純 HTML 範例中加入的相同佇列。不過,由於系統是動態建立,因此會在文件剖析之外執行,因此在下載時不會遭到封鎖 (不要在使用同步 XHR 的情況下,造成非非同步指令碼載入混淆,這是個好現象)。
上述指令碼應內嵌在網頁標題中,並盡快將指令碼下載排入佇列,而不會幹擾漸進式轉譯,並且盡快依照您指定的順序執行。您可以先下載「2.js」,但必須等到「1.js」成功下載及執行,或下載和執行失敗,才能執行「2.js」。哇!非同步下載但訂購執行!
除了 Safari 5.0 (5.1 版) 以外,所有支援 async 屬性的容器都可以這種方式載入指令碼。此外,所有版本的 Firefox 和 Opera 都支援不支援 async 屬性的版本,可方便地依照動態新增至文件的順序執行動態新增的指令碼。
能以最快的速度載入指令碼嗎?序列
如果您要動態決定要載入哪些指令碼,那麼可以,否則可能不行。在上述範例中,瀏覽器必須剖析及執行指令碼,才能找出要下載的指令碼。這麼做可隱藏您的指令碼,不讓預先載入掃描器偵測到。瀏覽器會使用這些掃描器,找出您下次可能造訪的網頁資源,或是在剖析器遭其他資源封鎖時,找出網頁資源。
我們可以將以下內容放在文件的標頭,重新加入可發現性:
<link rel="subresource" href="//other-domain.com/1.js">
<link rel="subresource" href="2.js">
這會告訴瀏覽器網頁需要 1.js 和 2.js。link[rel=subresource]
與 link[rel=prefetch]
類似,但語意不同。很抱歉,目前只有 Chrome 支援此功能,而且您必須宣告要載入的程式碼兩次,一次透過連結元素,另一次則是在程式碼中。
更正:我原本說這些是預先載入掃描器所拿的,不是,而是一般剖析器會擷取這些圖像。不過,預載掃描器可以擷取這些內容,只是目前還沒有這麼做,而可執行程式碼所包含的指令碼永遠無法預先載入。感謝 Yoav Weiss 在留言中糾正我的錯誤。
我覺得這篇文章憂鬱
這個情況令人沮喪,你也應該感到沮喪。在控管執行順序的同時,這款工具不會出現重複的宣告式方法,讓您以非同步的方式快速且非同步地下載指令碼。 透過 HTTP2/SPDY,您可以將要求的額外負擔降到最低,以便以最快速的方式,透過多個可個別快取的小型檔案提供指令碼。這項產品可以帶來下列好處:
<script src="dependencies.js"></script>
<script src="enhancement-1.js"></script>
<script src="enhancement-2.js"></script>
<script src="enhancement-3.js"></script>
…
<script src="enhancement-10.js"></script>
每個強化指令碼都會處理特定網頁元件,但需要 dependencies.js 中的公用程式函式。在理想情況下,我們會想要以非同步方式下載所有程式碼,然後以任何順序盡快執行強化指令碼,但須在 ends.js 之後執行。這就是漸進增強的真諦!很抱歉,除非修改指令碼本身,以便追蹤 dependencies.js 的載入狀態,否則無法以宣告式方式達成這項目標。即使 async=false 也無法解決這個問題,因為 enhancement-10.js 的執行作業會在 1-9 處阻斷。事實上,只有一個瀏覽器可以不經過駭客攻擊就做到這一點…
IE 有個好點子!
IE 載入指令碼的方式與其他瀏覽器不同。
var script = document.createElement('script');
script.src = 'whatever.js';
IE 現在會開始下載「whatever.js」,必須等到指令碼加入文件後,才會開始下載其他瀏覽器。IE 也有「readystatechange」事件和「readystate」屬性,可用來顯示載入進度。這其實非常實用,因為我們可以獨立控制指令碼的載入和執行作業。
var script = document.createElement('script');
script.onreadystatechange = function() {
if (script.readyState == 'loaded') {
// Our script has download, but hasn't executed.
// It won't execute until we do:
document.body.appendChild(script);
}
};
script.src = 'whatever.js';
我們可以選擇在文件中新增指令碼的時間,藉此建構複雜的依附元件模型。IE 自 6 版起就支援這個模型。這很有趣,但仍會遇到與 async=false
相同的預先載入器可探索性問題。
夠了!我該如何載入指令碼?
好的,如果您想以不會阻斷轉譯、不重複且支援瀏覽器的最佳方式載入指令碼,建議您採用以下做法:
<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>
就是這樣。在 body 元素結尾處。沒錯,做為網頁開發人員就像是西西弗斯國王 (Sisyphus),100 個新潮分數 (感謝引用希臘神話!)。但 HTML 和瀏覽器的限制,讓我們無法做得更好。
希望 JavaScript 模組透過宣告式的非阻斷方式載入指令碼,並給予控制執行順序,儘管必須以模組形式編寫指令碼,仍希望能夠節省。
糟糕,還有什麼更好的地方我們可以使用嗎?
為了獲得額外積分,如果您想進一步提升效能,且不介意程式碼稍微複雜和重複,可以結合上述幾個技巧。
首先,我們要為預載器新增子資源宣告:
<link rel="subresource" href="//other-domain.com/1.js">
<link rel="subresource" href="2.js">
然後,內嵌在文件標題中,我們使用 async=false
載入指令碼,並使用 async=false
回復為 IE 的就緒狀態指令碼載入,使其回復到延遲。
var scripts = [
'1.js',
'2.js'
];
var src;
var script;
var pendingScripts = [];
var firstScript = document.scripts[0];
// Watch scripts load in IE
function stateChange() {
// Execute as many scripts in order as we can
var pendingScript;
while (pendingScripts[0] && pendingScripts[0].readyState == 'loaded') {
pendingScript = pendingScripts.shift();
// avoid future loading events from this script (eg, if src changes)
pendingScript.onreadystatechange = null;
// can't just appendChild, old IE bug if element isn't closed
firstScript.parentNode.insertBefore(pendingScript, firstScript);
}
}
// loop through our script urls
while (src = scripts.shift()) {
if ('async' in firstScript) { // modern browsers
script = document.createElement('script');
script.async = false;
script.src = src;
document.head.appendChild(script);
}
else if (firstScript.readyState) { // IE<10
// create a script and add it to our todo pile
script = document.createElement('script');
pendingScripts.push(script);
// listen for state changes
script.onreadystatechange = stateChange;
// must set src AFTER adding onreadystatechange listener
// else we'll miss the loaded event for cached scripts
script.src = src;
}
else { // fall back to defer
document.write('<script src="' + src + '" defer></'+'script>');
}
}
經過一些技巧和簡化後,大小為 362 位元組 + 指令碼網址:
!function(e,t,r){function n(){for(;d[0]&&"loaded"==d[0][f];)c=d.shift(),c[o]=!i.parentNode.insertBefore(c,i)}for(var s,a,c,d=[],i=e.scripts[0],o="onreadystatechange",f="readyState";s=r.shift();)a=e.createElement(t),"async"in i?(a.async=!1,e.head.appendChild(a)):i[f]?(d.push(a),a[o]=n):e.write("<"+t+' src="'+s+'" defer></'+t+">"),a.src=s}(document,"script",[
"//other-domain.com/1.js",
"2.js"
])
與簡單的腳本加入方式相比,是否值得使用額外的位元組?如果您已經使用 JavaScript 依條件載入指令碼 (如 BBC 所做的那樣),那麼提早觸發這些下載作業也許會更有幫助。否則,請繼續使用簡單的結尾方法。
太好了,現在我知道 WHATWG 指令碼載入區段非常龐大。我需要一杯飲料。
快速參考
純文字指令碼元素
<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>
規格說明:一起下載,並在任何待處理的 CSS 之後依序執行,封鎖顯示作業直到完成。 瀏覽器回應:是的!
推遲
<script src="//other-domain.com/1.js" defer></script>
<script src="2.js" defer></script>
規格說明:一起下載,並在 DOMContentLoaded 之前依序執行。在沒有「src」的指令碼上忽略「延遲」功能。IE < 10 表示:我可能會在執行 1.js 的一半過程中執行 2.js。是不是很有趣?瀏覽器中的紅色瀏覽器指出:我不知道這個「延遲」是什麼,會假設指令碼在「沒有」的情況下載入。 其他瀏覽器:好的,但我可能不會忽略沒有「src」的「defer」指令碼。
非同步
<script src="//other-domain.com/1.js" async></script>
<script src="2.js" async></script>
規格說明:一起下載,並依下載順序執行。瀏覽器中的紅色瀏覽器指出:什麼是「非同步」?我會假設指令碼在那邊載入。 其他瀏覽器的訊息:沒錯。
非同步 false
[
'1.js',
'2.js'
].forEach(function(src) {
var script = document.createElement('script');
script.src = src;
script.async = false;
document.head.appendChild(script);
});
規格說明:同時下載並依序執行。Firefox 3.6 以下版本,Opera 表示:我不知道這個「非同步」功能是什麼,但我剛好會按照新增的順序,執行透過 JS 新增的指令碼。Safari 5.0 表示:我瞭解「async」,但不瞭解如何使用 JS 將其設為「false」。我會在收到指令碼後立即執行,不論順序為何。 IE < 10 表示:不知道「非同步」的概念,但有「onreadystatechange」的解決方法。 其他瀏覽器會以紅色顯示說明:我不知道這項「非同步」的情況,在指令碼到達之後,我會以任何順序執行指令碼。 其他所有人都說:我是你的朋友,我們會按照規則行事。