網頁圖片載入優化方案

餓了么 App 中新零售項目主要是以圖片展示為主,引導用戶點擊輪播廣告欄或者店鋪列表進入指定的商品頁面,因此頁面中包含了大量圖片,如搜索框下面的輪播廣告欄、中部的促銷欄以及底部的店鋪列表,這些區域中都有大量的展示圖片。因此圖片的載入速率直接影響頁面的載入速度。下面將從圖片載入存在的問題和原因、解決方案兩個方面來闡述如何優化新零售圖片的載入。

本文所有數據及圖片都是通過 Charles 模擬 256 kbps ISDN/DSL 網路環境獲取到的。在本案例中只考慮點陣圖,因此文本中提及的圖片都是指點陣圖而非矢量圖。

圖片載入存在的問題和原因

問題一:啟動頁面時載入過多圖片

圖1: 新零售圖片請求瀑布圖

問題原因分析:如上圖所示,頁面啟動時載入了大約 49 張圖片(具體圖片數量會根據後端返回數據而變化),而這些圖片請求幾乎是並發的,在 Chrome 瀏覽器,對於同一個域名,最多支持 6 個請求的並發,其他的請求將會推入到隊列中等待或者停滯不前,直到六個請求之一完成後,隊列中新的請求才會發出。上面的瀑布圖中,在綠色的標記框中,我們看到不同長度的白色橫柱,這些都是請求的圖片資源排隊等待時間。

問題二:部分圖片體積過大

圖2. 頂部輪播圖中的一張圖片載入圖

問題原因分析:如圖 1,紅框中是搜索框下部的輪播廣告中的一張圖片,通過圖 2 可以看到,該圖片主要耗時在 Conent Download 階段。在下載階段耗時 13.50s。而該請求的總共時間也就 13.78s。產生該問題的原因從圖 1 也能看出一些端倪,該圖片體積 76.2KB圖片體積過大,直接導致了下載圖片時間過長。

前端解決方案

針對問題一的解決方案

由於新零售首頁展示展示大量圖片,其實在這大約 49 張圖片中,大部分圖片都不是首屏所需的,因此可以延遲首屏不需要的圖片載入,而優先載入首屏所需圖片。這兒首屏的含義是指打開新零售首頁首先進入屏幕視窗內的區域範圍。

判斷圖片是否是首屏內圖片,首先想到的肯定是通過 getBoundingClientRect 方法,獲取到圖片的位置信息,判斷其是否在 viewport 內部。可能的代碼如下:

const inViewport = (el) => {n const rect = el.getBoundingClientRect()nn return rect.top > 0n && rect.bottom < window.innerHeightn && rect.left > 0n && rect.right < window.innerWidthn}n

但是在項目中,我們並沒有採用該方案來判斷是否在首屏,其原因在於,只有當 DOM 元素插入到 DOM 樹中,並且頁面進行重排和重繪後,我們才能夠知道該元素是否在首屏中。在項目中我們使用了 v-img 指令(新零售項目使用該指令對圖片進行載入、並且將 hash 轉換成 Url。項目已開源,在符合需求前提下歡迎使用),在 Vue 指令中包含兩個鉤子函數 bindinserted。官網對這兩個鉤子函數進行如下解釋:

  • bind:只調用一次,指令第一次綁定到元素時調用。在這裡可以進行一次性的初始化設置。
  • inserted:被綁定元素插入父節點時調用 (僅保證父節點存在,但不一定已被插入文檔中)。

由上面解釋可知,我們只能夠在 inserted 鉤子函數中才能夠獲取到元素的位置,並且判斷其是否在首屏中。在新零售項目中,經過筆者測試,這兩個鉤子函數的觸發時差大約是200ms,因此如果在 inserted 鉤子函數內再去載入圖片就會比在 bind 鉤子函數中載入晚大約200ms,在 4G 網路環境下,200ms 對於很多圖片來說已經足夠用來載入了,因此我們最終放棄了在 inserted 鉤子函數中載入首屏圖片的方案。

如果元素沒有插入到 DOM 樹中並渲染,怎麼能夠判斷其是否在首屏中呢?

<img v-img="{ hash: xxx, defer: true }">

項目中使用了一種比較笨的方式來判斷哪些是首屏圖片,新零售頁面布局是確定的,輪播廣告欄下面是促銷欄、再下面是店鋪列表,這些組件的高度也都相對固定,因此這些組件是否在首屏中其實我們是事先知道的。因此在實際使用 v-img 指令的時候,通過傳 defer 配置項來告訴 v-img 哪些圖片需要提前載入,哪些圖片等待提前載入的圖片載入完畢後再載入。這樣我們就能夠在 bind 鉤子函數中載入優先載入的圖片了。比如說,輪播組件圖片、促銷組件圖片、前兩個店鋪中的展示圖片需要先載入,除此以外的其他圖片,需等待首屏圖片完全載入後再進行請求載入。實際實現代碼如下:

const promises = [] // 用來存儲優先載入的圖片 nVue.directive(img, {n bind(el, binding, vnode) {n // ...n const { defer } = binding.valuen // ...n if (!defer) {n promises.push(update(el, binding, vnode))n }n },n inserted(el, binding, vnode) {n const { defer } = binding.valuen if (!defer) returnn if (inViewport(el)) {n promises.push(update(el, binding, vnode))n } else {n Vue.nextTick(() => {n Promise.all(promises)n .then(() => {n promises.length = 0n update(el, binding, vnode)n })n .catch(() => {})n })n }n },n // ...n })n

首先通過聲明一個數組 promises 用於存儲優先載入的圖片,在 bind 鉤子函數內部,如果 defer 配置項為 false,說明不延時載入,那麼就在 bind 鉤子函數內部載入該圖片,且將返回的 promise 推入到 promises 數組中。在 inserted 鉤子函數內,對於延遲載入的圖片(defer 為 true),但是其又在首屏內,那麼也有優先載入權,在 inseted 鉤子函數調用時就對其載入。而對於非首屏且延遲載入的圖片等待 promises 數組內部所有的圖片都載入完成後才載入。當然在實際代碼中還會考慮容錯機制,比如上面某張圖片載入失敗、或者載入時間太長等。因此我們可以配置一個最大等待時間。

優化後的圖片載入瀑布圖如下:

圖3. 圖片按需載入的瀑布圖

如上圖所示,下面紅框的圖片不是首屏圖片,因此進行了延遲載入。可以看出,其是在上面所有圖片(包括上面的紅框中耗時最長的那張圖)載入完成之後進行載入的。這樣減少了首屏載入時的網路消耗,提升了圖片下載速度。

優化前後對比

通過上面的優化方案,在預設的網路環境下(參見文末注),分別對優化前和優化後進行了 5 次平行清空緩存載入,平均數據如下:

通過上面表格可以看出,DOMContentLoadedLoaded 並沒有多大參考價值,首屏的完整展現所需要的時間依然由載入最慢(一般都是體積最大那張圖片)的圖片決定,也就是上表的 Max_size_image 決定,上表可以看出,優化後比優化前最大體積圖片的載入時間縮短了 5.74s。提速了整整 41.41%。載入最慢的圖片載入速度的變化也很好的反應了首屏時間的變化。

當然上面的數據也不能夠完全反應線上場景,畢竟測試的時間點及後端數據都有所不同。我們也不能夠在同一時間點、同一網路環境下對優化前、優化後進行同時數據採集。

針對問題一還有些後續的解決方案:

  • 在 HTTP/1.0 和 HTTP/1.1 協議下,由於 Chrome 只支持同域同時發送 6 個並發請求,可以進行域名切分,來提升並發的請求數量,或者使用 HTTP/2 協議。

針對問題二的解決方案

圖片體積過大,導致下載時間過長。在保證清晰度的前提下盡量使用體積較小的圖片。而一張圖片的體積由兩個因素決定,該圖片總的像素數目和編碼單位像素所需的位元組數。因此一張圖片的文件大小就等於圖片總像素數目乘以編碼單位像素所需位元組數,也就是如下等式:

FileSize = Total Number Pixels * Bytes of Encode single Pixels

舉個例子:

一張 100px * 100px 像素的圖片,其包含該 100 * 100 = 10000 個像素點,而每個像素點通過 RGBA 顏色值進行存儲,RGBA 每個色道都有 0~255 個取值,也就是 2^8 = 256。正好是 8 位 1byte。而每個像素點有四個色道,每個像素點需要 4bytes。因此該圖片體積為:10000 * 4bytes = 40000bytes = 39KB

有了上面的背景知識後,我們就知道怎麼去優化一張圖片了,無非就兩個方向:

  • 一方面是減少單位像素所需的位元組數
  • 另一方面是減少一張圖片總的像素個數

單位像素優化:單位像素的優化也有兩個方向,一個方向是「有損」的刪除一些像素數據,另一個方面是做一些「無損」的圖片像素壓縮。正如上面例子所說,RGBA 顏色值可以表示 256^4 種顏色,這是一個很大的數字,往往我們不需要這麼多顏色值,因此我們是否可以減少色板中的顏色種類呢?這樣表示單位像素的位元組數就減少了。而「無損」壓縮是通過一些演算法,存儲像素數據不變的前提下,盡量減少圖片存儲體積。比如一張圖片中的某一個像素點和其周圍的像素點很接近,比如一張藍天的圖片,因此我們可以存儲兩個像素點顏色值的差值(當然實際演算法中可能不止考慮兩個像素點也許更多),這樣既保證了像素數據的「無損」,同時也減少了存儲體積。不過也增加了圖片解壓縮的開銷。

針對單位像素的優化,衍生出了不同的圖片格式,jpegpnggifwebp。不同的圖片格式都有自己的減少單位像素體積的演算法。同時也有各自的優勢和劣勢,比如 jpegpng 不支持動畫效果,jpeg 圖片體積小但是不支持透明度等。因此項目在選擇圖片格式上的策略就是,在滿足自己需求的前提下選擇體積最小的圖片格式,新零售項目中已經統一使用的 WebP 格式,和 jpeg 格式相比,其體積更減少 30%,同時還支持動畫和透明度。

圖片像素總數優化

圖4:圖片載入尺寸和實際渲染尺寸對比

上圖是新零售類目頁在 Chrome 瀏覽器中的 iPhone 6 模擬器載入後的輪播展示的圖片之一,展示的圖片是 750 * 188 像素,但是圖片的實際尺寸為 1440 * 360 像素,也就是說我們根本不需要這麼大的圖片,大圖片不僅造成了圖片載入的時長增加(後面會有數據說明),同時由於圖片尺寸需要縮小增加CPU的負擔。

上文中已經提及,項目中我們使用的 v-img 指令來載入項目中的所需圖片,如果我們能夠根據設備的尺寸來載入不同尺寸(像素總數不同)的圖片,也就是說在保證圖片清晰度的前提下,盡量使用體積小的圖片,問題就迎刃而解了。項目中我們使用的是七牛的圖片服務,七牛圖片服務提供了圖片格式轉換、按尺寸裁剪等圖片處理功能。只需要對 v-img 指令添加圖片寬、高的配置,那麼我們是不是可以對不同的設備載入不同尺寸的圖片呢?

項目中我們使用的 lib-flexible 來對不同的移動端設備進行適配,lib-flexible 庫在我們頁面的html元素添加了兩個屬性,data-dprstyle。這兒我們主要會用到 style 中的 font-size 值,在一定的設備範圍內其正好是html元素寬度的十分之一(具體原理參見:使用Flexible實現手淘H5頁面的終端適配),也就是說我們可以通過style屬性大概獲取到設備的寬度。同時設計稿又是以 iPhone6 為基礎進行設計的,也就是設計稿是寬度為 750px的設計圖,這樣在設計圖中的圖片大小我們也就能夠轉換成其他設備中所需的圖片大小了。

舉個例子:

設計稿中一張寬 200px 的圖片,其對應的 iPhone 6 設備的寬度為 750px。我們通過 html 元素的 style 屬性計算出 iPhone6 plus 的寬度為 1242px。這樣也就能夠計算中 iPhone6 plus 所需圖片尺寸。計算如下:

200 * 1242 / 750 = 331.2px

實現代碼如下:

const resize = (size) => {n let viewWidthn const dpr = window.devicePixelRation const html = document.documentElementn const dataDpr = html.getAttribute(data-dpr)n const ratio = dataDpr ? (dpr / dataDpr) : dprnn try {n viewWidth = +(html.getAttribute(style).match(/(d+)/) || [])[1]n } catch(e) {n const w = html.offsetWidthn if (w / dpr > 540) {n viewWidth = 540 * dpr / 10n } else {n viewWidth = w / 10n }n }nn viewWidth = viewWidth * rationn if (Number(viewWidth) >= 0 && typeof viewWidth === number) {n return (size * viewWidth) / 75 // 75 is the 1/10 iphone6 deivce width pixeln } else {n return sizen }n}n

上面 resize 方法用於將配置的寬、高值轉換為實際所需的圖片尺寸,也就是說,size 參數是 iphone 6 設計稿中的尺寸,resize 的返回值就是當前設備所需的尺寸,再把該尺寸配置到圖片伺服器的傳參中,這樣我們就能夠獲取到按設備裁剪後的圖片了。

優化前後效果對比,有了上面的基礎,我們在 Chrome 中的不同的移動端模擬器上進行了實驗,我們對新零售類目頁中的一張體積最大的廣告圖片在不同設備中的載入進行了數據統計(平行三次清空緩存載入),為什麼選擇體積最大的圖片,上文也已經說過,其決定了首屏展現所需的時間。

上表格中,除去最後一行是未優化的載入數據,從上到下,設備屏幕尺寸逐漸變大,載入的圖片尺寸也從 23.2kb增加到 65.5kb。而載入時間和下載時長也跟隨著圖片體積的加大而增加,下面的折線圖更能夠反應圖片尺寸、載入時長、下載時長之間的正相關關係。TTFB(從發送請求到接收到第一個位元組所需時長)卻和圖片大小沒有明顯的正相關關係,可能對於圖片伺服器在裁剪上述不同尺寸的圖片所需時長差異不大。

圖5:不同設備中對同一張圖片進行載入,文件大小、載入和下載時長的折線變化

由上折線圖我們還能看到,對於小屏幕設備的效果尤為明顯,在不優化下,iPhone5 中圖片的載入需要 14.85s,而優化後,載入時長縮短到了 3.90s。載入時長整整縮短了 73.73%。而對於大屏幕的 iPhone6 plus 也有 26.00% 時長優化。

當然上面的數據是建立在 256 kbps ISDN/DSL 的網路環境下的,該低速網路環境下,圖片的載入時間主要是由於下載時間決定的,因此通過優化圖片體積能夠達到很好的效果。在 4G(Charles模擬)環境下,iPhone5 中的優化效果就會有些折扣,載入時長縮短 69.15%。其實也很容易想到,在高速的網路環境下,TTFB 對載入時長的影響會比低速網路環境下影響要大一些。

最後總結

通過上面的研究及數據結果表明,新零售圖片載入緩慢的優化策略:

  • 首屏圖片優先載入,等首屏圖片載入完全後再去載入非首屏圖片。
  • 對大部分圖片,特別是輪播廣告中的圖片進行按設備尺寸裁剪,減少圖片體積,減少網路開銷,加快下載速率。

本文中沒有過多的討論代碼實現細節,而是把重點放在了圖片載入緩慢的原因分析,以及優化前後效果對比的數據分析上,如果想看更多代碼細節,請移步 vue-img。


推薦閱讀:

Vuejs 中使用 markdown
一般網站的前端和後端工作量怎麼算?
數據可視化的web前端開發採用什麼樣的架構比較合適?

TAG:前端性能优化 | 加载图片 | 前端开发 |