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

Jake Archibald
Jake Archibald

簡介

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

不,等等,回來!聽起來是個平凡又簡單的事,但別忘了,在這個理論上,瀏覽器就像是舊型的小孔洞。瞭解這些同義詞後,您就可以選擇以最快、最低限度的方式來載入指令碼。若您覺得時間很緊,請跳至快速參考資源。

首先,規格定義了指令碼的各種下載和執行方式:

載入指令碼時的 WHATWG
載入指令碼的注意事項

如同所有 WHATWG 規格,一開始看起來像是在廢棄物工廠中的集體炸彈之後,但只要讀過第 5 次並從眼睛消除血液後,事實真的是如此:

我的第一個指令碼包括

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

啊你,就是這麼簡單!這個瀏覽器會同時下載並盡快執行這些指令碼,以維持順序。「1.js」必須先執行 (或無法執行),「1.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」,「2.js」可能會在「1.js」之前執行。如果獨立運作,「1.js」是追蹤指令碼,與「2.js」無關,那就沒有問題。但若「1.js」是「2.js」是 CDN 的 jQuery,由於「2.js」是真的...但這根本不會發生這種錯誤,網頁也會因為錯誤而無法發生。

我知道我們需要的功能,也就是 JavaScript 程式庫!

這個神秘會立刻下載一組指令碼,而不會阻擋轉譯作業,並依照加入順序盡快執行。很遺憾,HTML 可不願您這麼做。

但 JavaScript 只差一點點就能解決這個問題。您必須對 JavaScript 做出變更,才能將 JavaScript 包裝在程式庫呼叫的正確順序 (例如 RequireJS) 中。有些則會使用 XHR 以正確順序同時下載 eval(),除非使用 CORS 標頭且瀏覽器支援,否則不支援其他網域中的指令碼。有些人甚至使用了超大型的駭客,例如 LabJS

這類入侵行為涉及誘騙瀏覽器下載資源,過程中會觸發事件,但避免執行。在 LabJS 中,系統會使用錯誤的 MIME 類型來新增指令碼,例如 <script type="script/cache" src="...">。所有指令碼下載完成後,將會再次加入正確的類型,希望瀏覽器能直接從快取中取得,並立即執行。這取決於方便而未指定的行為,而且當 HTML5 宣告的瀏覽器不應下載類型無法辨識的指令碼時就會發生故障。值得注意的是,LabJS 現在使用本文中的方法組合,已能根據這些變化進行調整。

不過,指令碼載入器本身存在效能問題,您必須等待程式庫的 JavaScript 下載並剖析資料,系統才能開始下載該程式庫的所有指令碼。還有,該如何載入指令碼載入器?我們如何載入指令碼,指示指令碼載入器要載入哪些內容?誰負責看著守護者?我為什麼要裸體?這些都是棘手的問題

基本上,如果必須先下載額外的指令碼檔案,再考慮下載其他指令碼,就會失去了效能。

要支援的 DOM

答案實際上在 HTML5 規格中,但隱藏在指令碼載入部分底部。

讓我們以「Earthling」(地球) 為例:

[
  '//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 無法達成的行為。因為「明確」不非同步,系統會將指令碼加入執行佇列,和第一個純文字範例中加入的相同佇列。不過,由於動態建立會在文件剖析之外執行,因此不會在文件剖析之外執行,因此在下載期間不會遭到封鎖 (請勿與同步處理 XHR 混淆指令碼載入,但這並不是件好事)。

上述指令碼必須內嵌在網頁標題中,將指令碼下載排入佇列時,不會幹擾漸進式轉譯,且會按照您指定的順序盡快執行。「2.js」可以在「1.js」之前免費下載,但在「1.js」成功下載並執行或失敗前才會執行。呵呵!下載非同步,但排序執行!

除了 Safari 5.0 之外,所有支援非同步屬性的功能都支援以這種方式載入指令碼,但 Safari 5.0 除外 (5.1 除外)。此外,對於所有 Firefox 和 Opera 版本而言,由於瀏覽器不支援非同步屬性,所以可以依照日後在文件中加入的順序,輕鬆執行動態新增的指令碼。

這是以最快速度載入指令碼的方法嗎?序列

若是由您動態決定要載入的指令碼,當然不是,在上述範例中,瀏覽器必須剖析和執行指令碼,才能找出要下載的指令碼。這麼做會隱藏指令碼,避免系統預先載入掃描器。瀏覽器會利用這些掃描器,從您接下來可能會造訪的網頁中尋找資源,或在其他資源封鎖剖析器時探索網頁資源。

只要在文件標題中附加以下程式碼,我們就能重新開放可偵測性:

<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>

每項強化指令碼都會處理特定頁面元件,但需要 dependency.js 中的公用程式函式。理想情況下,我們想要以非同步方式下載所有下載項目,然後盡快以任何順序 (但在 dependency.js 之後) 執行強化指令碼。採用循序漸進的漸進增強效果! 遺憾的是,除非指令碼本身會修改以追蹤依附元件.js 的載入狀態,否則目前沒有宣告方式。就算 async=false 也無法解決問題,因為這項功能在 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 的就緒狀態指令碼載入,並延後到延遲載入。

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」的指令碼,我可能不會忽略「延遲」。

非同步

<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);
});

Spec 表示:一起下載,並在全部下載時依序執行。Firefox < 3.6, Opera 說:我不知道這個「非同步」是什麼,但其實,我按照加入的順序執行透過 JS 新增的指令碼。 Safari 5.0 表示:我理解「非同步」,但不瞭解使用 JS 將其設為「false」。我會依照任何順序立即執行指令碼。 IE < 10 表示:這對於「非同步」的概念,沒有「非同步」的概念,但有「onReadystatechange」這個解決方法。 其他紅色瀏覽器顯示為紅色,指出:我不瞭解這個「非同步」情況,所以每當收到你的指令碼,我們就會立刻執行你的指令碼,順序不限。 其他說明:我是你的朋友,我們打算這本書。