Skip to content

阻塞渲染的 CSS 與 JavaScript:critical CSS、defer 與 async

阻塞渲染的 CSS 與 JavaScript: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

css
/* main.css */
@import url("base.css");
@import url("components.css");

@import串接式 的,瀏覽器要先下載 main.css、解析它、才發現還要下載另外兩個檔案。三個檔案變成三輪往返。

改用平行的 <link>

html
<link rel="stylesheet" href="/base.css">
<link rel="stylesheet" href="/components.css">

2. 關鍵樣式內嵌

把首屏需要的樣式 直接寫在 HTML 裡,其餘的非同步載入:

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 屬性標明,不符合條件時不阻塞渲染:

html
<!-- 一律阻塞 -->
<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 的三種載入方式

html
<!-- 1. 同步:阻塞 HTML 解析 -->
<script src="/app.js"></script>

<!-- 2. defer:不阻塞,文件解析完成後按順序執行 -->
<script src="/app.js" defer></script>

<!-- 3. async:不阻塞,下載完立刻執行、順序不保證 -->
<script src="/analytics.js" async></script>
同步deferasync
阻塞 HTML 解析
執行時機立刻文件解析完成後下載完立刻
保證執行順序
能操作完整的 DOM不保證

把它畫成時間軸更好懂,綠色是 HTML 解析,橘色是下載與執行,注意三者「什麼時候擋住解析」的位置不同:

async 不是「完全不擋」

下載階段確實不擋,但 執行 那一刻主執行緒只有一條,HTML 解析照樣得停下來等。所以第三方程式碼即使加了 async,仍可能卡住首次繪製,這與 INP 的長任務是同一件事。

defer 還是 async

不確定就用 defer

情況用哪個
有依賴關係(A 要先於 B)defer
需要操作 DOMdefer
應用程式的主要程式碼defer
完全獨立的第三方程式碼async
分析工具async

async 的順序不保證,所以 只適合完全獨立的程式碼。兩段互相依賴的都用 async 會偶發性地壞掉,而且很難重現。

ES 模組預設就是 defer

不必再寫 defer。要立刻執行的話反而要明確加 async

html
<!-- type="module" 隱含 defer 的行為 -->
<script type="module" src="/app.js"></script>

建置工具通常已經處理好

Vite 這類工具產出的入口程式碼會是 <script type="module">,並自動加上 modulepreload。自己手寫 <script> 標籤的機會比想像中少。

第三方程式碼

這通常是最大的阻塞來源

您的程式碼可能只有 50KB,但廣告、分析、客服、A/B 測試工具加起來可能好幾百 KB,而且您改不了它們的內容。

分三步處理:

第一步:確認真的需要

最有效的優化,也最難推動

問清楚每一段第三方程式碼:誰在用它?多久看一次那份報表?

實務上經常發現有些程式碼是「三年前某次活動加的,現在沒人看」。移除一段程式碼比優化十個設定有效。

第二步:加上 async

html
<!-- 分析工具:完全獨立,用 async -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>

第三步:延到互動後才載入

html
<!-- 客服元件:使用者點了才載入 -->
<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 載入,而且 只在正式機載入

js
// 只有正式機設定 ID,留空則完全不載入
...(gaId
  ? [
      [
        'script',
        {
          async: '',
          src: `https://www.googletagmanager.com/gtag/js?id=${gaId}`,
        },
      ],
      // ...
    ]
  : []),

開發與測試環境完全不載入第三方程式碼

這樣做有兩個好處:本機開發不受第三方影響(除錯乾淨),測試機的效能量測也不會被分析工具干擾。

這是效能與環境設定的交集,用環境變數控制第三方程式碼,比在原始碼裡寫條件判斷乾淨。

樣式檔本身不大時不必急著內嵌 critical CSS,做壓縮就足夠了。這符合前面說的順序:先做簡單的,效益不夠再上複雜的。

檢查清單

項目標準
沒有同步的 <script><head>必備
所有程式碼有 deferasync必備
有依賴關係的程式碼用 defer 而非 async必備
沒有用 @import 串接樣式必備
樣式已壓縮必備
已移除未使用的樣式建議
條件式樣式用 media 標明建議
第三方程式碼用 async必備
非必要的第三方元件延到互動後載入建議
第三方程式碼只在需要的環境載入建議
已用 Coverage 面板檢查使用率建議

常見問題

為什麼 CSS 會阻塞渲染?

因為瀏覽器必須知道所有樣式才能決定元素長什麼樣。若先畫再套樣式,使用者會看到沒有樣式的內容閃一下再變成正常版面,那比等一下更糟。所以它選擇等樣式解析完才繪製。

defer 和 async 該用哪一個?

有依賴關係或需要操作 DOM 的用 defer,它保證按文件順序執行且在文件解析完成後才跑。完全獨立的第三方程式碼用 async,它下載完就立刻執行、順序不保證。不確定就用 defer

關鍵樣式內嵌值得做嗎?

首屏內容多、外部樣式檔又大的網站值得。但它的維護成本不低:內嵌的樣式會隨版面改動而過期,而且要靠工具自動抽取。樣式檔本身不大的話,先做壓縮與移除未使用的樣式效益更高。

同步載入的程式碼為什麼特別嚴重?

它會讓 HTML 解析完全停下來,等程式碼下載並執行完才繼續。放在檔頭的話會擋住整份文件的解析,後面的內容連 DOM 都還沒建立,畫面自然是空白的。

第三方程式碼要怎麼處理?

分三步:先確認真的需要它、加上 async 避免阻塞、再評估能不能延到互動後才載入。客服元件與分享按鈕特別適合等使用者點了才載入。

延伸閱讀

參考資料:web.dev:Render blocking resources