阻塞渲染的 CSS 與 JavaScript:critical CSS、defer 與 async
即使圖片已經下載完、字型已經就緒,只要有資源在 阻塞渲染,瀏覽器就不會繪製任何東西,使用者面對的是一片空白。
這是 LCP 第四段(元素繪製延遲)的主因。
兩種阻塞
| 資源 | 阻塞什麼 | 嚴重程度 |
|---|---|---|
| CSS | 阻塞 渲染(HTML 仍在解析) | 中 |
同步 <script> | 阻塞 HTML 解析 | 高 |
同步載入的程式碼更嚴重
CSS 阻塞的是「繪製」,HTML 仍在背景解析,資源仍在下載。
同步載入的程式碼阻塞的是「解析」,整份 HTML 停在那一行,後面的內容連 DOM 都還沒建立。
所以同步載入的程式碼放在 <head> 是最糟的組合。
為什麼 CSS 會阻塞渲染
這是刻意的設計
瀏覽器必須知道 所有樣式 才能決定元素長什麼樣。
如果先畫再套樣式,使用者會看到「沒有樣式的內容閃一下、然後變成正常版面」,這叫 FOUC(Flash of Unstyled Content),體驗比等一下更糟,而且那次重排會計入 CLS 。
所以瀏覽器選擇 等樣式解析完才繪製。
CSS 的三種處理方式
1. 縮小與移除未使用的樣式
先做這個,效益最高
在考慮複雜的技巧之前,先確認:
| 做法 | 見 |
|---|---|
| 移除沒用到的樣式 | 用 DevTools 的 Coverage 面板找 |
| 壓縮檔案 | 檔案最小化與壓縮 |
用 <link> 而非 @import | 引入樣式檔 |
一個 200KB 的樣式檔壓到 30KB,阻塞時間就少了大半,這比 critical CSS 簡單得多。
不要用 @import
/* main.css */
@import url("base.css");
@import url("components.css");@import 是 串接式 的,瀏覽器要先下載 main.css、解析它、才發現還要下載另外兩個檔案。三個檔案變成三輪往返。
改用平行的 <link>:
<link rel="stylesheet" href="/base.css">
<link rel="stylesheet" href="/components.css">2. 關鍵樣式內嵌
把首屏需要的樣式 直接寫在 HTML 裡,其餘的非同步載入:
<head>
<style>
/* 只放首屏需要的樣式 */
body { margin: 0; font-family: system-ui, sans-serif; }
.header { height: 64px; }
.hero { min-height: 400px; }
</style>
<!-- 其餘樣式非同步載入,不阻塞渲染 -->
<link
rel="stylesheet"
href="/styles.css"
media="print"
onload="this.media='all'"
>
<noscript><link rel="stylesheet" href="/styles.css"></noscript>
</head>media="print" 的技巧
media="print" 的樣式表不阻塞螢幕渲染(因為它只在列印時適用),但仍會被下載。onload 觸發後改回 all 就套用了。
<noscript> 是關閉 JavaScript 時的備援。
維護成本不低
| 問題 | 說明 |
|---|---|
| 會過期 | 版面改了,內嵌的樣式沒跟著改 |
| 要靠工具抽取 | 手工挑首屏樣式不現實 |
| HTML 變大 | 每一頁都多了那段內嵌樣式 |
先做壓縮與移除未使用的樣式,樣式檔仍然很大時才考慮這一招。
3. 媒體查詢拆檔
只在特定條件下需要的樣式,用 media 屬性標明,不符合條件時不阻塞渲染:
<!-- 一律阻塞 -->
<link rel="stylesheet" href="/main.css">
<!-- 只在符合條件時阻塞 -->
<link rel="stylesheet" href="/print.css" media="print">
<link rel="stylesheet" href="/wide.css" media="(min-width: 1200px)">JavaScript 的三種載入方式
<!-- 1. 同步:阻塞 HTML 解析 -->
<script src="/app.js"></script>
<!-- 2. defer:不阻塞,文件解析完成後按順序執行 -->
<script src="/app.js" defer></script>
<!-- 3. async:不阻塞,下載完立刻執行、順序不保證 -->
<script src="/analytics.js" async></script>| 同步 | defer | async | |
|---|---|---|---|
| 阻塞 HTML 解析 | 是 | 否 | 否 |
| 執行時機 | 立刻 | 文件解析完成後 | 下載完立刻 |
| 保證執行順序 | 是 | 是 | 否 |
| 能操作完整的 DOM | 否 | 是 | 不保證 |
把它畫成時間軸更好懂,綠色是 HTML 解析,橘色是下載與執行,注意三者「什麼時候擋住解析」的位置不同:
async 不是「完全不擋」
下載階段確實不擋,但 執行 那一刻主執行緒只有一條,HTML 解析照樣得停下來等。所以第三方程式碼即使加了 async,仍可能卡住首次繪製,這與 INP 的長任務是同一件事。
defer 還是 async
不確定就用 defer
| 情況 | 用哪個 |
|---|---|
| 有依賴關係(A 要先於 B) | defer |
| 需要操作 DOM | defer |
| 應用程式的主要程式碼 | defer |
| 完全獨立的第三方程式碼 | async |
| 分析工具 | async |
async 的順序不保證,所以 只適合完全獨立的程式碼。兩段互相依賴的都用 async 會偶發性地壞掉,而且很難重現。
ES 模組預設就是 defer
不必再寫 defer。要立刻執行的話反而要明確加 async。
<!-- type="module" 隱含 defer 的行為 -->
<script type="module" src="/app.js"></script>建置工具通常已經處理好
Vite 這類工具產出的入口程式碼會是 <script type="module">,並自動加上 modulepreload。自己手寫 <script> 標籤的機會比想像中少。
第三方程式碼
這通常是最大的阻塞來源
您的程式碼可能只有 50KB,但廣告、分析、客服、A/B 測試工具加起來可能好幾百 KB,而且您改不了它們的內容。
分三步處理:
第一步:確認真的需要
最有效的優化,也最難推動
問清楚每一段第三方程式碼:誰在用它?多久看一次那份報表?
實務上經常發現有些程式碼是「三年前某次活動加的,現在沒人看」。移除一段程式碼比優化十個設定有效。
第二步:加上 async
<!-- 分析工具:完全獨立,用 async -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>第三步:延到互動後才載入
<!-- 客服元件:使用者點了才載入 -->
<button id="open-chat">聯絡客服</button>
<script>
document.getElementById("open-chat").addEventListener(
"click",
() => {
const s = document.createElement("script");
s.src = "https://third-party.example.com/chat.js";
document.head.appendChild(s);
},
{ once: true },
);
</script>適合延後載入的第三方元件
| 元件 | 觸發時機 |
|---|---|
| 客服對話 | 點擊按鈕 |
| 社群分享按鈕 | 捲到文章底部 |
| 留言系統 | 捲到留言區 |
| 嵌入的影片 | 點擊縮圖(見 圖片延遲載入 ) |
| 地圖 | 點擊地圖區塊 |
這同時改善 INP,沒載入的程式碼不會有長任務。
怎麼找出阻塞的資源
| 工具 | 怎麼看 |
|---|---|
| Lighthouse | 「Eliminate render-blocking resources」直接列出來 |
| DevTools 的 Network | 依 Waterfall 排序,看哪些在第一波 |
| DevTools 的 Coverage | 看樣式與程式碼有多少比例真的用到 |
| DevTools 的 Performance | 時間軸上看首次繪製前發生了什麼 |
Coverage 面板最實用
它會顯示「這個檔案有 80% 的程式碼在這次載入中沒用到」,那 80% 就是可以拆出去延後載入的部分。
按 Ctrl + Shift + P 輸入 Coverage 開啟。
把第三方程式碼交給環境變數
打包工具的預設行為已經處理掉大部分的阻塞問題:
| 項目 | 常見的預設處理 |
|---|---|
| 樣式 | 建置時打包並壓縮,檔名帶雜湊可長期快取 |
| 程式碼 | 產出 type="module" 的入口,隱含 defer |
剩下要自己決定的是第三方程式碼。分析工具用 async 載入,而且 只在正式機載入:
// 只有正式機設定 ID,留空則完全不載入
...(gaId
? [
[
'script',
{
async: '',
src: `https://www.googletagmanager.com/gtag/js?id=${gaId}`,
},
],
// ...
]
: []),開發與測試環境完全不載入第三方程式碼
這樣做有兩個好處:本機開發不受第三方影響(除錯乾淨),測試機的效能量測也不會被分析工具干擾。
這是效能與環境設定的交集,用環境變數控制第三方程式碼,比在原始碼裡寫條件判斷乾淨。
樣式檔本身不大時不必急著內嵌 critical CSS,做壓縮就足夠了。這符合前面說的順序:先做簡單的,效益不夠再上複雜的。
檢查清單
| 項目 | 標準 |
|---|---|
沒有同步的 <script> 在 <head> | 必備 |
所有程式碼有 defer 或 async | 必備 |
有依賴關係的程式碼用 defer 而非 async | 必備 |
沒有用 @import 串接樣式 | 必備 |
| 樣式已壓縮 | 必備 |
| 已移除未使用的樣式 | 建議 |
條件式樣式用 media 標明 | 建議 |
第三方程式碼用 async | 必備 |
| 非必要的第三方元件延到互動後載入 | 建議 |
| 第三方程式碼只在需要的環境載入 | 建議 |
| 已用 Coverage 面板檢查使用率 | 建議 |
常見問題
為什麼 CSS 會阻塞渲染?
因為瀏覽器必須知道所有樣式才能決定元素長什麼樣。若先畫再套樣式,使用者會看到沒有樣式的內容閃一下再變成正常版面,那比等一下更糟。所以它選擇等樣式解析完才繪製。
defer 和 async 該用哪一個?
有依賴關係或需要操作 DOM 的用 defer,它保證按文件順序執行且在文件解析完成後才跑。完全獨立的第三方程式碼用 async,它下載完就立刻執行、順序不保證。不確定就用 defer。
關鍵樣式內嵌值得做嗎?
首屏內容多、外部樣式檔又大的網站值得。但它的維護成本不低:內嵌的樣式會隨版面改動而過期,而且要靠工具自動抽取。樣式檔本身不大的話,先做壓縮與移除未使用的樣式效益更高。
同步載入的程式碼為什麼特別嚴重?
它會讓 HTML 解析完全停下來,等程式碼下載並執行完才繼續。放在檔頭的話會擋住整份文件的解析,後面的內容連 DOM 都還沒建立,畫面自然是空白的。
第三方程式碼要怎麼處理?
分三步:先確認真的需要它、加上 async 避免阻塞、再評估能不能延到互動後才載入。客服元件與分享按鈕特別適合等使用者點了才載入。