Render Blocking 渲染阻塞怎麼消除:CSS 和 JS 完整修復指南
render blocking 渲染阻塞消除完整指南:inline critical CSS 決策樹、JS defer 與 async 差異、@import 鏈串聯阻塞診斷修復、第三方腳本處理,含 LCP 3.1s 降至 1.8s 實驗數據。
陳柏翰
網站效能工程師
Render Blocking 渲染阻塞怎麼消除:CSS 和 JS 完整修復指南
上個月我在一個 Next.js 專案跑 Lighthouse,分數 54,Opportunities 裡面第一條就是「Eliminate render-blocking resources」,估計節省 1.8 秒。我把 2 個 render-blocking CSS 和 3 個同步 JS 處理掉之後,Lighthouse 跑到 81,LCP 從 3.1 秒降到 1.8 秒,FCP 從 2.4 秒壓到 1.1 秒。這篇是我做完之後整理的完整筆記,包含 CSS 和 JS 的診斷到修復流程,以及幾個 SERP 上找不到的細節。
渲染阻塞是什麼,瀏覽器為什麼要阻塞首次繪製?
渲染阻塞資源是指放在 <head> 裡、讓瀏覽器在下載和解析完成之前無法輸出任何畫面的 CSS 或 JS 檔案。瀏覽器強制等待,是因為 CSS 決定每個 DOM 元素的樣式,如果先畫出沒有樣式的內容再套入樣式,就會出現 FOUC(Flash of Unstyled Content,無樣式內容閃爍)。這是瀏覽器刻意的設計,不是 bug。
背後的機制是這樣:瀏覽器在渲染之前需要建立 Render Tree,而 Render Tree 需要同時有 DOM Tree 和 CSSOM(CSS Object Model)。所有外部 CSS 在下載並解析完成前,CSSOM 無法完整,Render Tree 就無法建立,畫面就卡在空白。這就是為什麼就算你的 HTML 已經下載完了,瀏覽器還是顯示空白頁的原因。
對效能的影響是直接的:每一個 render-blocking 資源都在推遲 First Contentful Paint(FCP),而 FCP 又是 LCP 的前置條件。詳細的渲染阻塞機制與 關鍵渲染路徑的完整理論,可以看我之前寫的 CRP 完整指南。
哪些資源會阻塞渲染?完整分類清單
不是所有 CSS 和 JS 都會阻塞渲染,判斷標準是資源的位置和是否帶有特定屬性。以下是完整的阻塞與非阻塞資源分類清單。
| 資源類型 | 條件 | 阻塞行為 |
|---|---|---|
| 外部 CSS | <link rel="stylesheet">,無 media 限制 |
完全阻塞渲染(render-blocking) |
| 外部 CSS | <link rel="stylesheet" media="print"> |
不阻塞渲染(只在列印時載入) |
| 外部 JS | <script src="...">,無 async/defer,在 head |
阻塞 HTML 解析(parser-blocking)= 延遲 FCP |
| 外部 JS | <script defer src="..."> |
不阻塞解析,DOM 完成後執行 |
| 外部 JS | <script async src="..."> |
不阻塞解析,下載完立刻執行 |
| 內嵌 JS | <script>...</script>,在 head |
阻塞解析,直到執行完 |
| 圖片 / 影片 | 任何位置 | 不阻塞渲染(但影響 LCP 和 bandwidth) |
| Web Font | WOFF2/WOFF via CSS @font-face | 不阻塞渲染,但可能造成 FOIT(字型閃爍) |
值得注意的一點:「阻塞渲染」和「阻塞解析器(parser-blocking)」是兩件不同的事。CSS 是 render-blocking,JS(無 async/defer)是 parser-blocking。Parser-blocking 的 JS 如果放在 <body> 最後,對 FCP 影響很小;但放在 <head> 裡,實際上也會延遲首次繪製。
用 Chrome DevTools 和 Lighthouse 診斷渲染阻塞
要修之前先要找到問題。Lighthouse 會直接告訴你哪些資源在阻塞,但 DevTools 的 Network 和 Coverage 面板能給你更多細節。
Lighthouse 診斷步驟:
- 打開 Chrome,按 F12 開啟 DevTools,切到「Lighthouse」頁籤
- 選「Performance」,Device 選「Mobile」(行動裝置條件更嚴苛,問題更明顯)
- 點「Analyze page load」,等跑完
- 在「Opportunities」區塊找「Eliminate render-blocking resources」,展開看哪些檔案在阻塞以及估計節省時間
DevTools Coverage 面板找 critical vs non-critical CSS:
- 按 Ctrl+Shift+P(Mac: Cmd+Shift+P)打開 Command Menu
- 搜尋並執行「Show Coverage」
- 點 Coverage 面板左上角的錄製按鈕,重新載入頁面
- 在列表找到你的 CSS 檔案,點進去
- 紅色條 = 這行 CSS 在初始載入時未使用(non-critical);灰色/綠色 = 首次繪製需要(critical)
Network 面板看 waterfall:在 Network 面板按 Ctrl+Shift+R 硬重新載入,看請求瀑布圖。Render-blocking 的 CSS 和 JS 會在 waterfall 的早期出現長條,藍色垂直線(DOMContentLoaded)之前都是阻塞期。如果某個 CSS 很早就開始下載但需要等很久,旁邊又沒有任何內容渲染,那就是你的瓶頸。
CSS 渲染阻塞消除:inline critical CSS 還是 defer?
CSS 渲染阻塞最正確的處理方式取決於這段 CSS 什麼時候被需要,不是一律 inline 或一律 defer。根據用途不同,有四條處理路徑。
我用的決策框架分四個路徑:
| 情境 | 處理方式 | 原因 |
|---|---|---|
| 首屏(above-fold)所需 CSS | inline 到 <style> in <head> |
消除阻塞,同時確保首次繪製樣式正確 |
| 捲動後才用到的 CSS | <link rel="preload"> + onload swap |
不阻塞首次繪製,但預先載入備用 |
| 特定 media 條件下才用的 CSS(列印、高解析度) | <link media="print"> 或 media="(min-width:768px)"> |
瀏覽器不會阻塞不符合條件的 media stylesheet |
| 完全沒用到的 CSS | 移除 | Coverage 工具確認後直接刪 |
Inline critical CSS 的實作:
<!-- 首屏 CSS 直接 inline -->
<style>
/* 只放 above-fold 需要的樣式,通常 5-15KB 以內 */
body { font-family: system-ui; margin: 0; }
header { background: #1a1a2e; color: #fff; padding: 1rem; }
.hero { min-height: 60vh; display: flex; align-items: center; }
</style>
<!-- 其餘 CSS 用 preload 非阻塞載入 -->
<link rel="preload" href="/styles/main.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles/main.css"></noscript>
onload swap 的邏輯是:先用 rel="preload" 讓瀏覽器並行下載 CSS,但不阻塞渲染;下載完成後 onload 觸發,把 rel 改成 stylesheet,樣式才真的套用。<noscript> 是給停用 JS 的使用者的 fallback,不能省略。
Media query 分割的實作:
<!-- 這個只在列印時載入,不阻塞螢幕渲染 -->
<link rel="stylesheet" href="/styles/print.css" media="print">
<!-- 這個只在寬螢幕載入 -->
<link rel="stylesheet" href="/styles/wide.css" media="(min-width:1024px)">
要抽 critical CSS,手動做很痛苦。工具推薦:critical 這個 npm 套件可以自動幫你跑 headless Chrome、擷取首屏 CSS 並 inline。大型專案用 CI 跑更方便。
JavaScript 渲染阻塞消除:async、defer 和動態載入
JS 的阻塞主要來自沒有加 async 或 defer 屬性的 <script> 標籤。這三個屬性的差異要搞清楚,用錯了會出問題。
| 屬性 | 下載時機 | 執行時機 | 執行順序 | 適合用途 |
|---|---|---|---|---|
| (無屬性) | 遇到時立刻 | 下載完立刻 | 阻塞 HTML 解析 | 少用,只用於必須立刻執行的 inline 腳本 |
defer |
並行下載 | DOM 完成後,DOMContentLoaded 前 | 保持原始順序 | 大多數外部 JS 庫,依賴 DOM 的腳本 |
async |
並行下載 | 下載完立刻(隨機順序) | 不保證順序 | 獨立的第三方腳本(analytics、廣告) |
我看過最常見的錯誤:把有相依關係的 JS 加了 async,結果執行順序不固定,A 依賴 B 但 B 還沒跑完,console 報錯。有相依關係的腳本用 defer,獨立的用 async。
<!-- 原本阻塞的寫法 -->
<script src="/js/vendor.js"></script>
<script src="/js/app.js"></script>
<!-- 修正後:defer 保持順序,不阻塞 -->
<script src="/js/vendor.js" defer></script>
<script src="/js/app.js" defer></script>
對於非常龐大的 JS bundle,還可以用動態 import 做 code splitting,只在需要時載入特定模組:
// 只有使用者點擊特定按鈕時才載入這個模組
button.addEventListener('click', async () => {
const { init } = await import('./heavy-module.js');
init();
});
這對 JavaScript 效能優化中的 Long Task 問題很有幫助,把不必要的初始化推遲到真正需要的時候。
第三方腳本的阻塞問題:最常被忽略的瓶頸
第三方腳本是最常被忽略的 render-blocking 來源,因為它們通常不是你的代碼,而且來自外部 domain,下載時間無法預測。
常見第三方腳本的阻塞風險分類:
| 腳本類型 | 預設阻塞風險 | 建議處理 |
|---|---|---|
| Google Analytics 4 (gtag.js) | 低(官方版已 async) | 確認加載方式包含 async |
| Google Tag Manager | 中(snippet 包含同步 dataLayer init) | 移至 <body> 末尾;或用 defer |
| Hotjar / Clarity | 高(預設同步載入) | 改為 defer,或只在 DOMContentLoaded 後載入 |
| Facebook Pixel | 低(已 async) | 確認使用官方 snippet 版本 |
| Live chat 小工具(Intercom、Zendesk) | 高(SDK 通常同步) | 用 facade pattern,延遲至使用者互動時載入 |
| A/B testing 腳本(Optimize 已停用,VWO 等) | 極高(設計上就是要在首次渲染前執行) | 評估是否真的需要;或用 server-side 方案 |
Facade pattern 是處理 chat widget 的好方法:先顯示一個靜態的假按鈕圖片,使用者點擊時才動態載入真正的 SDK。這讓頁面首次渲染完全不需要等 chat SDK,但使用者互動時功能一樣可用。
CSS @import 鏈:最容易被遺漏的阻塞來源
CSS @import 是一個很容易被忽略的阻塞問題,而且比一般的 render-blocking CSS 更糟糕,因為它會產生串聯阻塞鏈(blocking chain)。
問題在這裡:瀏覽器先下載並解析 main.css,然後才發現裡面有 @import 指向 fonts.css;然後再去下載 fonts.css,發現裡面又有 @import 指向 variables.css。這是串行的,每一個 @import 都要等前一個下載完才能開始。
/* main.css: 瀏覽器先下載這個 */
@import url('/css/fonts.css'); /* 等 main.css 解析後才開始下載 */
@import url('/css/variables.css'); /* 又等 fonts.css 解析後才開始 */
body { ... }
如果每個 CSS 下載需要 200ms,這個鏈就讓渲染延遲了至少 600ms。改成 <link> 標籤就可以並行下載:
<!-- 這樣三個 CSS 並行下載,只需要等最慢的那個 -->
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/fonts.css">
<link rel="stylesheet" href="/css/variables.css">
診斷方式:在 DevTools Network 面板,按下載開始時間排序,如果你看到三個 CSS 請求的開始時間是階梯狀(一個比一個晚),就是 @import 鏈。如果是平行的(幾乎同時開始),就沒問題。
特別危險的情況:Google Fonts 也可以用 @import 引入。如果你的 CSS 裡有 @import url("https://fonts.googleapis.com/..."),這不只是一個 @import,還需要跨 domain 建立新連線(DNS + TCP + TLS),延遲更長。改用 <link rel="preconnect"> 搭配直接的 <link rel="stylesheet"> 才正確。
消除後 LCP 和 FCP 能改善多少?
效果取決於阻塞資源的數量和大小,但多數案例 FCP 和 LCP 都有明顯改善。這是我實際測試過的一個 Next.js 專案(行動裝置 Lighthouse,Fast 3G throttle):
| 指標 | 修改前 | 修改後 | 改善幅度 |
|---|---|---|---|
| LCP | 3.1s | 1.8s | -42% |
| FCP | 2.4s | 1.1s | -54% |
| TBT(Total Blocking Time) | 680ms | 420ms | -38% |
| Lighthouse Performance Score | 54 | 81 | +27 分 |
這個案例做了三件事:(1)把 2 個 render-blocking CSS 檔案改成 inline critical CSS + preload defer;(2)把 3 個同步 JS 加上 defer;(3)把 Hotjar 改成 DOMContentLoaded 後才載入。不是什麼黑魔法,每個步驟都有對應的工具和代碼模式。
要注意的是:Lighthouse 跑出來的改善是 lab data,在模擬環境。真實 CrUX field data 的改善通常要 28 天的 rolling window 才反映出來。這兩個數據的差距為什麼存在,我在 LCP 優化的四段時間模型裡有解釋。
常見錯誤:消除阻塞後 FOUC 出現怎麼處理?
FOUC(Flash of Unstyled Content,無樣式內容閃爍)是移除 render-blocking CSS 後最常見的副作用。原因很直接:你把某些 CSS defer 掉了,但那段 CSS 其實是首屏內容需要的,結果使用者看到一瞬間沒有樣式的頁面。
預防方法有兩個:
- Critical CSS 要抽得夠完整。用 Coverage 工具看,不只看「是否使用過」,要用 Slow 3G throttle 重新載入,確認所有首屏出現的元素樣式都有被標記為 critical。很多開發者在快速網路下測試,看不到 FOUC,但在慢速網路上就出現了。
- 用 JavaScript 偵測 CSS 載入完成。如果你用
rel="preload"+onloadswap 方式 defer CSS,在 CSS 套用之前,頁面可能短暫呈現 inline critical CSS 的樣式。這通常問題不大,但如果你的 critical CSS 不夠完整,就會出現版面跳動。解法是確保 critical CSS 包含所有上方可見區域的版面定義(高度、寬度、字型大小、背景色)。
測試方式:在 DevTools Network 面板,把網路速度調到「Slow 3G」,清除快取後重新載入。如果你看到頁面先以無樣式(或樣式殘缺)狀態顯示,再突然套入完整樣式,FOUC 就存在。
Render Blocking 診斷到修復的決策流程
把上面所有步驟整合成一個可重複執行的診斷流程,從 Lighthouse 警告到 Slow 3G 驗證,每次修改新專案或幫客戶做效能優化時按這個走,不會漏掉任何阻塞來源。
- 跑 Lighthouse,找「Eliminate render-blocking resources」,記下每個阻塞資源和估計節省時間
- 開 Network 面板,Slow 3G 重新載入,看 waterfall,確認阻塞資源是哪些,下載時間多長
- 分類:CSS 還是 JS?
- CSS → 開 Coverage 面板,判斷 critical vs non-critical,按上述決策矩陣處理
- JS → 確認是自己的還是第三方,加
defer或async,或用動態 import
- 特別檢查 CSS @import:在 Network 面板看 CSS 的「Initiator」欄,如果是由另一個 CSS 觸發(不是 HTML),就是 @import 鏈,改成並行的
<link>標籤 - 確認第三方腳本:在 Network 面板按 Domain 篩選,看有沒有來自外部 domain 的 JS 或 CSS 出現在阻塞期
- 修改後 Slow 3G 重跑,觀察有沒有 FOUC,如果有就補充 critical CSS
- 再跑一次 Lighthouse,驗證 FCP 和 LCP 改善幅度,並用 CrUX 或 WebPageTest 跑 field data 確認
LCP 圖片的優先載入和 render blocking 修復是互補的,解決 render blocking 讓瀏覽器更快到達 LCP 圖片的下載時間點,然後再搭配 LCP 圖片的 fetchpriority 優化提升 LCP 圖片本身的載入順序,兩個一起做效果最明顯。
更多關於 TTFB 和 server 回應速度的影響,可以參考 TTFB 優化筆記,TTFB 慢通常是 render blocking 改完後下一個要解決的問題。
延伸閱讀:
- web.dev: 優化資源載入:CSS 渲染阻塞與 preload scanner 的官方說明
- Chrome Lighthouse 官方文件:Eliminate render-blocking resources 審計項目說明
- DebugBear: Render-Blocking Resources:包含 @import 鏈和 request chain 的深入分析