
script 中 async、defer 與 module 的差異
前面已經知道 HTML parser 遇到 script 時,下載與執行時機可能會影響 DOM 建立和畫面顯示。不過我們常說的「載入 JavaScript」,其實還可以拆成 fetch、parse、compile 與 execute 等工作;async、defer 和 module 主要是在改變資源取得與執行如何配合 HTML parsing,不代表 JavaScript 下載完成後就已經執行完成。
這篇會先用帶有 src 的外部 classic script,比較不加屬性、加上 async 與加上 defer 的差異,再補上 type="module" 的預設行為。每種方式適合的情境都不太一樣,不能無腦使用喔!
預設 script
當 HTML parser 讀到沒有 async、defer 或 type="module" 的外部 script 時,會先暫停解析 HTML,等待 script 下載並執行完成後,才繼續解析後面的內容。
如果把這種 script 放在 <head> 裡面,可能會延後後面的頁面內容出現在畫面上的時間。通常使用者一覺得等待太久,就會想離開當前頁面,所以還是要依照情境使用!
寫法上就會是這樣:
<script src="script.js"></script>雖然會阻塞 HTML 解析,但還是有適合的使用場景。比方說為了避免頁面先顯示淺色、再突然跳成深色,通常會在瀏覽器開始呈現頁面前,透過一小段 inline script 同步套用正確的主題。這時候刻意利用阻塞行為,就能避免主題閃爍。

async script
async script 可以在 HTML 解析期間並行下載,下載完成後就會盡快執行。如果當下 HTML 還在解析,執行 script 時仍會暫停解析;而且多支 async script 會依照各自準備完成的時間執行,不保證文件中的宣告順序。
所以,當 script 需要固定的執行順序時,通常不建議使用 async。它比較適合不必等 DOM 解析完成,也不依賴其他 script 的獨立功能,例如 Google Analytics 或其他行為偵測工具。因為使用者可能在兩秒內操作完畫面就關閉頁面;如果追蹤工具直到五秒後才完成下載與初始化,前面的操作就可能來不及被記錄。因此,這類工具通常會希望在 HTML 解析期間下載,並在資源準備好後盡快執行,Google 官方提供的 tag 範例就使用 async。
不過,async 在這裡能做的,只是讓 script 不必等待 HTML 解析完成便執行;實際上能不能記錄到使用者離開前的操作,還是會受到網路速度、tag 初始化、事件送出時機及使用者同意狀態等因素影響,不是加上 async 就一定不會漏掉。
寫法上就會像下面這樣:
<script async src="script.js"></script>除了直接寫在 HTML 裡,也可以用 JavaScript 動態產生外部 script。這類 script 預設會採用 async 的執行方式;如果多支動態 script 之間需要維持順序,就要在插入文件前明確設定 script.async = false:
const sources = ['/vendor.js', '/app.js']
for (const source of sources) {
const script = document.createElement('script')
script.async = false
script.src = source
document.body.append(script)
}這裡要特別注意,script.async = false 不代表動態 script 會變成預設的 parser-blocking script。只有由 HTML parser 解析出來,而且沒有 async、defer 或 type="module" 的 classic script,才會中斷 HTML 解析。透過 document.createElement() 建立的 script 並不是 parser-inserted script,設定 async = false 只會讓多支 script 依照插入順序執行;它們仍會在符合順序且資源準備好後盡快執行,不會像 defer 一樣等待 HTML 解析完成。

defer script
defer script 可以在 HTML 解析期間並行下載,但會等文件解析完成後才執行。多支 defer script 會依照文件中的宣告順序執行,而且都會在 DOMContentLoaded 事件觸發前執行完成。
有了這個特性後,defer 就很適合需要操作 DOM,或彼此有固定執行順序的 script。比方說,要先載入 Vue,接著才能執行依賴 Vue 的第三方套件,就很適合這樣做!
<script defer src="vue.js"></script>
<script defer src="plugin.js"></script>
這裡要注意,defer 只對帶有 src 的 classic script 有效果;把 defer 加在 inline classic script 上不會得到相同的延後行為。
module script
<script type="module"> 會使用 JavaScript module 的載入規則。外部 module script 與 HTML parsing 並行下載,並遞迴取得它 import 的 dependencies;在沒有 async 時,預設行為和 defer 類似,會等文件解析完成後才執行,而且 DOMContentLoaded 也會等待 module 執行完成。
<script type="module" src="/assets/app.js"></script>Inline module 也會延後到文件解析完成後執行,不需要額外加上 defer:
<script type="module">
import { mountApp } from '/assets/app.js'
mountApp()
</script>Module script 還有幾個跟 classic script 不一樣的地方:它預設使用 strict mode、同一個 module 在同一份 document 中只會 evaluate 一次,而且跨來源取得 module 時會套用 CORS。假如在 module script 上再加上 async,就不會等待 HTML 解析完成,也不保證多支 module script 依文件順序執行;整個 dependency graph 準備好後便可以盡快執行。
<script async type="module" src="/analytics.js"></script>所以 module 並不是把 classic script 換個副檔名而已。除了下載時機,還要一起考慮 dependency graph、CORS 與執行順序。
總結
如果先把 script 的載入簡化成「取得」與「執行」兩個階段,幾種寫法的差異就會清楚很多:
| 寫法 | 取得時機 | 執行時機 | 是否中斷 HTML 解析 | DOMContentLoaded 是否等待 | 多支 script 的執行順序 |
|---|---|---|---|---|---|
| 預設 classic script | 解析到標籤時等待下載 | 下載完成後立即執行 | 下載與執行都會阻塞 | 解析會等它完成 | 依文件中的宣告順序 |
async classic script | 與 HTML 解析並行下載 | 下載完成後盡快執行 | 執行時仍會中斷解析 | 不會特別等待 | 不保證順序 |
defer classic script | 與 HTML 解析並行下載 | HTML 解析完成後執行 | 不會在解析途中阻塞 | 會等待 | 依文件中的宣告順序 |
| module script | 與 HTML 解析並行取得 dependency graph | HTML 解析完成後執行 | 不會在解析途中阻塞 | 會等待 | 沒有 async 時依文件順序處理 |
async module script | 與 HTML 解析並行取得 dependency graph | 準備完成後盡快執行 | 執行時可能中斷解析 | 不會特別等待 | 不保證順序 |
簡單來說,如果 script 完全獨立、不依賴 DOM 解析完成或其他 script,而且希望準備完成後盡快執行,可以考慮使用 async;如果 classic script 需要操作 DOM,或彼此之間有固定的執行順序,defer 通常會是比較穩定的選擇。使用 ESM 時,module script 本身就有 defer-like 行為,不需要再加 defer。只有在 script 必須先執行完成,後面的 HTML 才能繼續解析時,才需要使用預設的阻塞行為。
另外,透過 JavaScript 動態插入的外部 classic script 預設會採用 async 的執行方式。如果多支動態 script 之間需要維持順序,就要在插入文件前明確設定 script.async = false。
Script 要在什麼時間執行是一件事,執行後會不會長時間占住 Main Thread 又是另一件事。後續談到 Event Loop 與 Long Task 時,會再接著看 JavaScript 為什麼可能延後輸入事件與下一次畫面更新。