深入瞭解載入指令碼時的昏暗水域

Jake Archibald
Jake Archibald

簡介

本文將說明如何在瀏覽器中載入及執行 JavaScript。

不,等等,請回來!我知道這聽起來很平凡且簡單,但請記住,這發生在瀏覽器中,在理論上簡單的情況下,會變成以舊版為導向的奇怪漏洞。瞭解這些特殊情況,您就能選擇最快、最不干擾的腳本載入方式。如果您行程緊湊,請直接查看快速參考資料。

首先,以下是規格如何定義指令碼可下載及執行的各種方式:

WHATWG 對指令碼載入的看法
關於指令碼載入的 WHATWG

就像所有 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」的解決方法。 其他以紅色標示的瀏覽器表示:我不瞭解這個「非同步」的意思,我會在收到指令碼後立即執行,不論順序為何。其他所有人都說:我是你的朋友,我們會按照規則行事。