如何讓你的網頁「看起來」展示地更快 —— 骨架屏二三事

讓網頁展示的更快,官方說法叫作首屏繪製,First Paint 或者簡稱 FP,直白的說法叫作白屏時間,就是從輸入 URL 到真的看到內容(沒必要可交互,那個叫 TTI, Time to Interactive)之間經歷的時間。固然這個時間越短越好。javascript

但這裏要注意,和首屏相關的除了 FP 還有兩個指標,分別稱爲 FCP (First Contentful Paint,頁面有效內容的繪製) 和 FMP (First Meaningful Paint,頁面有意義的內容繪製)。雖然這幾個概念可能會讓咱們繞暈,但咱們只須要了解一點:首屏時間 FP 並不要求內容是真實的,有效的,有意義的,可交互的。換言之,隨便 給用戶看點啥都行。css

FP/FCP/FMP/TTI

這就是本文標題的玄機了:「看起來」。是的,只是看起來更快,實際上仍是那樣。因此本文並不討論性能優化,討論的是一個投機取巧的小伎倆,但的確可以實實在在的提高體驗。打個比方,性能優化是修煉內功,提高你自己的各項機能;而本文接下來要討論的是一些招式,能讓你在第一時間就唬住對手。html

這所謂的招式就是我接下來要談的內容,學名骨架屏,也叫 Skeleton。你可能沒聽過這個名字,但你不可能沒見過它。前端

骨架屏長什麼樣

這種應該是最多見的形式,使用各類形狀的灰色矩形來模擬圖片和文字。有些 APP 也會使用圓形,但重點都是和實際內容結構近似,不能差距太大。vue

若是追求效果,還能夠在色塊表面添加動畫(如波紋),顯示出一種動態的效果,算是致敬 Loading 了。java

在圖片居多的站點,這將會是一個很好的體驗,由於圖片一般加載較慢。如上圖演示中的佔位圖片採用了低像素的圖片,即大致配色和變化是和實際內容一致的。webpack

若是沒法生成這樣的低像素圖片,稍微降級的方案是經過算法獲取圖片的主體顏色,使用純色塊佔位。git

再退一級,還可使用全站相同的站位圖片,或者直接一個統一顏色的色塊。雖然說效果確定不如上面兩種,但也聊勝於無。github

骨架屏徹底是自定義的,想作成什麼樣全憑你的想象。你想作圓形的,三角形的,立體的均可以,但「佔位」決定了它的特性:它不能太複雜,必須第一時間,最快展示出來。web

骨架屏有哪些優點

大致來講,骨架屏的優點在於:

  1. 在頁面加載初期預先渲染內容,提高感官上的體驗。

  2. 通常狀況骨架屏和實際內容的結構是相似的,所以以後的切換不會過於突兀。這點和傳統的 Loading 動圖不一樣,能夠認爲是其升級版。

  3. 只須要簡單的 CSS 支持 (涉及圖片懶加載可能還須要 JS ),不要求 HTTPS 協議,沒有額外的學習和維護成本。

  4. 若是頁面採用組件化開發,每一個組件能夠根據自身狀態定義自身的骨架屏及其切換時機,同時維持了組件之間的獨立性。

骨架屏能用在哪裏

如今的 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,因此並無骨架屏出場的餘地。

骨架屏怎麼用

討論了一波背景,咱們來看如何使用。首先先無視具體的實現細節,先看思路。

實現思路

大致分爲幾個步驟:

  1. 往本應爲空的容器節點內部注入骨架屏的 HTML。

    骨架屏爲了儘快展示,要求快速和簡單,因此骨架屏多數使用靜態的圖片。並且把圖片編譯成 base64 編碼格式能夠節省網絡請求,使得骨架屏更快展示,更加有效。

    <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>
    複製代碼
  2. 在執行 JS 開始真正內容的渲染以前,清空骨架屏 HTML

    以 Vue 爲例,即在 mount 以前清空內容便可。

    let 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")

normal timeline

咱們能夠很明顯地看到 3 個時間點:

  1. HTML 加載完成了。瀏覽器在解析 HTML 的同時,發現了它須要引用的 2 個外部資源 index.jsindex.css,因而發送網絡請求去獲取。

  2. 獲取成功後,執行 JS 並註冊 CSS 的規則。

  3. 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 加載回來以後,再應用這個樣式。具體來講代碼以下:

<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。(不過這點對於單頁應用來講可能也無所謂,由於若是沒有腳本,那頁面實際內容也渲染不出來的)

<noscript><link rel="stylesheet" href="index.css"></noscript>
複製代碼

其次,rel="preload" 並非沒有兼容性問題。對於不支持 preload 的瀏覽器,咱們能夠添加一些 polyfill 代碼(來使全部瀏覽器得到一致的效果。

<script> /*! loadCSS rel=preload polyfill. [c]2017 Filament Group, Inc. MIT License */ (function(){ ... }()); </script>
複製代碼

polyfill 的壓縮代碼能夠參見 Lavas 的 SPA 模板第 29 行

加載順序

不一樣於傳統頁面,咱們的實際 DOM 是經過 render.js 生成的。因此若是 JS 先於 CSS 執行,那將會發生跳動。(由於先渲染了實際內容卻沒有樣式,然後樣式加載,頁面出現很明顯的變化)因此這裏咱們須要嚴格控制 CSS 早於渲染。

<link rel="preload" href="index.css" as="style" onload="this.rel='stylesheet';window.STYLE_READY=true;window.mountApp && window.mountApp()">
複製代碼

JS 對外暴露一個 mountApp 方法用於渲染頁面(實際上是模擬 Vue 的 mount

// 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 引用方式

<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" 的網絡設置)

preload timeline

此次在 render.jsindex.css 還在加載的時候頁面已經呈現出骨架屏的內容,實際肉眼也能夠觀測到。在截圖的狀況下,骨架屏的展示大約持續了 300ms,佔據整個網絡請求的大約一半時間。

至於說爲何不是 HTML 加載完成立馬展示骨架屏,而是還要等大約 300ms 才展示,從圖上看是瀏覽器 ParseHTML 所花費的時間,可能在 Dev Tools 打開的狀況下計算資源有限,不過可優化空間已經不大。(可能簡化骨架屏的結構能起一些做用吧)

多骨架屏的支持

通常來講一個站點的全部頁面不太多是同一種展現類型。例如說首頁和內部頁面就展現風格而言會頗有區別,另外例如列表頁和搜索頁比較接近(可能都有列表展現),但和詳情頁(多是商品,服務,我的信息,博客文章等等)就會很不相同。但單頁應用的 index.html 只有一個,全部的變化都源自前端渲染框架在容器節點內部進行改變。因此直接將骨架屏注入到 index.html 中會致使全部的頁面都用同一個骨架屏,那就很難達成「和實際內容結構相似」的目標了,骨架屏就退化爲 Loading 了。

爲了要支持多種骨架屏,咱們須要在 index.html 裏面進行判斷邏輯(獨立於主體 JS 以外),具體來講:

  1. 把全部種類的骨架屏的 HTML 和樣式所有寫入 index.html

  2. index.html 底下新增內聯的腳本 <script>,根據當前路由判斷應該展現哪個骨架屏

這樣會致使 index.html 體積變大一點,但總體感受依然是收益大於付出,我認爲是值得的。

後記

這個優化點最先由個人前同事 xiaop 同窗 在開發 Lavas 的 SPA 模板中發現並完成的,Issue 記錄在此。我在他的基礎上,作了一個分離 Lavas 和 Vue 環境而且更直白的例子,讓截圖也儘量易於理解,方便閱讀。在此很是感謝他的工做!

另外骨架屏的編寫我所有采用的是純粹的手寫 HTML 和 CSS,不止展示邏輯,包括開發流程也是獨立於單頁應用其餘常規頁面的。固然這可能給開發者帶來一點不便,因此這時候須要推出 xiaop 同窗的利器:vue-skeleton-webpack-plugin。它的做用是把骨架屏自己也當成一個 Vue 組件,配上單獨的路由規則來統一在 Vue 項目中的開發體驗,最後使用 webpack 在打包構建的時候加以區分並注入,對於使用 Vue + webpack 開發的同窗來講能夠一試。

參考文章

相關文章
相關標籤/搜索