瀏覽器讀 HTML 很像逐行讀稿,讀到一支普通的外部 JavaScript,它會先把稿子放下,等那支腳本下載、執行完畢才繼續往下讀。如果這支 JS 檔案又大又放在 head 裡,使用者盯著空白畫面的那幾秒,多半就是這個習慣造成的,而 defer 這個屬性要處理的正是這件事。
defer 與 async 都是用來解除這個停頓的屬性。兩者都讓腳本一邊下載、一邊讓瀏覽器繼續解析頁面,差別在於下載完之後「什麼時候執行」。defer 排到整份 HTML 解析完才依序執行,async 則是誰先下載完就立刻跑。這個差別只有一個單字,卻決定了腳本會不會讀不到 DOM、會不會比它依賴的函式庫更早執行,也連帶影響 LCP 與 INP 這兩個 Core Web Vitals 指標。
defer 是什麼?
defer 是加在外部 <script> 標籤上的布林屬性,用意是讓瀏覽器一邊解析 HTML、一邊下載這支 JavaScript,等整份文件解析完成後才執行它。
先看沒有任何屬性的情況。MDN Web Docs 說明,沒有 async、defer 或 type="module" 的腳本,會在瀏覽器繼續解析頁面之前就被抓取並執行。也就是說,解析器讀到 <script src="app.js"> 就停下來,等 app.js 下載、執行完才繼續往下讀。放在 head 的大型 JS 檔案,會讓這段等待直接變成使用者眼前的空白畫面。
加上 defer 之後,下載與解析同時進行,腳本不會再擋住後面的 HTML。它的執行時間點被排到解析完成之後、DOMContentLoaded 事件觸發之前,而且 DOMContentLoaded 會等 defer 腳本執行完才觸發。同一份文件有多支 defer 腳本時,它們照在文件中出現的順序執行,與誰先下載完無關。
<script defer src="app.js"></script>Code language: HTML, XML (xml)
defer 只對有 src 的外部腳本有效。沒有 src 的 inline 腳本就算加了 defer,也不會有任何效果,瀏覽器讀到時照樣立刻執行。
早期常見的做法是把 script 放在 </body> 結尾標籤前面,MDN 在 DOMContentLoaded 的說明裡,也提到這樣寫可以省去事件監聽。defer 達成的是同一件事,也就是等 DOM 建好再執行。不同的是標籤可以留在 head,頁面載入了哪些腳本一眼就看得到,執行時機也由屬性明確規定,不再靠擺放位置來暗示。
async 在下載完成的當下執行,順序不保證
async 同樣讓腳本與 HTML 解析並行下載,但執行規則完全不同。async 腳本只要下載完就立刻執行,不管解析進行到哪裡。
這代表兩件事。第一,如果 HTML 還沒解析完,async 腳本一開始執行就會打斷解析。WHATWG HTML Standard 的示意圖也是這樣畫的,下載與解析並行,下載一完成,解析就停下來讓腳本執行。第二,多支 async 腳本的執行順序,只取決於誰先下載完,與它們在 HTML 裡的先後無關。

拿一組常見的 jQuery 寫法當例子,就能看出這個風險。下面三支腳本都標了 async,其中 script2.js 與 script3.js 會呼叫 jQuery 的函式。
<script async src="js/vendor/jquery.js"></script>
<script async src="js/script2.js"></script>
<script async src="js/script3.js"></script>Code language: HTML, XML (xml)
jquery.js 的檔案通常比較大,很可能比另外兩支晚到。晚到的結果,是 script2.js 執行時 jQuery 還不存在,瀏覽器丟出 $ is not defined 或 jQuery is not defined 這類錯誤,出現哪一個要看程式碼用哪個名稱呼叫。如果把三個標籤都換成 defer,三支腳本就會照寫的順序執行,jquery.js 一定排在最前面。
所以 async 腳本不能假設 DOM 已經建好,也不能假設另一支 async 腳本已經載入。async 最適合彼此獨立、不依賴頁面上其他程式的腳本,統計代碼就是典型例子。
<script async src="analytics.js"></script>Code language: HTML, XML (xml)
async 和 defer 也能同時寫在一個標籤上。MDN 指出,兩者並存時,瀏覽器的行為視同只指定了 async。WHATWG HTML Standard 解釋了保留這種寫法的目的,是讓只認 defer、不認 async 的舊瀏覽器退回 defer 的行為,而不是預設的阻擋行為。
同一組腳本在三種寫法下的執行時序
要看出三種寫法的差別,最直接的方法是拿同一份 HTML 換屬性、看 console 的輸出順序。假設頁面載入兩支外部腳本,a.js 檔案較大,負責定義一個全域物件 lib,b.js 檔案較小,執行時要讀取 lib,所以 b 依賴 a。
// a.js 檔案較大,定義 lib
window.lib = { name: "lib" };
console.log("a.js ran");Code language: JavaScript (javascript)
// b.js 檔案較小,依賴 lib
console.log("b.js ran, lib =", window.lib.name);Code language: JavaScript (javascript)
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<title>執行時序測試</title>
<script src="a.js"></script>
<script src="b.js"></script>
</head>
<body>
<p>頁面內容</p>
<script>
console.log("body parsed");
document.addEventListener("DOMContentLoaded", () => {
console.log("DOMContentLoaded");
});
</script>
</body>
</html>Code language: HTML, XML (xml)
第一種是上面這份沒有任何屬性的寫法。解析器讀到 a.js 就停下來,等它下載並執行,再處理 b.js,最後才讀到 body。console 的輸出是:
a.js ran
b.js ran, lib = lib
body parsed
DOMContentLoadedCode language: plaintext (plaintext)
順序正確,代價是 head 裡的兩支腳本都擋住了後面的內容。
第二種是把 head 裡的兩行改成 defer。
<script defer src="a.js"></script>
<script defer src="b.js"></script>Code language: HTML, XML (xml)
這時兩支腳本在解析進行的同時下載,b.js 即使先到,也要等 a.js 執行完。輸出變成:
body parsed
a.js ran
b.js ran, lib = lib
DOMContentLoadedCode language: plaintext (plaintext)
inline 腳本先印出 body parsed,因為 defer 腳本要等整份文件解析完才輪到。接著 a.js、b.js 依文件順序執行,最後才觸發 DOMContentLoaded。無論網路快慢,這個順序都固定。
第三種是改成 async。
<script async src="a.js"></script>
<script async src="b.js"></script>Code language: HTML, XML (xml)
b.js 檔案小,通常先下載完,也就先執行。這時 window.lib 還沒有被 a.js 建立,b.js 會丟出 TypeError,Chrome 的訊息是 Cannot read properties of undefined (reading 'name')。反過來,如果某次 a.js 先到,兩支腳本就都能正常印出、不會報錯。同一份程式碼時好時壞,正是 async 用在有依賴關係的腳本上最典型的症狀。
body parsed 與 DOMContentLoaded 在輸出裡的位置,同樣會隨下載時間變動。像這份很短的測試頁,HTML 往往在腳本下載完之前就已解析完畢,所以兩支 async 腳本多半在 DOMContentLoaded 之後才執行。

三種寫法的差異,可以整理成下面這張表。
| 寫法 | 執行時機 | 執行順序 | 是否打斷解析 |
|---|---|---|---|
| 沒有屬性 | 解析器讀到時,下載完立即執行 | 照文件順序 | 解析在下載與執行期間都被擋住 |
| defer | 整份 HTML 解析完成之後、DOMContentLoaded 之前 | 照文件順序 | 不打斷,下載與解析並行 |
| async | 下載完成的當下 | 不保證 | 下載時不擋,執行時會打斷解析 |
這張表的依據是 WHATWG HTML Standard 的處理規則。瀏覽器準備一支外部 classic script 時,帶 async 的腳本會進入「一可用就執行」的集合,下載好就馬上執行。沒有 async 但帶 defer 的腳本,會進入「文件解析完成後執行」的有序清單,即使下載完了也只是標記為就緒,等解析結束才依清單順序執行。兩者都沒有的腳本,解析器會停下來等它。
依賴關係和執行急迫性,決定該用 defer 或 async
挑屬性時,不能只背一句「獨立的用 async、有依賴的用 defer」,更實用的做法是同時看兩個條件,一是這支腳本依賴什麼,二是它多急著執行。
MDN 的判斷偏重依賴關係,它的建議是外部腳本預設加上 defer,不需要等 HTML 就緒的才改用 async。web.dev 在談第三方腳本時,則多加了急迫性這個面向,建議第三方腳本除非是關鍵渲染路徑必需的,否則一律加上 async 或 defer。需要在載入過程中早一點執行的,例如部分統計腳本,用 async;較不重要的資源,例如位在頁面下方、使用者一開始看不到的影片,改用 defer。
兩種建議並不衝突,只是切入角度不同。前者問「它需要什麼才能跑」,後者問「它值不值得搶在前面跑」,實際決定時兩個問題都問一遍,判斷才比較周全。

| 腳本類型 | 建議屬性 | 理由 |
|---|---|---|
| 讀取或改動 DOM 的互動腳本 | defer | 解析完成後才執行,DOM 已經就緒 |
| 函式庫,以及依賴它的外掛或應用程式碼 | defer,標籤依執行順序排列 | defer 腳本照文件順序執行,依賴鏈不會斷 |
| 統計與廣告代碼 | async | 彼此獨立,不需要 DOM,越早載入越好 |
| 背景預載的資料檔 | async | 只要盡快就位,不在乎和其他腳本的先後 |
| 折疊線下方的影片播放器等次要資源 | defer | 不急著搶在第一次顯示前執行 |
容易誤會的一點是,defer 並不會讓下載變慢或改成逐一下載,所有 defer 腳本仍然並行下載。被延後的是「執行時機」,所以有依賴關係時,要把 <script> 標籤擺成想要的執行順序,瀏覽器會照這個順序執行。
inline 腳本搶先 defer 函式庫執行的錯誤與修正
defer 最常見的踩雷情境,是外部函式庫標了 defer,旁邊卻有一段 inline 腳本直接呼叫它。
<script defer src="library.js"></script>
<script>
library.init();
</script>Code language: HTML, XML (xml)
執行這段,console 會出現 ReferenceError: library is not defined。原因在於 defer 與 async 管不到 inline 的 classic script,WHATWG 的屬性適用表裡,這兩個屬性對 inline classic script 都標為不適用。瀏覽器解析到這段 inline 腳本的當下就立刻執行,而 library.js 要等整份文件解析完才會執行,所以 library 這個名稱當時還不存在。
最直接的改法,是把 inline 的邏輯搬到另一支外部檔案,一起標上 defer,並擺在 library.js 後面。
<script defer src="library.js"></script>
<script defer src="app.js"></script>Code language: HTML, XML (xml)
app.js 裡就能放 library.init();。兩支 defer 腳本會依序執行,library.js 先、app.js 後,呼叫發生時 library 已經就緒。
如果非得留在 inline,就把呼叫包進 DOMContentLoaded 的監聽裡。
document.addEventListener("DOMContentLoaded", () => {
library.init();
});Code language: JavaScript (javascript)
這樣可行,是因為 DOMContentLoaded 會等所有 defer 腳本下載並執行完才觸發。inline 腳本本身雖然先跑,但真正呼叫 library.init() 的時間點,已經被推遲到 library.js 執行完之後。
DOMContentLoaded、load 與 readyState 的先後順序
defer 和 async 的差別,換成事件的角度來看,就是一條時間線上各個節點的先後。HTML 解析完成之後,順序大致是下面這樣。
document.readyState變成interactive。- defer 腳本與 module 腳本依序執行。
DOMContentLoaded事件觸發。- 還沒跑完的 async 腳本繼續執行。
document.readyState變成complete。load事件觸發。
MDN 指出,DOMContentLoaded 不等圖片、子框架與 async 腳本,但 defer 腳本會等樣式表載入,所以樣式表下載得慢的話,也會連帶拖慢 DOMContentLoaded。load 則不同。WHATWG 規定,「盡快執行」的 async 腳本集合,以及動態腳本「依序盡快執行」的清單都清空之後,瀏覽器才會觸發 load 事件。因此 async 腳本會延遲 load,卻不會延遲 DOMContentLoaded。把 load 當成 DOMContentLoaded 來用,等於多等了圖片和所有 async 腳本。

async 腳本和 DOMContentLoaded 的先後則不固定。若它在解析完成前就下載完,會在 DOMContentLoaded 之前執行;若比較晚到,就會在之後執行。動態插入的腳本同樣可能在事件發生之後才執行,所以這類腳本執行時,DOMContentLoaded 可能已經過去,此時再用 addEventListener 掛監聽,監聽函式永遠不會被呼叫。
穩妥的做法是先檢查 document.readyState,再決定直接執行還是掛監聽。
function initPage() {
console.log("DOM ready");
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initPage);
} else {
initPage();
}Code language: JavaScript (javascript)
readyState 還是 loading 時,代表 HTML 還在解析、事件尚未觸發,這時掛上監聽;否則事件已經發生或正在進行,直接執行即可。這個檢查不會有競態條件(race condition)的問題,因為 JavaScript 一次只執行一個工作,從檢查到掛上監聽之間,瀏覽器不會插進其他工作去觸發事件。
模組腳本本來就會延遲執行,加上 async 改為下載完就執行
type="module" 的腳本不需要再加 defer,它本來就會延遲執行。模組腳本連同它的依賴會並行下載,等頁面解析完成才執行。WHATWG 規定 defer 屬性對 module script 沒有效果。
<script type="module" src="main.js"></script>Code language: HTML, XML (xml)
模組腳本與 defer 腳本共用同一個「解析完成後執行」的有序清單,所以兩者混在同一份文件裡,仍然依文件順序執行,並且都排在 DOMContentLoaded 之前。
如果給模組加上 async,規則就改變了。模組與它所有的依賴會並行下載,一可用就執行,不再等解析完成。inline 的 module script 也可以加 async,這是 inline classic script 辦不到的。
模組還有一個配套的 nomodule 屬性,用來給不支援模組的舊瀏覽器放後備腳本。支援模組的瀏覽器會直接忽略帶 nomodule 的標籤,舊瀏覽器則看不懂 type="module",會跳過模組那一行,改執行後備版本。WHATWG 規格的範例裡,後備腳本也搭配了 defer。
<script type="module" src="main.js"></script>
<script nomodule defer src="legacy.js"></script>Code language: HTML, XML (xml)
模組腳本還有幾個特性。它會自動使用嚴格模式;同一個模組即使被多處引用,也只會執行一次;從其他網域載入時,伺服器需要回應 CORS 標頭。
動態插入的 script 預設等同 async
用 JavaScript 建立 <script> 再插入文件,是載入器與第三方嵌入碼常用的做法。這種腳本不是由 HTML 解析器產生的,行為和直接寫在 HTML 裡的 script 標籤不一樣。
根據 WHATWG HTML Standard,用 document.createElement("script") 建立的腳本有一個 force async 旗標,預設為 true,所以就算標籤上沒有 async 屬性,script.async 讀出來也是 true,會被當成 async 處理。它在插入文件的當下開始下載,誰先下載完誰先執行。
需要照順序執行時,要明確把 async 設為 false。設成 false 以後,腳本會改進入「依序盡快執行」的清單,前面的腳本都就緒並執行完,它才會執行。
function loadScript(src) {
const script = document.createElement("script");
script.src = src;
script.async = false;
document.head.append(script);
}
loadScript("library.js");
loadScript("app.js");Code language: JavaScript (javascript)
這段程式呼叫兩次 loadScript,兩支腳本會同時開始下載,但 app.js 即使先到,也會等 library.js 執行完才執行。
不過這個「依序」和 HTML 裡寫 defer 的依序仍然不同。動態腳本沒有「等解析完成」這個語意,插入當下就開始下載,下載完、而且前面的依序腳本都執行完,就會執行。如果這時 HTML 還在解析,它一執行同樣會打斷解析。需要讓腳本等到文件解析完成的話,要在載入器裡自己檢查 readyState,或改用 HTML 標籤的 defer。

defer 與 async 對 LCP 和 INP 的影響
談到效能指標,最容易出現「加了 defer 分數就會上去」的誤解。
先看評估門檻。web.dev 的說明裡,LCP 在 2.5 秒以內算良好;INP 在 200 毫秒以內算良好,超過 500 毫秒則屬於緩慢,中間為待改善。兩個指標都以第 75 百分位數評估,意思是 75% 的頁面載入都要達標。另一個相關名詞是長工作,指主執行緒上耗時超過 50 毫秒的工作,這種工作會讓頁面一時無法回應輸入。
defer 與 async 只改變腳本「何時」載入與執行,不改變腳本本身的大小。所以它們對兩個指標的幫助,各有自己的限制。
解除腳本的渲染阻擋不等於 LCP 元素提早出現
Chrome for Developers 的 Lighthouse 說明指出,被標為妨礙顯示資源的腳本,條件有三個:位於 <head>、沒有 defer、沒有 async。加上其中一個屬性,就能解除腳本對第一次渲染的阻擋,非關鍵程式碼因此可以保留在檔案裡,只是改成延後執行。web.dev 也提醒,head 裡的同步腳本幾乎都會對效能造成負面影響,需要盡快執行的程式碼如果很小,可以直接改寫成 inline。
不過 defer 並不保證 LCP 元素會更快出現。如果 LCP 元素本身是由 JavaScript 動態加入,或被延遲載入的函式庫先隱藏起來,畫面上的主內容就被綁在腳本的時程上,這時把腳本改成 defer,只會讓它更晚執行,LCP 反而可能延後。另外,擋住渲染的原因也不只有腳本,head 裡的樣式表同樣會擋。
根據 web.dev 對 Fetch Priority 的說明,async 與 defer 的腳本在 Chrome 裡預設的抓取優先度是 Low。如果其中某一支確實關鍵,就可以用 fetchpriority="high" 把它拉高。
<script async fetchpriority="high" src="critical.js"></script>Code language: HTML, XML (xml)
這只適合極少數真的重要的腳本。每一支都標 high,優先度就失去區別的意義。
腳本執行形成的長工作拖慢 INP
INP 關心的不是腳本怎麼載入,而是腳本執行時佔住主執行緒多久。web.dev 指出,腳本載入後還要評估,也就是剖析、編譯與執行,這段評估本身就可能形成長工作,干擾載入期間的互動。一般來說,每個 <script> 元素對應一個評估工作,所以一支巨大的 bundle 就是一個巨大的工作。
defer 與 async 只是改變這個工作發生的時間點。defer 腳本集中在解析結束後、DOMContentLoaded 之前執行,async 腳本則隨下載完成隨時插進來。腳本如果很大,載入期間仍然會形成長工作,使用者這時點擊畫面,就得等這個工作結束才有反應。
web.dev 在 2023 年一篇談腳本評估的文章裡提到,Chromium 系瀏覽器當時會把所有已載入的 defer 腳本,放進與 DOMContentLoaded 同一個工作中執行,可能讓這個工作變得更長,文中也說正在探索解法。從 Chromium 原始碼的功能設定來看,這項行為後來已依 HTML 規格調整,defer 與 module 腳本改成各自在獨立的工作中執行,目的就是避免長工作。因此在現行的 Chrome 裡,多支 defer 腳本不會再被合併成一個長工作,但一支大型腳本本身仍然是一個工作。
同一篇 web.dev 文章也提到,TBT(Total Blocking Time,總阻塞時間)與 INP 的相關性很高,載入期間 TBT 偏高,是 INP 可能偏高的合理徵兆,不過 TBT 終究是另一個指標。
真正有效的改善多半跟腳本大小有關,例如把一支大 bundle 拆成多個小腳本,讓每個工作變小,或是把非必要的模組改用動態 import() 延後載入,降低啟動期間主執行緒的競爭。換句話說,defer 與 async 不會讓腳本變輕,只是改變它什麼時候跑,要降低 INP,還是得從腳本本身的份量下手。
WordPress 站台的寫法交給 enqueue 的 strategy 參數
在 WordPress 裡通常不需要手動改 <script> 標籤。從 WordPress 6.3 起,註冊或載入腳本時,第五個參數可以傳入含 strategy 的陣列,值可設為 defer 或 async,由 WordPress 輸出對應的屬性。
defer 與 async 並沒有誰比較好,只有腳本與頁面的關係不同。下次看到 head 裡一排沒有任何屬性的 script,可以先問這支腳本需不需要 DOM 或其他腳本,再問它能不能晚一點執行,兩個答案合起來,就能判斷該掛 defer、掛 async,還是維持原樣。
遇到有依賴關係的腳本,defer 保證執行順序,也保證 DOM 已經就緒,是比較穩的選擇;彼此獨立的統計與廣告代碼,才適合交給 async。
