讓網頁展現的更快,官方說法叫做首屏繪製,First Paint 或者簡稱 FP,直白的說法叫做白屏時間,就是從輸入 URL 到真的看到內容(不必可互動,那個叫 TTI, Time to Interactive)之間經歷的時間。當然這個時間越短越好。
但這裡要注意,和首屏相關的除了 FP 還有兩個指標,分別稱為 FCP (First Contentful Paint,頁面有效內容的繪製) 和 FMP (First Meaningful Paint,頁面有意義的內容繪製)。雖然這幾個概念可能會讓我們繞暈,但我們只需要瞭解一點:首屏時間 FP 並不要求內容是真實的,有效的,有意義的,可互動的。換言之,隨便 給使用者看點啥都行。
這就是本文標題的玄機了:“看起來”。是的,只是看起來更快,實際上還是那樣。所以本文並不討論效能優化,討論的是一個投機取巧的小伎倆,但的確能夠實實在在的提升體驗。打個比方,效能優化是修煉內功,提升你本身的各項機能;而本文接下來要討論的是一些招式,能讓你在第一時間就唬住對手。
這所謂的招式就是我接下來要談的內容,學名骨架屏,也叫 Skeleton。你可能沒聽過這個名字,但你不可能沒見過它。
骨架屏長什麼樣
這種應該是最常見的形式,使用各種形狀的灰色矩形來模擬圖片和文字。有些 APP 也會使用圓形,但重點都是和實際內容結構近似,不能差距太大。
如果追求效果,還可以在色塊表面新增動畫(如波紋),顯示出一種動態的效果,算是致敬 Loading 了。
在圖片居多的站點,這將會是一個很好的體驗,因為圖片通常載入較慢。如上圖演示中的佔點陣圖片採用了低畫素的圖片,即大體配色和變化是和實際內容一致的。
如果無法生成這樣的低畫素圖片,稍微降級的方案是通過演算法獲取圖片的主體顏色,使用純色塊佔位。
再退一級,還可以使用全站相同的站點陣圖片,或者直接一個統一顏色的色塊。雖說效果肯定不如上面兩種,但也聊勝於無。
骨架屏完全是自定義的,想做成什麼樣全憑你的想象。你想做圓形的,三角形的,立體的都可以,但“佔位”決定了它的特性:它不能太複雜,必須第一時間,最快展現出來。
骨架屏有哪些優勢
大體來說,骨架屏的優勢在於:
- 在頁面載入初期預先渲染內容,提升感官上的體驗。
- 一般情況骨架屏和實際內容的結構是類似的,因此之後的切換不會過於突兀。這點和傳統的 Loading 動圖不同,可以認為是其升級版。
- 只需要簡單的 CSS 支援 (涉及圖片懶載入可能還需要 JS ),不要求 HTTPS 協議,沒有額外的學習和維護成本。
- 如果頁面採用元件化開發,每個元件可以根據自身狀態定義自身的骨架屏及其切換時機,同時維持了元件之間的獨立性。
骨架屏能用在哪裡
現在的 WEB 站點,大致有兩種渲染模式:
前端渲染
由於最近幾年 Angular/React/Vue 的相繼推出和流行,前端渲染開始佔據主導。這種模式的應用也叫單頁應用(SPA, Single Page Application)。
前端渲染的模式是伺服器(多為靜態伺服器)返回一個固定的 HTML。通常這個 HTML 包含一個空的容器節點,沒有其他內容。之後內部包含的 JS 包含路由管理,頁面渲染,頁面切換,繫結事件等等邏輯,所以稱之為前端渲染。
因為前端要管理的事情很多,所以 JS 通常很大很複雜,執行起來也要花較多的時間。在 JS 渲染出實際內容之前,骨架屏就是一個很好的替補隊員。
後端渲染
在這波前端渲染流行之前,早期的傳統網站採用的模式叫做後端渲染,即伺服器直接返回網站的 HTML 頁面,已經包含首頁的全部(或絕大部分) DOM 元素。其中包含的 JS 的作用大多是繫結事件,定義使用者互動後的行為等。少量會額外新增/修改一些 DOM,但無礙大局。
此外,前端渲染的模式存在 SEO 不友好的問題,因為它返回的 HTML 是一個空的容器。如果搜尋引擎沒有執行 JS 的能力(稱為 Deep Render),那它就不知道你的站點究竟是什麼內容,自然也就無法把站點排到搜尋結果中去。這對於絕大部分站點來說是不可接受的,於是前端框架又相繼推出了服務端渲染(簡稱 SSR, Server Side Rendering)模式。這個模式和傳統網站很接近,在於返回的 HTML 也是包含所有的 DOM,而非前端渲染。而前端 JS 除了繫結事件之外,還會多做一個事情叫做“啟用”(hydration),這裡就不再贅述了。
不論是傳統模式還是 SSR,只要是後端渲染,就不需要骨架屏。因為頁面的內容直接存在於 HTML,所以並沒有骨架屏出場的餘地。
骨架屏怎麼用
討論了一波背景,我們來看如何使用。首先先無視具體的實現細節,先看思路。
實現思路
大體分為幾個步驟:
- 往本應為空的容器節點內部注入骨架屏的 HTML。
骨架屏為了儘快展現,要求快速和簡單,所以骨架屏多數使用靜態的圖片。而且把圖片編譯成 base64 編碼格式可以節省網路請求,使得骨架屏更快展現,更加有效。
123456789101112131415161718<html><head><style>.skeleton-wrapper {// styles}</style><!-- 宣告 meta 或者引入其他 CSS --></head><body><div id="app"><div class="skeleton-wrapper"><img src="data:image/svg+xml;base64,XXXXXX"></div></div><!-- 引用 JS --></body></html>
- 在執行 JS 開始真正內容的渲染之前,清空骨架屏 HTML
以 Vue 為例,即在mount
之前清空內容即可。
123456let app = new Vue({...})let container = document.querySelector('#app')if (container) {container.innerHTML = ''}app.$mount(container)
僅此兩步,並不牽涉多麼複雜的機制和高階的 API,因此非常容易應用,趕快用起來!
示例
我編寫了一個示例,用於快速展現骨架屏的效果,程式碼在此。
index.html
預設包含了骨架屏,並且內聯了樣式(以<style>
標籤新增在頭部)。render.js
它負責建立 DOM 元素並新增到<body>
上,渲染頁面實際的內容,用來模擬常見的前端渲染模式。index.css
頁面實際內容的樣式表,不包含骨架屏的樣式。
程式碼的三個檔案各司其職,配合上面的實現思路,應該還是很好理解的。可以在 這裡 檢視效果。
因為這個示例的邏輯太過簡單,而實際的前端渲染框架複雜得多,包含的功能也不單純是渲染,還有狀態管理,路由管理,虛擬 DOM 等等,所以檔案大小和執行時間都更大更長。我們在檢視例子的時候,把網路調成 “Fast 3G” 或者 “Slow 3G” 能夠稍微真實一些。
但匪夷所思的是,對著這個地址重新整理試幾次,我也基本看不到骨架屏(骨架屏的內容是一個居中的藍色方形圖片,外加一條白色橫線反覆側滑的高亮動畫)。是我們的實現思路有問題嗎?
瀏覽器的奧祕:減少重排
為了排除肉眼的遺漏和干擾,我們用 Chrome Dev Tools 的 Performance 工具來記錄剛才發生了什麼,截圖如下:(截圖時的網路設定為 “Fast 3G”)
我們可以很明顯地看到 3 個時間點:
- HTML 載入完成了。瀏覽器在解析 HTML 的同時,發現了它需要引用的 2 個外部資源
index.js
和index.css
,於是傳送網路請求去獲取。 - 獲取成功後,執行 JS 並註冊 CSS 的規則。
- JS 一執行,很自然的渲染出了實際的內容,並應用了樣式規則(隨機顏色的橫條)。
我們的骨架屏呢?按照預想,骨架屏應該出現在 1 和 2 之間,也就是在獲取 JS 和 CSS 的同時,就應該渲染骨架屏了。這也是我們當時把骨架屏的 HTML 注入到 index.html
, 還把 CSS 從 index.css
中分離出來的良苦用心,然而瀏覽器並不買賬。
這其實和瀏覽器的渲染順序有關。
相信大家都整理過行李箱。我們在整理行李箱時,會根據每個行李的大小合理安排,大的和小的配合,填滿一層再放上面一層。現在突然有人跑來跟你說,你的電腦不用帶了,你要多帶兩件衣服,你不能帶那麼多瓶礦泉水。除了想打他之外,為了重新整理行李箱,必然需要把整理好的行李拿出來再重新放。在瀏覽器中這個過程叫做重排 (reflow),而那個餿主意就是新載入的 CSS。顯而易見,重排的開銷是很大的。
熟能生巧,箱子理多了,就能想出解決辦法。既然每個 CSS 檔案載入都可能觸發重繪,那我能不能等所有 CSS 載入完了一起渲染呢?正是基於這一點,瀏覽器會等 HTML 中所有的 CSS 都載入完,註冊完,一起應用樣式,力求一次排列完成工作,不要反覆重排。看起來瀏覽器的設計者經常出差,因為這是一個很正確的優化思路,但應用在骨架屏上就出了問題。
我們為了儘早展現骨架屏,把骨架屏的樣式從 index.css
分離出來。但瀏覽器不知道,它以為骨架屏的 HTML 還依賴 index.css
,所以必須等它載入完。而它載入完之後,render.js
也差不多載入完開始執行了,於是骨架屏的 HTML 又被替換了,自然就看不到了。而且在等待 JS, CSS 載入的時候依然是個白屏,骨架屏的效果大打折扣。
所以我們要做的是告訴瀏覽器,你放心大膽的先畫骨架屏,它和後面的 index.css
是無關的。那怎麼告訴它呢?
告訴瀏覽器先渲染骨架屏
我們在引用 CSS 時,會使用 <link rel="stylesheet" href="xxxx>
這樣的語法。但實際上,瀏覽器還提供了其他一些機制確保(後續)頁面的效能,我們稱之為 preload,中文叫預載入。具體來說,使用 <link rel="preload" href="xxxx">
,提前把後續要使用的資源先宣告一下。在瀏覽器空閒的時候會提前載入並放入快取。之後再使用就可以節省一個網路請求。
這看似無關的技術,在這裡將起到很大的作用,因為 預載入的資源是不會影響當前頁面的。
我們可以通過這種方式,告訴瀏覽器:先不要管 index.css
,直接畫骨架屏。之後 index.css
載入回來之後,再應用這個樣式。具體來說程式碼如下:
1 |
<link rel="preload" href="index.css" as="style" onload="this.rel='stylesheet'"> |
方法的核心是通過改變 rel
可以讓瀏覽器重新界定 <link>
標籤的角色,從預載入變成當頁樣式。(另外也有文章採用修改 media
的方法,但瀏覽器支援度較低,這裡不作展開了。我把文章列在最後了)這樣的話,瀏覽器在 CSS 尚未獲取完成時,會先渲染骨架屏(因為此時的 CSS 還是 preload
,也就是後續使用的,並不妨礙當前頁面)。而當 CSS 載入完成並修改了自己的 rel
之後,瀏覽器重新應用樣式,目的達成。
不得不考慮的注意點
事實上,並不是把 rel="stylesheet"
改成 rel="preload"
就完事兒了。在真正應用到生產環境之前,我們還有很多事情要考慮。
相容性考慮
首先,在 <link>
內部我們使用了 onload
,也就是使用了 JS。為了應對使用者的瀏覽器沒有開啟指令碼功能的情況,我們需要新增一個 fallback。(不過這點對於單頁應用來說可能也無所謂,因為如果沒有指令碼,那頁面實際內容也渲染不出來的)
1 |
<noscript><link rel="stylesheet" href="index.css"></noscript> |
其次,rel="preload"
並不是沒有相容性問題。對於不支援 preload 的瀏覽器,我們可以新增一些 polyfill 程式碼(來使所有瀏覽器獲得一致的效果。
1 2 3 4 |
<script> /*! loadCSS rel=preload polyfill. [c]2017 Filament Group, Inc. MIT License */ (function(){ ... }()); </script> |
polyfill 的壓縮程式碼可以參見 Lavas 的 SPA 模板第 29 行。
載入順序
不同於傳統頁面,我們的實際 DOM 是通過 render.js
生成的。所以如果 JS 先於 CSS 執行,那將會發生跳動。(因為先渲染了實際內容卻沒有樣式,而後樣式載入,頁面出現很明顯的變化)所以這裡我們需要嚴格控制 CSS 早於渲染。
1 |
<link rel="preload" href="index.css" as="style" onload="this.rel='stylesheet';window.STYLE_READY=true;window.mountApp && window.mountApp()"> |
JS 對外暴露一個 mountApp
方法用於渲染頁面(其實是模擬 Vue 的 mount
)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// render.js function mountApp() { // 方法內部就是把實際內容新增到 <body> 上面 } // 本來直接呼叫方法完成渲染 // mountApp() // 改成掛到 window 由 CSS 來呼叫 window.mountApp = mountApp() // 如果 JS 晚於 CSS 載入完成,那直接執行渲染。 if (window.STYLE_READY) { mountApp() } |
如果 CSS 更快載入完成,那麼通過設定 window.STYLE_READY
允許 JS 載入完成後直接執行;而如果 JS 更快,則先不自己執行,而是把機會留給 CSS 的 onload
。
清空 onload
loadCSS 的開發者提出,某些瀏覽器會在 rel
改變時重新出發 onload
,導致後面的邏輯走了兩次。為了消除這個影響,我們再在 onload
裡面新增一句 this.onload=null
。
最終的 CSS 引用方式
1 2 3 4 5 6 7 |
<link rel="preload" href="index.css" as="style" onload="this.onload=null;this.rel='stylesheet';window.STYLE_READY=true;window.mountApp && window.mountApp()"> <!-- 為了方便閱讀,折行重複一遍 --> <!-- this.onload=null --> <!-- this.rel='stylesheet' --> <!-- window.STYLE_READY=true --> <!-- window.mountApp && window.mountApp() --> |
修改後的效果
修改後的程式碼在 這裡,訪問地址在 這裡。(為了簡便,我省去了處理相容性的程式碼,即 <noscript>
和 preload polyfill)
Performance 截圖如下:(依然採用了 “Fast 3G” 的網路設定)
這次在 render.js
和 index.css
還在載入的時候頁面已經呈現出骨架屏的內容,實際肉眼也可以觀測到。在截圖的情況下,骨架屏的展現大約持續了 300ms,佔據整個網路請求的大約一半時間。
至於說為什麼不是 HTML 載入完成立馬展現骨架屏,而是還要等大約 300ms 才展現,從圖上看是瀏覽器 ParseHTML 所花費的時間,可能在 Dev Tools 開啟的情況下計算資源有限,不過可優化空間已經不大。(可能簡化骨架屏的結構能起一些作用吧)
多骨架屏的支援
一般來說一個站點的所有頁面不太可能是同一種展示型別。例如說首頁和內部頁面就展示風格而言會很有區別,另外例如列表頁和搜尋頁比較接近(可能都有列表展示),但和詳情頁(可能是商品,服務,個人資訊,部落格文章等等)就會很不相同。但單頁應用的 index.html
只有一個,所有的變化都源自前端渲染框架在容器節點內部進行改變。所以直接將骨架屏注入到 index.html
中會導致所有的頁面都用同一個骨架屏,那就很難達成“和實際內容結構類似”的目標了,骨架屏就退化為 Loading 了。
為了要支援多種骨架屏,我們需要在 index.html
裡面進行判斷邏輯(獨立於主體 JS 之外),具體來說:
- 把所有種類的骨架屏的 HTML 和樣式全部寫入
index.html
- 在
index.html
底下新增內聯的指令碼<script>
,根據當前路由判斷應該展示哪一個骨架屏
這樣會導致 index.html
體積變大一點,但整體感覺依然是收益大於付出,我認為是值得的。
後記
這個優化點最早由我的前同事 xiaop 同學 在開發 Lavas 的 SPA 模板中發現並完成的,Issue 記錄在此。我在他的基礎上,做了一個分離 Lavas 和 Vue 環境並且更直白的例子,讓截圖也儘可能易於理解,方便閱讀。在此非常感謝他的工作!
另外骨架屏的編寫我全部採用的是純粹的手寫 HTML 和 CSS,不止展現邏輯,包括開發流程也是獨立於單頁應用其他常規頁面的。當然這可能給開發者帶來一點不便,所以這時候需要推出 xiaop 同學的利器:vue-skeleton-webpack-plugin。它的作用是把骨架屏本身也當成一個 Vue 元件,配上單獨的路由規則來統一在 Vue 專案中的開發體驗,最後使用 webpack 在打包構建的時候加以區分並注入,對於使用 Vue + webpack 開發的同學來說可以一試。
參考文章
- 讓骨架屏更快渲染 – xiaop 同學原作
- Loading CSS without blocking render – 使用修改
media
的方式達成目的。 - filamentgroup/loadCSS – 同樣使用修改
rel
的方式,並提供了 preload polyfill