2021 年 10 月 25 日

腳本:非同步、延遲

在現代網站中,腳本通常「比」HTML「重」:它們的下載大小較大,處理時間也較長。

當瀏覽器載入 HTML 並遇到 <script>...</script> 標籤時,它無法繼續建立 DOM。它必須立即執行腳本。外部腳本 <script src="..."></script> 也是如此:瀏覽器必須等到腳本下載、執行已下載的腳本,然後才能處理頁面的其餘部分。

這會導致兩個重要的問題

  1. 腳本無法看到它們下方的 DOM 元素,因此它們無法新增處理常式等。
  2. 如果頁面頂端有一個龐大的腳本,它會「封鎖頁面」。使用者無法在腳本下載並執行之前看到頁面內容
<p>...content before script...</p>

<script src="https://javascriptinfo.dev.org.tw/article/script-async-defer/long.js?speed=1"></script>

<!-- This isn't visible until the script loads -->
<p>...content after script...</p>

有一些解決方法。例如,我們可以將腳本放在頁面底部。然後它可以看到它上方的元素,並且不會封鎖頁面內容的顯示

<body>
  ...all content is above the script...

  <script src="https://javascriptinfo.dev.org.tw/article/script-async-defer/long.js?speed=1"></script>
</body>

但這個解決方案遠非完美。例如,瀏覽器只有在下載完完整的 HTML 文件後才會注意到腳本(並開始下載腳本)。對於較長的 HTML 文件,這可能會造成明顯的延遲。

對於使用非常快速連線的人來說,這些事情是看不見的,但世界上許多人仍然有慢速的網際網路速度,並使用遠非完美的行動網路連線。

幸運的是,有兩個 <script> 屬性可以幫我們解決這個問題:deferasync

defer

defer 屬性告訴瀏覽器不要等待腳本。瀏覽器將繼續處理 HTML,建立 DOM。腳本在「背景中」載入,然後在 DOM 完全建立後執行。

以下是與上述相同的範例,但使用了 defer

<p>...content before script...</p>

<script defer src="https://javascriptinfo.dev.org.tw/article/script-async-defer/long.js?speed=1"></script>

<!-- visible immediately -->
<p>...content after script...</p>

換句話說

  • 具有 defer 的腳本絕不會阻擋頁面。
  • 具有 defer 的腳本總是在 DOM 準備就緒時執行(但在 DOMContentLoaded 事件之前)。

以下範例示範第二個部分

<p>...content before scripts...</p>

<script>
  document.addEventListener('DOMContentLoaded', () => alert("DOM ready after defer!"));
</script>

<script defer src="https://javascriptinfo.dev.org.tw/article/script-async-defer/long.js?speed=1"></script>

<p>...content after scripts...</p>
  1. 頁面內容立即顯示。
  2. DOMContentLoaded 事件處理常式等待遞延腳本。它只會在腳本下載並執行時觸發。

遞延腳本會保留其相對順序,就像一般腳本一樣。

假設我們有兩個遞延腳本:long.js,然後是 small.js

<script defer src="https://javascriptinfo.dev.org.tw/article/script-async-defer/long.js"></script>
<script defer src="https://javascriptinfo.dev.org.tw/article/script-async-defer/small.js"></script>

瀏覽器會掃描頁面中的腳本並平行下載,以提升效能。因此,在上述範例中,兩個腳本會平行下載。small.js 可能會先完成。

…但 defer 屬性除了告訴瀏覽器「不要阻擋」之外,還會確保保留相對順序。因此,即使 small.js 先載入,它仍會等待並在 long.js 執行後才執行。

當我們需要載入 JavaScript 函式庫,然後載入依賴於它的腳本時,這一點可能很重要。

defer 屬性僅適用於外部腳本

如果 <script> 標籤沒有 src,則會忽略 defer 屬性。

async

async 屬性有點像 defer。它也會讓腳本變成非阻擋式。但它的行為有一些重要的差異。

async 屬性表示腳本完全獨立

  • 瀏覽器不會在 async 腳本上阻擋(就像 defer 一樣)。
  • 其他腳本不會等待 async 腳本,而 async 腳本也不會等待其他腳本。
  • DOMContentLoaded 和非同步腳本不會互相等待
    • DOMContentLoaded 可能發生在非同步腳本之前(如果非同步腳本在頁面完成後才完成載入)
    • …或發生在非同步腳本之後(如果非同步腳本很短或在 HTTP 快取中)

換句話說,async 腳本會在背景中載入,並在準備就緒時執行。DOM 和其他腳本不會等待它們,而它們也不會等待任何東西。這是一個完全獨立的腳本,載入後就會執行。這是不是很簡單呢?

以下是類似於我們在 defer 中看到的範例:兩個腳本 long.jssmall.js,但現在使用 async 取代 defer

它們不會互相等待。無論哪個先載入(可能是 small.js)都會先執行

<p>...content before scripts...</p>

<script>
  document.addEventListener('DOMContentLoaded', () => alert("DOM ready!"));
</script>

<script async src="https://javascriptinfo.dev.org.tw/article/script-async-defer/long.js"></script>
<script async src="https://javascriptinfo.dev.org.tw/article/script-async-defer/small.js"></script>

<p>...content after scripts...</p>
  • 頁面內容立即顯示:async 沒有阻擋它。
  • DOMContentLoaded 可能發生在 async 之前或之後,這裡沒有保證。
  • 較小的腳本 small.js 排在第二個,但可能在 long.js 之前載入,因此 small.js 會先執行。不過,如果 long.js 有快取,可能會先載入,然後先執行。換句話說,非同步腳本會以「先載入」順序執行。

當我們將獨立的第三方腳本整合到網頁中時,非同步腳本非常有用:例如計數器、廣告等,因為它們不依賴我們的腳本,而我們的腳本也不應該等待它們。

<!-- Google Analytics is usually added like this -->
<script async src="https://google-analytics.com/analytics.js"></script>
async 屬性僅適用於外部腳本。

就像 defer 一樣,如果 <script> 標籤沒有 src,則會忽略 async 屬性。

動態腳本

還有一種重要的方式可以將腳本新增到網頁中。

我們可以使用 JavaScript 建立一個腳本,並將其動態附加到文件中。

let script = document.createElement('script');
script.src = "/article/script-async-defer/long.js";
document.body.append(script); // (*)

腳本會在附加到文件後立即開始載入 (*)

動態腳本預設會表現為「非同步」。

也就是說

  • 它們不會等待任何東西,也沒有任何東西會等待它們。
  • 先載入的腳本會先執行(「先載入」順序)。

如果我們明確設定 script.async=false,則可以變更這個順序。然後,腳本將會按照文件順序執行,就像 defer 一樣。

在此範例中,loadScript(src) 函式會新增一個腳本,並將 async 設定為 false

因此,long.js 總是會先執行(因為它是先新增的)。

function loadScript(src) {
  let script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.body.append(script);
}

// long.js runs first because of async=false
loadScript("/article/script-async-defer/long.js");
loadScript("/article/script-async-defer/small.js");

如果不設定 script.async=false,則腳本會以預設的先載入順序執行(small.js 可能會先執行)。

同樣地,就像 defer 一樣,如果我們想要載入一個函式庫,然後載入另一個依賴它的腳本,則順序很重要。

摘要

asyncdefer 有一個共同點:下載此類腳本不會阻擋網頁呈現。因此,使用者可以立即閱讀網頁內容並熟悉網頁。

但它們之間也有重要的差異。

順序 DOMContentLoaded
async 先載入順序。它們的文件順序並不重要,先載入的會先執行。 無關緊要。即使文件尚未完全下載,它們也可能載入並執行。如果腳本很小或有快取,而文件夠長,就會發生這種情況。
defer 文件順序(按照它們在文件中的順序)。 在文件載入並剖析完畢後執行(如果需要,它們會等待),就在 DOMContentLoaded 之前執行。

在實務上,defer 用於需要整個 DOM 和/或其相對執行順序很重要的腳本。

async 用於獨立的腳本,例如計數器或廣告。而且它們的相對執行順序並不重要。

沒有腳本的頁面應該可以使用

請注意:如果您使用 deferasync,那麼使用者將在腳本載入之前看到頁面。

在這種情況下,某些圖形元件可能尚未初始化。

別忘了放入「載入中」指示,並停用尚未運作的按鈕。讓使用者清楚地看到他們可以在頁面上執行哪些操作,以及哪些操作仍在準備中。

教學課程地圖

留言

留言前請先閱讀…
  • 如果您有改善建議 - 請提交 GitHub 議題或發起拉取請求,而不是留言。
  • 如果您無法理解文章中的某些內容 – 請詳細說明。
  • 若要插入幾行程式碼,請使用 <code> 標籤,對於多行程式碼 – 請將它們包覆在 <pre> 標籤中,對於超過 10 行的程式碼 – 請使用沙盒 (plnkrjsbincodepen…)