愛康體檢寶 PC(www.tijianbao.com/) 算是一個“老”專案,為什麼說“老”呢,因為在前端技術日新月異,每天都有新知識、新概念,甚至新框架的今天,它還是基於vue-cli 2.x、webpack 3.x構建,顯然有些老了。其次,在早期開始這個專案的時候,由於倉促上線,也沒有過多的考慮效能及載入問題,目前網站上使用的圖片未經過裁切,所有的庫都打包到一個 vendor 裡,首屏載入時間太長,等等這些問題使網站的使用者體驗不是太好,基於各方面的原因,決定對它進行一次優化,主要從以下幾個方面進行:
- 使用 https 及 升級成 http/2 協議
- 合理控制快取
- 合理使用圖片
- 升級至 webpack4
- 優化首屏載入
- 優化前後的資料對比
下面分別展開來說
使用 https 及 升級成 http/2 協議
https 主要帶來安全性方面的提升,而且 http/2 依賴於 https,只有使用 https 協議的站點可以升級 http/2 協議。
http/2 帶來了一系列的改動和優化,主要如下:
- 每個伺服器只用一個連線。HTTP/2 對每個伺服器只使用一個連線,而不是每個檔案一個連線。這樣,就省掉了多次建立連線的時間,這個時間對 TLS 尤其明顯,因為 TLS 連線費時間。
- 加速 TLS 交付。HTTP/2 只需一次耗時的 TLS 握手,並且通過一個連線上的多路利用實現最佳效能。HTTP/2 還會壓縮首部資料,省掉 HTTP/1.x 時代所需的一些優化工作,比如拼接檔案,從而提高快取利用率。
- 簡化 Web 應用。使用 HTTP/2 可以讓 Web 開發者省很多事,因為不用再做那些針對 HTTP/1.x 的優化工作了。
- 適合內容混雜的頁面。HTTP/2 特別適合混合了 HTML、CSS、JavaScript、圖片和有限多媒體的傳統頁面。瀏覽器可以優先安排那些重要的檔案請求,讓頁面的關鍵部分先出現,快出現。
- 更安全。通過減少 TLS 的效能損失,可以讓更多應用使用 TLS,從而讓使用者資訊更安全。
這裡有一篇來自 google 的 HTTP/2 簡介 更為全面和權威。
配置主要是在編譯 nginx 時加上 with-http_ssl_module 模組和 with-http_v2_module模組
./configure --with-http_v2_module --with-http_ssl_module
複製程式碼
配置伺服器 conf 檔案
server {
listen 443 ssl http2 default_server;
ssl_certificate server.crt;
ssl_certificate_key server.key;
...
}
複製程式碼
然後重啟伺服器,完成升級~
合理控制快取
快取對於 web 應用程式至關重要,合理控制快取可以有效提升 web 效能,我們之前有些域下未做明確的快取管理,雖然瀏覽器有預設的快取機制,但是由於預設的機制未必能滿足我們的要求,而且各瀏覽器的預設機制不同,可能造成 web 程式的表現也不同,所以很有必要對各資源的快取進行精細控制。
關於瀏覽器快取,我寫過 一篇文章 做了詳細介紹,這裡只說具體的實施細節:
- 開啟 gzip (伺服器優化的一總分,不屬於快取範疇)
- 開啟 etag
- 對 html 型別的檔案設定過期時間為 80s
- 對 api 請求設定無快取
- 對靜態資源設定一個較長時間的快取
具體 nginx 配置如下:
# 配置 gzip
gzip on;
gzip_min_length 0k;
gzip_comp_level 1;
gzip_types text/plain application/json application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
gzip_vary on;
gzip_disable "MSIE [1-6]\.";
# 開啟 etag
etag on;
# 不快取介面
location ~* \.(?:manifest|appcache|xml|json)$ {
add_header Cache-Control "no-cache";
}
# 設定 html 過期時間為 80s
location ~* \.(?:html)$ {
add_header Cache-Control "max-age=80";
}
# 給靜態資源設定一個長期快取
location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ {
expires 1M;
access_log off;
add_header Cache-Control "public";
}
# CSS and Javascript
location ~* \.(?:css|js)$ {
expires 1y;
access_log off;
add_header Cache-Control "public";
}
複製程式碼
因為 html 型別的頁面檔案可能實時發版,不能設定較長時間的快取,否則可能造成發版後不更新的現象。
靜態資源都設定成較長快取時間,由於發版後,如果靜態資源有更新,都會產生新的 hash 值,從而使老的資源過期。
api 請求由於要實現獲請最新內容,所以不設定瀏覽器快取,期望的結果是能通過 etag 優化傳輸,但在實踐中通過 nginx 設定 api 無效,具體過程還在探索中。
通過以上方式,對 web 程式的快取進行了較精細的控制。
合理使用圖片
網站本身使用的圖片有兩個來源,分別是 又拍雲 和 我們自己的 idc(通過阿里雲加速),在使用的過程中都沒有裁切,導致整體載入的圖片資源比較大,做了如下優化:
針對來自又拍雲的圖片資源
又拍雲提供了動態裁切的功能,可以特別方便的控制圖片資源。
例如對於某個圖片資源,我們需要的是一個寬為 200px 的圖片,以前我們是直接引用這個資源:domain.url/images/001.jpg ,這是一個原始資源,可能是一個很大的圖片,直接引用會造成浪費
通過自動裁切,我們可以引用指定寬度的圖片:domain.url/images/001.jpg!/fw/200 ,通過在 url 後加 !/fw/200,可以引用寬度為 200px 的圖片,最大限度上節省資源
對於支援 webp 的瀏覽器,還可以讓其輸出 webp 版本:domain.url/images/001.jpg!/fw/200/format/webp ,通過關鍵字 /format/webp 指定輸出的資源格式為 webp
總的來說,又拍雲提供方便靈活的資源控制方法,更多細節見官方文件:help.upyun.com/knowledge-b…
針對來自 idc 的圖片資源
來自 idc 的圖片相對較難處理,由於沒有云儲存提供的功能,idc 只是單純的做為檔案儲存伺服器,所以沒有辦法動態裁切。
不過天無絕人之路,可以通過 nginx 的一個模組來實現類似的功能,這個模組就是:ngx_http_image_filter_module,通過這個模組可以對指定的資源按條件進行裁切,當 CDN 回源的時候給他裁切好的圖片就可以了,部分實現了雲端儲存的功能,具體實施如下:
一、編譯 nginx 時加上 --with-http_image_filter_module
二、配置 nginx
location ~* /images/(.+)$ {
set $width -; #圖片寬度預設值
set $height -; #圖片高度預設值
if ($arg_width != "") {
set $width $arg_width;
}
if ($arg_height != "") {
set $height $arg_height;
}
#image_filter_jpeg_quality 85;
image_filter resize $width $height; #設定圖片寬高
image_filter_buffer 10M; #設定Nginx讀取圖片的最大buffer。
image_filter_interlace on; #是否開啟圖片影像隔行掃描
if ($arg_info = "yes") {
# image_filter size;
}
error_page 415 = 415.png; #圖片處理錯誤提示圖,例如縮放引數不是數字
}
複製程式碼
通過以上配置,當我們想訪問某個資源時可能通過:domain.url/images/002.jpg?width=200 得到寬為 200px 的圖片。
由於大規模部署,運維需要做更詳細的測試,所以當這次優化上線時,這個功能還沒有上線。當測試完成後,運維就會將這個功能部署到線上。
這個模組的官方文件是:nginx.org/en/docs/htt…
使用圖片懶載入 和 webp
使用圖片懶載入 主要依賴 Vue-Lazyload 這個 npm 模組,具體使用方法見:www.npmjs.com/package/vue…
這裡主要說一下其中的兩個功能 progressive 和 webp:
Vue.use(VueLazyload,{
observer: true,
attempt: 10,
filter: {
progressive (listener, options) {
const is_upyun_CDN = /upyunimages\./
if (is_upyun_CDN.test(listener.src)) {
listener.el.setAttribute('lazy-progressive', 'true')
listener.loading = listener.src.replace(/fw.+/, 'fw/10')
}
},
webp (listener, options) {
if (!options.supportWebp) return
const is_upyun_CDN = /upyunimages\./
if (is_upyun_CDN.test(listener.src)) {
listener.src += '/format/webp'
}
}
}
})
複製程式碼
通過 filter 整體對所有懶載入資源進行過濾控制:
progressive:允許在載入大圖見,先載入一個小圖,會有一個很好的使用者體驗
webp: 對支援 webp 的瀏覽器,載入資源的 webp 版本,有效降低檔案大小
通過這一系列操作,可以更進一種降低不必要的資源載入量。
升級至 webpack4
按說 webpack 3 用的好好的,為什麼要升級到 4 呢,原因還是因為新版本給我們帶來了諸多好片,而且目前已經是穩定版本,主要有以下內容:
- 更快的編譯速度,網上有說提高了 98%,我雖然沒有驗證資料,但直觀上快了,而且快了很多
- 零配置模組打包,雖然沒辦法做到“零” ,但是更多更合理的預設配置,使工程構建更加方便
- 拋棄了 CommonChunksPlugin,使用更為先進的 SplitChunksPlugin 提取公共資源
- 使用 Tree Shaking ,有效減少業務程式碼體積
- 引入 mode 屬性,可以定義為 development 和 production,不用再為生產和開發環境編寫過多的配置程式
- 等等其他未提及的 以及 預設的優化
升級過程中可能會遇到種種問題,好在有個 官方升級指南 可以幫我們覆蓋掉一部分,但這會指南過於簡明扼要,具體到專案中還會有很多坑,好在通過錯誤提示結合偉大的google,最終都能找到答案(如果你用 baidu ,很有可能最終爬不上來 :< ...),也可以結合網上其他一些升級方面的文章,都會很有幫助,我這裡就不細述了。
總在來說升級 wp 4 會花一些時間,但帶來的效率和效能提升絕對值得。
優化首屏載入
對於 web 單頁面應用而言,一個非常大的痛點就是在首次載入時載入的資源量過大,導致使用者在第一次訪問時出現在白屏時間較長,如何優化這個體驗是整體優化中的重中之重,所以放在最後來說。
前面已經說了,通過減少圖片大小、升級 webpack 4、優化公共資源包等等手段,都是為了這個服務(當然了,也不全是:>),這些手段都是對資源進行操作。當對這所有的資源進行了操作,如何合理處理這些資源,就到了瀏覽器的渲染機制,如何通過優化渲染過程,提高首屏渲染速度,是我們下一步要考慮的。
瀏覽器渲染頁面的過程,主要分為五步(略過請求部分,只討論請求到資源後瀏覽器如何處理):
- 處理 HTML 標記並構建 DOM 樹。
- 處理 CSS 標記並構建 CSSOM 樹。
- 將 DOM 與 CSSOM 合併成一個渲染樹。
- 根據渲染樹來佈局,以計算每個節點的幾何資訊。
- 將各個節點繪製到螢幕上。
從上面可知,瀏覽器只要載入到 html 結構和 css,就可以渲染出頁面。
針對上面得到的結構,有3種可選方案來實現:
- 服務端渲染
- 利用 prerender-spa-plugin 做預渲染
- HTML 內實現 Loading
先揭曉答案,最終我選擇了第 3 種,至於為什麼選擇,接下來挨個來看
服務端渲染
什麼是服務端渲染?來自 官網 的解釋是:將元件渲染為伺服器端的 HTML 字串,將它們直接傳送到瀏覽器,最後將這些靜態標記"啟用"為客戶端上完全可互動的應用程式。
伺服器端渲染(SSR)的優勢主要在於:
- 更好的 SEO,由於搜尋引擎爬蟲抓取工具可以直接檢視完全渲染的頁面。
- 更快的內容到達時間(time-to-content),特別是對於緩慢的網路情況或執行緩慢的裝置。無需等待所有的 JavaScript 都完成下載並執行,才顯示伺服器渲染的標記,所以你的使用者將會更快速地看到完整渲染的頁面。
我之前寫過一篇文章,詳細討論了 如何實現一個服務端渲染專案。
但是這樣一個看似美好的方案,同樣存在需要權衡的地方:
- 開發條件所限。瀏覽器特定的程式碼,只能在某些生命週期鉤子函式(lifecycle hook)中使用;一些外部擴充套件庫(external library)可能需要特殊處理,才能在伺服器渲染應用程式中執行。
- 涉及構建設定和部署的更多要求。與可以部署在任何靜態檔案伺服器上的完全靜態單頁面應用程式(SPA)不同,伺服器渲染應用程式,需要處於 Node.js server 執行環境。
- 更多的伺服器端負載。在 Node.js 中渲染完整的應用程式,顯然會比僅僅提供靜態檔案的 server 更加大量佔用 CPU 資源(CPU-intensive - CPU 密集),因此如果你預料在高流量環境(high traffic)下使用,請準備相應的伺服器負載,並明智地採用快取策略。
同時還有一個不得不考慮的問題是,由於服務端渲染模糊了前後端的界限,需要更多的伺服器方面的知識,在專案落地時要全面考慮運維、後期專案交接等等,最後放棄這個方案~
利用 prerender-spa-plugin 做預渲染
預渲染 可以達到和 SSR 類似的目的,它在編譯階段,將指定的頁面編譯成 html,當有請求時直接將 html 內容傳送給客戶端,但是他也存在問題:
- 它是在編譯階段完成的,只能編譯有限的頁面(例如 /, /about, /contact 等),沒有辦法將所有內容靜態化,而且如果內容有更新,之前編譯過的頁面也不會得到更新。
- 頁面抖動,例如我們首次訪問的是一個動態內容 /article/id-1234 ,由於這個動態內容之前沒有編譯成 html,這個請求會落在 /index.html 上,請先會先把頁面渲染成首頁內容,當動態介面有內容後,會再次更新頁面,將頁面渲染成目標頁,這會對使用者造成困惑。
但無論怎樣,這是一個不錯的方案,VUE 官方也推薦這個方案,在實踐的過程中我也遇到了一些問題,同時也做了一些 記錄 ,但是綜合考慮還是放棄了這個方案。
HTML 內實現 Loading
這是我們最終選擇的方案,這個方案從原理到實現都相對簡單,它藉助 html-webpack-plugin 將一段指定的 html 和 css 插入到模板中,在 js 和 api 請求未返回之前,以最快的速度給使用者一個 loading 提示,告知使用者得到了響應。
具體做法如下:
將 loading 效果的 html 拆分成 loading.html
和 loading.css
,分別放在 /src/preLoad/loading.html 和 /src/preLoad/loading.css
在 webpack 的 config 檔案裡讀取這兩個檔案:
module.exports = {
loading: {
html: fs.readFileSync(path.join(__dirname, '../src/preLoad/loading.html')),
css: '<style>' + fs.readFileSync(path.join(__dirname, '../src/preLoad/loading.css')) + '</style>'
}
// ...
}
複製程式碼
在 build 的配置檔案裡引入:
module.exports = {
plugins: [
new HtmlWebpackPlugin({
loading: config.loading
// ...
})
// ...
]
// ...
}
複製程式碼
在模板檔案中插入變數:
<!DOCTYPE html>
<html>
<head>
<!-- ... -->
<%= htmlWebpackPlugin.options.loading.css %>
</head>
<body>
<div id="app">
<%= htmlWebpackPlugin.options.loading.html %>
</div>
</body>
</html>
複製程式碼
通過這種方法,可以將一個動態的 loading 效果插入到頁面中。
由於 css 會阻塞渲染,所以當我們看到這個 loading 之前,儘量的少載入其他的 css 和 js,採用的方案是不提取 css,由於 vue-cli 預設的設定是提取,所以需要手動修改一下:
// vue-loader.conf.js
module.exports = {
loaders: utils.cssLoaders({
sourceMap: sourceMapEnabled,
extract: false // 不提取
}),
// ...
}
複製程式碼
這樣 css 就會被編譯進 js 裡,通過 js 進行輸出,在 loading 效果出來之前,不會阻塞頁面。
最後還有一個問題需要解決,由於程式依賴各種第三方包,這些包都會打包到 vendor.js 中,使這個檔案特別大,甚至超過了 1M,需要對這塊進行優化,思路就是告訴 webpack 要打包不,不要將某些包打到 vendor.js裡,然後我們手動在 html 裡引入這些檔案。
由於現在第三方 CDN 提供了穩定的資源訪問,而且藉助 http/2 的多種利用特性,使的這些第三方資源載入特別快,具體做法如下:
在 webpack 的 config 定義將要從第三方引入的資源:
在 webpack 的基礎配置檔案裡定義那些包不需要打包到 vendor.js裡:
// webpack.base.conf.js
module.exports = {
externals: {
'vue': 'Vue',
'vuex': 'Vuex',
'iview': 'iview'
}
//...
}
複製程式碼
在 webpack 的 config 檔案裡批明第三方資源:
module.exports = {
loading: {
html: fs.readFileSync(path.join(__dirname, '../src/preLoad/loading.html')),
css: '<style>' + fs.readFileSync(path.join(__dirname, '../src/preLoad/loading.css')) + '</style>'
},
css: [
'https://cdn.jsdelivr.net/npm/iview@2.14.3/dist/styles/iview.css'
],
js: [
'https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js',
'https://cdn.jsdelivr.net/npm/vuex@3.0.1/dist/vuex.min.js',
'https://cdn.jsdelivr.net/npm/iview@2.14.3/dist/iview.min.js'
]
// ...
}
複製程式碼
和 loading 類似,在 build 的配置檔案裡引入:
module.exports = {
plugins: [
new HtmlWebpackPlugin({
loading: config.loading,
externals_js: config.js, // 引入 js
externals_css: config.css, // 引入 css
// ...
})
// ...
]
// ...
}
複製程式碼
在模板檔案中插入變數:
<!DOCTYPE html>
<html>
<head>
<!-- ... -->
<%= htmlWebpackPlugin.options.loading.css %>
<% for (var i in htmlWebpackPlugin.options.externals_css) { %>
<link href="<%= htmlWebpackPlugin.options.externals_css[i] %>" rel="stylesheet">
<% } %>
</head>
<body>
<div id="app">
<%= htmlWebpackPlugin.options.loading.html %>
</div>
<% for (var i in htmlWebpackPlugin.options.externals_js) { %>
<script src="<%= htmlWebpackPlugin.options.externals_js[i] %>"></script>
<% } %>
</body>
</html>
複製程式碼
通過這種方法,將比較大的包從 vendor.js 裡剔除,通過第三方 CDN 引入。
優化前後的資料對比
通過這幾個方面的處理,來對比一下優化前後的資料:
專案 | 優化前 | 優化後 |
---|---|---|
總的資源載入量 | 6.7M | 939K |
總載入完成時間 | 19.05s | 5.11s |
首屏渲染時間 | 808ms | 391ms |
首次內容渲染 | 2.53s | 1.62s |
PageSpeed Insights 分數 | 13 分 | 83 分 |
由於瀏覽器訪問及測試受限於網路及服務的不穩定性,其結果是不精確的,但做為參考值可以看到他是有很大提升。