簡介
本文將說明如何在瀏覽器中載入及執行 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」。太好了!非同步下載,但有序執行!
所有支援 async 屬性的瀏覽器都支援以這種方式載入指令碼,但 Safari 5.0 除外 (5.1 則沒問題)。此外,所有版本的 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 中的公用程式函式。理想情況下,我們希望以非同步方式下載所有內容,然後盡快以任意順序執行強化指令碼,但要在 dependencies.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
,使用 JavaScript 載入指令碼,並回退至 IE 的 readystate 指令碼載入功能,然後回退至延後。
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」的情況下,忽略「defer」指令碼。 IE < 10 指出:我可能會在執行 1.js 的一半過程中執行 2.js。是不是很有趣?以紅色標示的瀏覽器:我不知道「延遲」是什麼,我會載入指令碼,就當作沒有延遲一樣。其他瀏覽器:好,但我可能不會忽略沒有「src」的「defer」指令碼。
非同步
<script src="//other-domain.com/1.js" async></script>
<script src="2.js" async></script>
規格說明:一起下載,並依下載順序執行。以紅色標示的瀏覽器顯示:「async」是什麼?我會載入指令碼,就當作沒有指令碼一樣。其他瀏覽器:沒問題。
非同步 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 表示:我瞭解「異步」,但不瞭解如何使用 JS 將其設為「false」。我會在收到指令碼後立即執行,不論順序為何。 IE < 10 表示:不瞭解「非同步」的意思,但有個使用「onreadystatechange」的解決方法。 其他以紅色標示的瀏覽器表示:我不瞭解這個「非同步」的意思,我會在收到指令碼後立即執行,不論順序為何。其他所有人都說:我是你的朋友,我們會按照規則行事。