挺長的一篇文章,比較全面地介紹了 CSS 載入的相關知識,由於譯者水平有限,有能力的同學建議直接看原文,同時也希望譯文對你有所幫助,謝謝~以下是正文:
承蒙抬愛,我被稱為 CSS 魔術師已經十多年了,但最近在部落格上,CSS 相關的文章卻不多。那就結合 CSS 與效能這兩大主題,為大家帶來一篇文章吧。
CSS 是頁面渲染的關鍵因素之一,(當頁面存在外鏈 CSS 時,)瀏覽器會等待全部的 CSS 下載及解析完成後再渲染頁面。關鍵路徑上的任何延遲都會影響首屏時間,因而我們需要儘快地將 CSS 傳輸到使用者的裝置,否則,(在頁面渲染之前,)使用者只能看到一個空白的螢幕。
最大的問題是什麼?
廣義而言,CSS 是(渲染)效能的關鍵,這是由於:
- 瀏覽器直到渲染樹構建完成後才會渲染頁面;
- 渲染樹由 DOM 與 CSSOM 組合而成;
- DOM 是 HTML 加上(同步)阻塞的 JavaScript 操作(DOM 後的)結果;
- CSSOM 是 CSS 規則應用於 DOM 後的結果;
- 使 JavaScript 非阻塞非常簡單,新增 async 或 defer 屬性即可;
- 相對而言,要讓 CSS 變為非同步載入是比較困難的;
- 所以記住這條經驗法則:(理想情況下,)最慢樣式表的下載時間決定了頁面渲染的時間。
基於上述考慮,我們需要儘快構建 DOM 與 CSSOM。一般情況下,DOM 的構建是相對較快,(當請求某個頁面時,)伺服器響應的首個請求是 HTML 文件。但一般 CSS 是作為 HTML 的子資源而存在,因此 CSSOM 的構建通常需要更長的時間。
在這篇文章中,會講述 CSS 為何是網路瓶頸(無論是對於它自己或是其他資源),該如何突破它,從而縮短關鍵路徑以減少首次渲染前的等待時間。
使用關鍵 CSS
如果條件允許,縮短渲染前等待時間最有效的方式就是使用 Critical CSS (關鍵 CSS)模式:找出首次渲染所需的樣式(通常是首屏相關的樣式),將它們內聯到 標籤中,其他樣式則通過非同步的方式進行載入。
雖然這十分有效,但實施起來卻並不容易,比如:高度動態化的網站(譯者注:如 SPA)通常難以提取首屏相關的樣式、提取的過程需要自動化、需要對首屏不同元素顯示或隱藏的狀態作出假設、某些邊界情況難以處理以及相關工具仍未成熟等問題。如果你的專案相當龐大或是有歷史包袱,這將變得更為複雜。
根據媒體型別拆分程式碼
如果在專案組難以執行關鍵 CSS 策略,可以嘗試根據媒體查詢拆分 CSS 檔案,這也是一種可靠的策略。執行此策略後,瀏覽器表現如下:
- 以非常高的優先順序下載符合當前上下文(裝置、螢幕尺寸、解析度、方向等)的 CSS 檔案,阻塞關鍵路徑;
- 以非常低的優先順序下載不符合當前上下文的 CSS 檔案,不會阻塞關鍵路徑。
瀏覽器基本上能將未命中媒體查詢的 CSS 檔案延遲下載。
1 |
<link rel="stylesheet" href="all.css" /> |
如果我們把全部的 CSS 程式碼都放在一個檔案中,請求的表現如下:
我們可以觀察到,這個單獨的 CSS 檔案會以 最高 的優先順序下載。
根據媒體查詢拆分成若干個 CSS 檔案後:
1 2 3 4 5 6 |
<link rel="stylesheet" href="all.css" media="all" /> <link rel="stylesheet" href="small.css" media="(min-width: 20em)" /> <link rel="stylesheet" href="medium.css" media="(min-width: 64em)" /> <link rel="stylesheet" href="large.css" media="(min-width: 90em)" /> <link rel="stylesheet" href="extra-large.css" media="(min-width: 120em)" /> <link rel="stylesheet" href="print.css" media="print" /> |
瀏覽器會以不同的優先順序下載 CSS 檔案:
瀏覽器仍然會下載全部的 CSS 檔案,但只有符合當前上下文的 CSS 檔案會阻塞渲染。
避免在 CSS 檔案中使用 @import
為縮短渲染等待時間而努力的下一項任務非常簡單:避免在 CSS 檔案中使用 @import
如果瞭解 @import
的原理,那應該清楚它的效能並不高,使用它會阻塞渲染更長時間。這是因為我們在關鍵路徑上創造了更多(佇列式)的網路請求:
- 下載 HTML;
- 請求並下載依賴的 CSS;
- (下載及解析完成後,本該是構造渲染樹,然而;)
- CSS 依賴了其他的 CSS,繼續請求並下載 CSS 檔案;
- 構造渲染樹。
以下是相關的案例:
1 |
<link rel="stylesheet" href="all.css" /> |
all.css 的內容:
1 2 |
@import url(imported.css); |
最終,瀏覽器的請求瀑布圖呈現為:
關鍵路徑上的 CSS 檔案並沒有並行下載。
通過將 @imports
請求的檔案改為 <link rel="stylesheet" />
:
1 2 |
<link rel="stylesheet" href="all.css" /> <link rel="stylesheet" href="imported.css" /> |
可以提高網路效能:
關鍵路徑上的 CSS 檔案是並行下載的。
@import
的 CSS 檔案的修改許可權,為了讓瀏覽器並行下載 CSS 檔案,可以往 HTML 中補充相應的 <link rel="stylesheet" src="@import的地址" />
。瀏覽器會並行下載相應的 CSS 檔案且不會重複下載 @import
引用的檔案。在 HTML 中謹慎地使用 @import
為了透徹地理解本節的內容,首先我們需要了解瀏覽器的預載入掃描器:各大瀏覽器都實現了一個名為預載入掃描器的輔助解析器。瀏覽器的核心解析器主要用於構建 DOM、CSSOM、執行 JavaScript 等。HTML 文件中某些標籤與狀態會阻塞核心解析器,因而核心解析器的執行是斷斷續續的。而預載入掃描器可以跳到核心解析器尚未解析的部分,用以發現其他待引用的子資源(如 CSS、JS 檔案、圖片等)。一旦發現此類子資源,預載入掃描器會開始下載它們,以便核心解析器在解析到對應內容時就能使用它們(,而不是直到那一刻才開始下載該資源)。預載入掃描器的出現,使網頁的載入效能提高了19%,這是一項了不起的成就,可以極大地優化使用者體驗。
作為開發者,需要警惕預載入掃描器背後隱藏的問題,這在後文會進行闡述。
在 HTML 中使用 @import
,在以 WebKit 與 Blink 為核心的瀏覽器中,可能會觸發它們預載入掃描器的 bug,在 Firefox 與 IE/Edge 中,則表現低效。
Firefox 與 IE / Edge:在 HTML 中將 @import
放在 JS 和 CSS 之前
在 Firefox 與 IE/Edge 中,預載入掃描器不會並行下載 <script src="">
和 <link rel="stylesheet" />
後 @imports
引用的資源。
這意味著如下的 HTML:
1 2 3 4 5 |
<script src="app.js"></script> <style> @import url(app.css); </style> |
會出現這樣的請求瀑布圖:
由於預載入掃描器失效,導致資源在 Firefox 中無法並行下載(IE/Edge 中有著同樣的問題)。
通過上圖,可以清晰地觀察到:直到 JavaScript 檔案下載完成之後,@import
引用的 CSS 檔案才開始下載。
不單 <script>
標籤會觸發此問題,<link>
標籤也會:
1 2 3 4 5 |
<link rel="stylesheet" href="style.css" /> <style> @import url(app.css); </style> |
與 <script>
標籤一樣,子資源無法並行下載。
此問題最簡單的解決方案是調換 <script>
或 <link rel="stylesheet" />
標籤與(包含 @import
的)<style>
標籤的位置。然而,當我們改變順序時,可能會對頁面造成影響。
最佳解決方案是完全不使用 @import
,再往 HTML 文件中加入另一個 <link rel="stylesheet" />
取而代之:
1 2 |
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="app.css" /> |
修改後,瀏覽器表現更好:
瀏覽器並行下載資源,IE/Edge 表現相同。
以 Blink 或 WebKit 核心的瀏覽器:在 HTML 文件中使用 @import
時,要用引號包裹 url。
對於以 Blink 或 WebKit 為核心的瀏覽器而言,當 @import
引用的 url 未被引號包裹時,表現與 Firefox 和 IE/Edge 一致(無法並行下載)。這意味著上述兩個核心的預載入掃描器存在 bug。
因此,無需調整程式碼的順序,只需要新增引號即可解決問題。但我還是建議使用另一個 <link rel="stylesheet" />
取代 @import
。
未新增引號時的程式碼:
1 2 3 4 5 |
<link rel="stylesheet" href="style.css" /> <style> @import url(app.css); </style> |
瀑布圖:
可以看到,缺失引號會破壞 Chrome 的預載入(Opera 與 Safari 表現也是如此。)
新增引號後的程式碼:
1 2 3 4 5 |
<link rel="stylesheet" href="style.css" /> <style> @import url("app.css"); </style> |
新增引號後,Chrome、Opera 和 Safari 的預載入掃描器表現恢復正常,
這絕對是 WebKit 與 Blink 核心的一個 bug,是否新增引號不應成為影響預載入掃描器的因素。
感謝 Yoav 幫我追蹤這個問題。
現在這個 bug 現已在 Chromium 的待修復列表中。
不要將動態插入 JavaScript 的程式碼放在<link rel="stylesheet" />
之後
在上一節中,我們瞭解到某些引用 CSS 檔案路徑 的方法,會對其他資源的下載造成負面影響。在本節中,我們將探究為何稍有不慎,CSS 將延遲其他資源的下載。該問題主要出現在動態建立的<script>
標籤中:
1 2 3 4 5 |
<script> var script = document.createElement('script'); script.src = "analytics.js"; document.getElementsByTagName('head')[0].appendChild(script); </script> |
所有瀏覽器都存在一個鮮為人知,但符合邏輯的現象,它會對效能造成很大的影響:
在瀏覽器下載完該 CSS 檔案之前,不會執行下面的 JS
1 2 3 4 |
<link rel="stylesheet" href="slow-loading-stylesheet.css" /> <script> console.log("I will not run until slow-loading-stylesheet.css is downloaded."); </script> |
這是合理的。當 CSS 檔案尚未下載完成時,HTML 文件中任何同步的 JavaScript 程式碼,均不會執行。考慮以下場景: <script>
中的程式碼會訪問當前的頁面樣式,為確保結果正確,需要等待( <script>
標籤前)所有 CSS 檔案下載並解析完畢後再獲取,否則無法保證正確性。因此,在 CSSOM 構建完成之前,<script>
中的程式碼不會執行。
根據這現象,CSS 檔案的下載時間會對後續 <script>
的執行時間造成影響。下面的例子能較好地說明問題。
如果我們將一個 <link rel="stylesheet" />
放在 <script>
之前,<script>
中動態建立新 <script>
的程式碼只會在 CSS 檔案下載完之後才會執行,這意味著 CSS 推遲了資源的下載與執行:
1 2 3 4 5 6 7 |
<link rel="stylesheet" href="app.css" /> <script> var script = document.createElement('script'); script.src = "analytics.js"; document.getElementsByTagName('head')[0].appendChild(script); </script> |
從下面的瀑布圖可以看到,JavaScript 檔案在 CSSOM 構建完成之後才開始下載,完全失去了並行下載的優勢:
analytics.js
,但對 analytics.js
的引用並非一開始就存在於 HTML 的文件之中,它是由 <link>
後面 <script>
的程式碼動態建立的,在建立之前,它只是一些字串,而不是預載入掃描器可識別的資源,無形中它被隱藏起來了。為了更安全地載入指令碼,第三方服務商經常提供這樣的程式碼片段。然而,開發者通常不信任第三方的程式碼,因而會把該片段放在頁面的最後,但這可能會導致不良的後果。事實上,Google Analytics (在文件中)對此的建議是:
將程式碼複製後,作為第一項貼上到待追蹤頁面的 中。
綜上,我的建議是:
如果 <script>
中的程式碼並不依賴 CSS,把它們放在樣式表之前。
調整一下程式碼:
1 2 3 4 5 6 7 |
<script> var script = document.createElement('script'); script.src = "analytics.js"; document.getElementsByTagName('head')[0].appendChild(script); </script> <link rel="stylesheet" href="app.css" /> |
交換位置之後,子資源可以並行下載,頁面的整體效能提高了兩倍以上。(譯者注:本節的內容只同意一半,<head>
中的程式碼,確實是建議先放 <script>
,再放 <link>
,後文也會有相關的內容,但第三方程式碼放在 <head>
中的第一項,取決於相關程式碼的用途。如非必要,放在頁面末尾或空閒時下載及執行也未嘗不可)
將無需查詢 CSSOM 的 JavaScript 程式碼放在 CSS 檔案之前,需要查詢的放在 CSS 檔案之後
這條建議遠比你想象中的有用。
上文討論了插入新 <script>
的程式碼應放在 <link>
之前,那是否能推廣到其他的 CSS 與 JavaScript 呢?為了弄明白這個問題,先提出以下假設:
假設:
- CSSOM 的構建會阻塞 CSS 後面同步 JS 的執行;
- 同步的 JS 會阻塞 DOM 的構建…
那如果 JS 並不依賴 CSSOM,以下那種情況會更快?
- script 在前 style 在後;
- style 在前 script 在後?
答案是:
如果 JS 檔案沒有依賴 CSS,你應該將 JS 程式碼放在樣式表之前。 既然沒有依賴,那就沒有任何理由阻塞 JavaScript 程式碼的執行。
(儘管執行 JavaScript 程式碼時會停止解析 DOM, 但預載入掃描器會提前下載之後的 CSS)
如果你一部分 JavaScript 需要依賴 CSS 而另一部分卻不用,最佳的實踐是將 JavaScript 分為兩部分,分別置於 CSS 的兩側:
1 2 3 4 5 6 7 |
<!-- 這部分 JavaScript 程式碼下載完後會立即執行 --> <script src="i-need-to-block-dom-but-DONT-need-to-query-cssom.js"></script> <link rel="stylesheet" href="app.css" /> <!-- 這部分 JavaScript 程式碼在 CSSOM 構建完成後才會執行 --> <script src="i-need-to-block-dom-but-DO-need-to-query-cssom.js"></script> |
根據這種組織方式,我們的頁面會按最佳的方式下載與執行相關程式碼。下面的截圖中,粉色代表 JS 的執行,但它們都比較“纖細”了,希望你能看得清楚。(第一欄的(下同))第一行是整個頁面的時間軸,留意該行粉色的部分,代表 JS 正在執行。第二行是首個 JS 檔案的時間軸,可以看到下載完後並立即執行。第三行是 CSS 的時間軸,因而沒有任何 JS 執行。最後一行是第二個 JS 檔案的時間軸,可以清晰地看到,直到 CSS 下載完成後才執行。
<head>
中的程式碼組織基本可以按照這種方式,即 JS 在 CSS 之前,因為 <head>
中的 JS 程式碼基本不依賴 CSS,唯一的反例是 JS 程式碼體積非常大或執行時間很長。)將 <link rel="stylesheet" />
放在 <body>
中。
最後一條優化策略比較新穎,它對頁面效能有很大幫助,並使頁面達到逐步渲染的效果,同時易於執行。
在 HTTP/1.1 中,我們習慣於將全部的 css 打成一個檔案,如 app.css:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
<html> <head> <link rel="stylesheet" href="app.css" /> </head> <body> <header class="site-header"> <nav class="site-nav">...</nav> </header> <main class="content"> <section class="content-primary"> <h1>...</h1> <div class="date-picker">...</div> </section> <aside class="content-secondary"> <div class="ads">...</div> </aside> </main> <footer class="site-footer"> </footer> </body> |
然而,從三方面而言,渲染效能降低了:
- 每個頁面只用到 app.css 中的部分樣式: 使用者會下載多餘的 CSS。
- 難以制定快取策略: 例如,某個頁面使用的日期選擇器更改了背景顏色,重新生成 app.css 後,舊的 app.css 快取將失效。
- 整個 app.css 在解析構建完 CSSOM 之前,頁面渲染被阻塞: 儘管當前頁面可能只用到了 17% 的 CSS程式碼,但(瀏覽器)仍需等待其他 83% 的程式碼下載並解析完後,才能開始渲染。
使用 HTTP/2,可以解決第一與第二點:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
<html> <head> <link rel="stylesheet" href="core.css" /> <link rel="stylesheet" href="site-header.css" /> <link rel="stylesheet" href="site-nav.css" /> <link rel="stylesheet" href="content.css" /> <link rel="stylesheet" href="content-primary.css" /> <link rel="stylesheet" href="date-picker.css" /> <link rel="stylesheet" href="content-secondary.css" /> <link rel="stylesheet" href="ads.css" /> <link rel="stylesheet" href="site-footer.css" /> </head> <body> <header class="site-header"> <nav class="site-nav">...</nav> </header> <main class="content"> <section class="content-primary"> <h1>...</h1> <div class="date-picker">...</div> </section> <aside class="content-secondary"> <div class="ads">...</div> </aside> </main> <footer class="site-footer"> </footer> </body> |
根據頁面的不同元件下載不同的 CSS,能有效地解決冗餘問題。這減少了對關鍵路徑造成阻塞的 CSS 檔案總大小。
同時,我們可以制定更有效的快取策略,(當程式碼產生變化之後,)只會影響對應檔案的快取,其他的檔案保持不變。
但仍有解決的問題:下載並解析全部 CSS 檔案之前,頁面的渲染仍然是阻塞的。頁面的渲染時間仍然取決於最慢的 CSS 檔案下載與解析的時間。假設由於某種原因,頁尾的 CSS 下載需要很長時間,(即使頁頭的 CSSOM 已經構建完成,)瀏覽器也只能等待而無法渲染頁頭。
然而,這現象在 Chrome (v69)中得到緩解,Firefox 與 IE/Edge 也已經進行了相關的優化。<link rel="stylesheet" />
只會阻塞後續內容,而不是整個頁面的渲染。這意味著我們可以用以下方式組織程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
<html> <head> <link rel="stylesheet" href="core.css" /> </head> <body> <link rel="stylesheet" href="site-header.css" /> <header class="site-header"> <link rel="stylesheet" href="site-nav.css" /> <nav class="site-nav">...</nav> </header> <link rel="stylesheet" href="content.css" /> <main class="content"> <link rel="stylesheet" href="content-primary.css" /> <section class="content-primary"> <h1>...</h1> <link rel="stylesheet" href="date-picker.css" /> <div class="date-picker">...</div> </section> <link rel="stylesheet" href="content-secondary.css" /> <aside class="content-secondary"> <link rel="stylesheet" href="ads.css" /> <div class="ads">...</div> </aside> </main> <link rel="stylesheet" href="site-footer.css" /> <footer class="site-footer"> </footer> </body> |
這樣的結果是我們能逐步渲染頁面,當前面的 CSS 可用時,頁面將呈現對應的內容(,而不需等待全部 CSS 下載並解析完畢)。
I如果瀏覽器不支援這種特性,也不會損害頁面的效能。整個頁面將回退為原來的模式,只有在最慢的 CSS 下載並解析完成後,才能渲染頁面。
有關這種特性的更多細節,建議閱讀這篇文章。
總結
本文內容比較 繁雜,成文後超出了本來的預期,嘗試總結了 CSS 載入相關的一系列的最佳實踐,值得仔細體會:
- 懶載入非關鍵 CSS:
- 優先載入關鍵 CSS,懶載入其他 CSS;
- 或根據媒體型別拆分 CSS 檔案。
- 避免使用
@import
:- 在 HTML 文件中應該避免;
- 在 CSS 檔案之中更應避免;
- 以及警惕預載入掃描器的怪異行為。
- 關注 CSS 與 JavaScript 的順序:
- 在 CSS 檔案後的 JavaScript 僅在 CSSOM 構建完成後才會執行;
- 如果你的 JavaScript 不依賴 CSS;
- 將它放置於 CSS 之前;
- 如果 JavaScript 依賴 CSS:
- 將它放置於 CSS 之後。
- 僅載入 DOM 依賴的 CSS:
- 這將提高初次渲染的速度使讓頁面逐步渲染。
注意
本文敘述的內容都遵循規範或根據瀏覽器的行為推導得出,然而,你應該親自進行測試。儘管理論上是正確的,但在實踐中可能會有所不同。記得好好測試!