前端效能優化--從 10 多秒到 1.05 秒

子木_lsy發表於2018-05-28

關於 效能優化 是個大的面,這篇文章主要涉及到 前端 的幾個點,如 前端效能優化 的流程、常見技術手段、工具等。

提及 前端效能優化 ,大家應該都會想到 雅虎軍規,本文會結合 雅虎軍規 融入自己的瞭解知識,進行的總結和梳理 ?

詳情,可以查閱我的 部落格 lishaoy.net

首先,我們先來看看 ? 雅虎軍規35 條。

  1. 儘量減少 HTTP 請求個數——須權衡
  2. 使用 CDN(內容分發網路)
  3. 為檔案頭指定 Expires 或 Cache-Control ,使內容具有快取性。
  4. 避免空的 src 和 href
  5. 使用 gzip 壓縮內容
  6. 把 CSS 放到頂部
  7. 把 JS 放到底部
  8. 避免使用 CSS 表示式
  9. 將 CSS 和 JS 放到外部檔案中
  10. 減少 DNS 查詢次數
  11. 精簡 CSS 和 JS
  12. 避免跳轉
  13. 剔除重複的 JS 和 CSS
  14. 配置 ETags
  15. 使 AJAX 可快取
  16. 儘早重新整理輸出緩衝
  17. 使用 GET 來完成 AJAX 請求
  18. 延遲載入
  19. 預載入
  20. 減少 DOM 元素個數
  21. 根據域名劃分頁面內容
  22. 儘量減少 iframe 的個數
  23. 避免 404
  24. 減少 Cookie 的大小
  25. 使用無 cookie 的域
  26. 減少 DOM 訪問
  27. 開發智慧事件處理程式
  28. 用 代替 @import
  29. 避免使用濾鏡
  30. 優化影象
  31. 優化 CSS Spirite
  32. 不要在 HTML 中縮放影象——須權衡
  33. favicon.ico要小而且可快取
  34. 保持單個內容小於25K
  35. 打包元件成複合文字

如對 雅虎軍規 的具體細則內容不是很瞭解,可自行去各搜尋 ? 引擎 ,搜尋 雅虎軍規 瞭解詳情。

壓縮 合併

對於 前端效能優化 自然要關注 首屏 開啟速度,而這個速度,很大因素是花費在網路請求上,那麼怎麼減少網路請求的時間呢?

  • 減少網路請求次數
  • 減小檔案體積
  • 使用 CDN 加速

所以 壓縮、合併 就是一個解決方案,當然可以用 gulpwebpackgrunt 等構建工具 壓縮、合併

JS、CSS 壓縮 合併

例如:gulp js、css 壓縮、合併程式碼如下 ?

//壓縮、合併js
gulp.task('scripts', function () {
    return gulp.src([
        './public/lib/fastclick/lib/fastclick.min.js',
        './public/lib/jquery_lazyload/jquery.lazyload.js',
        './public/lib/velocity/velocity.min.js',
        './public/lib/velocity/velocity.ui.min.js',
        './public/lib/fancybox/source/jquery.fancybox.pack.js',
        './public/js/src/utils.js',
        './public/js/src/motion.js',
        './public/js/src/scrollspy.js',
        './public/js/src/post-details.js',
        './public/js/src/bootstrap.js',
        './public/js/src/push.js',
        './public/live2dw/js/perTips.js',
        './public/live2dw/lib/L2Dwidget.min.js',
        './public/js/src/love.js',
        './public/js/src/busuanzi.pure.mini.js',
        './public/js/src/activate-power-mode.js'
    ]).pipe(concat('all.js')).pipe(minify()).pipe(gulp.dest('./public/dist/'));
});

// 壓縮、合併 CSS 
gulp.task('css', function () {
    return gulp.src([
        './public/lib/font-awesome/css/font-awesome.min.css',
        './public/lib/fancybox/source/jquery.fancybox.css',
        './public/css/main.css',
        './public/css/lib.css',
        './public/live2dw/css/perTips.css'
    ]).pipe(concat('all.css')).pipe(minify()).pipe(gulp.dest('./public/dist/'));
});
複製程式碼

然後,再把 壓縮、合併JS、CSS 放入 CDN , ? 看看效果如何

如圖:* 壓縮、合併 且放入 CND 之後的效果 *

首頁請求速度(js) 首頁請求速度(css)

以上是 lishaoy.net 清除快取後的 首頁 請求速度。

可見,請求時間是 4.59 s ,總請求個數 51 , 而 js 的請求個數是 8css 的請求個數是 3 (其實就 all.css 一個,其它 2 個是 Google瀏覽器載入的), 而沒使用 壓縮、合併 時候,請求時間是 10 多秒,總請求個數有 70 多個,js 的請求個數是 20 多個 ,對比請求時間 效能 提升 1倍

如圖:有快取下的首頁效果

首頁請求速度(快取)

基本都是秒開 ?

Tips:在 壓縮、合併 後,單個檔案控制在 25 ~ 30 KB左右,同一個域下,最好不要多於5個資源

圖片壓縮、合併

例如:gulp 圖片壓縮程式碼如下 ?

//壓縮image
gulp.task('imagemin', function () {
    gulp.src('./public/**/*.{png,jpg,gif,ico,jpeg}')
        .pipe(imagemin())
        .pipe(gulp.dest('./public'));
});
複製程式碼

圖片的合併可以採用 CSS Spirite,方法就是把一些小圖用 PS 合成一張圖,用 css 定位顯示每張圖片的位置

.top_right .phone {
	background: url(../images/top_right.png) no-repeat 7px -17px;
	padding: 0 38px;
}

.top_right .help {
	background: url(../images/top_right.png) no-repeat 0 -47px;
	padding: 0 38px;
}
複製程式碼

然後,把 壓縮 的圖片放入 CDN , ? 看看,效果如何

首頁請求速度(images)

可見,請求時間是 1.70 s ,總請求個數 50 , 而 img 的請求個數是 15 (這裡因為首頁都是大圖,就沒有合併,只是壓縮了) ,但是,效果很好 ? ,從 4.59 s 縮短到 1.70 s, 效能又提升一倍。

再看看有快取情況如何 ?

首頁請求速度(images 快取)

請求時間是 1.05 s ,有快取和無快取基本差不多

Tips:大的圖片在不同終端,應該使用不同解析度,而不應該使用縮放(百分比)

整個 壓縮、合併 (js、css、img) 再放入 CDN ,請求時間從 10 多秒 ,到最後的 1.70 s ,效能提升 5 倍多,可見,這個操作必要性。

快取

快取會根據請求儲存輸出內容的副本,例如 頁面、圖片、檔案,當下一個請求來到的時候:如果是相同的URL,快取直接使 用本地的副本響應訪問請求,而不是向源伺服器再次傳送請求。因此,可以從以下 2 個方面提升效能。

  • 減少相應延遲,提升響應時間
  • 減少網路頻寬消耗,節省流量

我們用兩幅圖來了解下瀏覽器的 快取機制

瀏覽器第一次請求

no-shadow
第一次請求

瀏覽器再次請求

no-shadow
再次請求

從以上兩幅圖中,可以清楚的瞭解瀏覽器 快取 的過程。 首次訪問一個 URL ,沒有 快取 ,但是,伺服器會響應一些 header 資訊,如:expires、cache-control、last-modified、etag 等,來記錄下次請求是否快取、如何快取。 再次訪問這個 URL 時候,瀏覽器會根據首次訪問返回的 header 資訊,來決策是否快取、如何快取。 我們重點來分析下第二幅圖,其實是分兩條線路,如下 ?

  • 第一條線路: 當瀏覽器再次訪問某個 URL 時,會先獲取資源的 header 資訊,判斷是否命中強快取 (cache-control和expires) ,如命中,直接從快取獲取資源,包括響應的 header 資訊 (請求不會和伺服器通訊) ,也就是 強快取 ,如圖
強快取
  • 第二條線路: 如沒有命中 強快取 ,瀏覽器會傳送請求到伺服器,請求會攜帶第一次請求返回的有關快取的 header 資訊 (Last-Modified/If-Modified-Since和Etag/If-None-Match) ,由伺服器根據請求中的相關 header 資訊來比對結果是否協商快取命中;若命中,則伺服器返回新的響應 header 資訊更新快取中的對應 header 資訊,但是並不返回資源內容,它會告知瀏覽器可以直接從快取獲取;否則返回最新的資源內容,也就是 協商快取

現在,我們瞭解到瀏覽器快取機制分為 強快取、協商快取,再來看看他們的區別 ?

快取策略 獲取資源形式 狀態碼 傳送請求到伺服器
強快取 從快取取 200(from memory cache) 否,直接從快取取
協商快取 從快取取 304(not modified) 是,通過伺服器來告知快取是否可用

強快取

與強快取相關的 header 欄位有兩個:

expires

expires: 這是 http1.0 時的規範,它的值為一個絕對時間的 GMT 格式的時間字串,如 Mon, 10 Jun 2015 21:31:12 GMT ,如果傳送請求的時間在 expires 之前,那麼本地快取始終有效,否則就會傳送請求到伺服器來獲取資源

cache-control

cache-control: max-age=number ,這是 http1.1 時出現的 header 資訊,主要是利用該欄位的 max-age 值來進行判斷,它是一個相對值;資源第一次的請求時間和 Cache-Control 設定的有效期,計算出一個資源過期時間,再拿這個過期時間跟當前的請求時間比較,如果請求時間在過期時間之前,就能命中快取,否則未命中, cache-control 除了該欄位外,還有下面幾個比較常用的設定值:

  • no-cache: 不使用本地快取。需要使用快取協商,先與伺服器確認返回的響應是否被更改,如果之前的響應中存在 ETag ,那麼請求的時候會與服務端驗證,如果資源未被更改,則可以避免重新下載。
  • no-store: 直接禁止遊覽器快取資料,每次使用者請求該資源,都會向伺服器傳送一個請求,每次都會下載完整的資源。
  • public: 可以被所有的使用者快取,包括終端使用者和 CDN 等中間代理伺服器。
  • private: 只能被終端使用者的瀏覽器快取,不允許 CDN 等中繼快取伺服器對其快取。

Tips:如果 cache-control 與 expires 同時存在的話,cache-control 的優先順序高於 expires

協商快取

協商快取都是由瀏覽器和伺服器協商,來確定是否快取,協商主要通過下面兩組 header 欄位,這兩組欄位都是成對出現的,即第一次請求的響應頭帶上某個欄位 Last-Modified 或者 Etag ,則後續請求會帶上對應的請求欄位 If-Modified-Since 或者 If-None-Match ,若響應頭沒有 Last-Modified 或者 Etag 欄位,則請求頭也不會有對應的欄位。

Last-Modified/If-Modified-Since

二者的值都是 GMT 格式的時間字串,具體過程:

  • 瀏覽器第一次跟伺服器請求一個資源,伺服器在返回這個資源的同時,在 responeheader 加上 Last-Modified 欄位,這個 header 欄位表示這個資源在伺服器上的最後修改時間

  • 瀏覽器再次跟伺服器請求這個資源時,在 requestheader 上加上 If-Modified-Since 欄位,這個 header 欄位的值就是上一次請求時返回的 Last-Modified 的值

  • 伺服器再次收到資源請求時,根據瀏覽器傳過來 If-Modified-Since 和資源在伺服器上的最後修改時間判斷資源是否有變化,如果沒有變化則返回 304 Not Modified ,但是不會返回資源內容;如果有變化,就正常返回資源內容。當伺服器返回 304 Not Modified 的響應時,response header 中不會再新增 Last-Modified的header ,因為既然資源沒有變化,那麼 Last-Modified 也就不會改變,這是伺服器返回 304 時的 response header

  • 瀏覽器收到 304 的響應後,就會從快取中載入資源

  • 如果協商快取沒有命中,瀏覽器直接從伺服器載入資源時,Last-ModifiedHeader 在重新載入的時候會被更新,下次請求時,If-Modified-Since 會啟用上次返回的Last-Modified

Etag/If-None-Match

這兩個值是由伺服器生成的每個資源的唯一標識字串,只要資源有變化就這個值就會改變;其判斷過程與 Last-Modified、If-Modified-Since 類似,與 Last-Modified 不一樣的是,當伺服器返回 304 Not Modified 的響應時,由於 ETag 重新生成過,response header 中還會把這個 ETag 返回,即使這個 ETag 跟之前的沒有變化。

Tips:Last-Modified與ETag是可以一起使用的,伺服器會優先驗證ETag,一致的情況下,才會繼續比對Last-Modified,最後才決定是否返回304。

Service Worker

什麼是 Service Worker

Service Worker 本質上充當Web應用程式與瀏覽器之間的代理伺服器,也可以在網路可用時作為瀏覽器和網路間的代理。它們旨在(除其他之外)使得能夠建立有效的離線體驗,攔截網路請求並基於網路是否可用以及更新的資源是否駐留在伺服器上來採取適當的動作。他們還允許訪問推送通知和後臺同步API。

Service worker 可以解決目前離線應用的問題,同時也可以做更多的事。 Service Worker 可以使你的應用先訪問本地快取資源,所以在離線狀態時,在沒有通過網路接收到更多的資料前,仍可以提供基本的功能(一般稱之為 Offline First)。這是原生APP 本來就支援的功能,這也是相比於 web app ,原生 app 更受青睞的主要原因。

再來看看 ? service worker 能做些什麼:

  • 後臺訊息傳遞
  • 網路代理,轉發請求,偽造響應
  • 離線快取
  • 訊息推送
  • … …

本文主要以(lishaoy.net)資源快取為例,闡述下 service worker如何工作

生命週期

service worker 初次安裝的生命週期,如圖 ?

no-shadow
sw生命週期

從上 ? 圖可知,service worker 工作的流程:

  1. 安裝: service worker URL 通過 serviceWorkerContainer.register() 來獲取和註冊。
  2. 啟用:service worker 安裝完成後,會接收到一個啟用事件(activate event)。 onactivate 主要用途是清理先前版本的 service worker 指令碼中使用的資源。
  3. 監聽: 兩種狀態
    • 終止以節省記憶體;
    • 監聽獲取 fetch 和訊息 message 事件。
  4. 銷燬: 是否銷燬由瀏覽器決定,如果一個 service worker 長期不使用或者機器記憶體有限,則可能會銷燬這個 worker

Tips:啟用成功之後,在 Chrome 瀏覽器裡,可以訪問 chrome://inspect/#service-workers和 chrome://serviceworker-internals/ 可以檢視到當前執行的service worker ,如圖 ?。

service worker

現在,我們來寫個簡單的例子 ?

註冊 service worker

要安裝 service worker ,你需要在你的頁面上註冊它。這個步驟告訴瀏覽器你的 service worker 指令碼在哪裡。

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js').then(function(registration) {
    // Registration was successful
    console.log('ServiceWorker registration successful with scope: ',    registration.scope);
  }).catch(function(err) {
    // registration failed :(
    console.log('ServiceWorker registration failed: ', err);
  });
}
複製程式碼

上面的程式碼檢查 service worker API 是否可用,如果可用,service worker /sw.js 被註冊。如果這個 service worker 已經被註冊過,瀏覽器會自動忽略上面的程式碼。

啟用 service worker

在你的 service worker 註冊之後,瀏覽器會嘗試為你的頁面或站點安裝並啟用它。 install 事件會在安裝完成之後觸發。install 事件一般是被用來填充你的瀏覽器的離線快取能力。你需要為 install 事件定義一個 callback ,並決定哪些檔案你想要快取.

// The files we want to cache
var CACHE_NAME = 'my-site-cache-v1';
var urlsToCache = [
  '/',
  '/css/main.css',
  '/js/main.js'
];

self.addEventListener('install', function(event) {
  // Perform install steps
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(function(cache) {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});
複製程式碼

在我們的 install callback 中,我們需要執行以下步驟:

  • 開啟一個快取
  • 快取我們的檔案
  • 決定是否所有的資源是否要被快取

上面的程式碼中,我們通過 caches.open 開啟我們指定的 cache 檔名,然後我們呼叫 cache.addAll 並傳入我們的檔案陣列。這是通過一連串 promise (caches.open 和 cache.addAll) 完成的。event.waitUntil 拿到一個 promise 並使用它來獲得安裝耗費的時間以及是否安裝成功。

監聽 service worker

現在我們已經將你的站點資源快取了,你需要告訴 service worker 讓它用這些快取內容來做點什麼。有了 fetch 事件,這是很容易做到的。

每次任何被 service worker 控制的資源被請求到時,都會觸發 fetch 事件,我們可以給 service worker 新增一個 fetch 的事件監聽器,接著呼叫 event 上的 respondWith() 方法來劫持我們的 HTTP 響應,然後你用可以用自己的方法來更新他們。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request);
  );
});
複製程式碼

caches.match(event.request) 允許我們對網路請求的資源和 cache 裡可獲取的資源進行匹配,檢視是否快取中有相應的資源。這個匹配通過 urlvary header 進行,就像正常的 HTTP 請求一樣。

那麼,我們如何返回 request 呢,下面 ? 就是一個例子 ?

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request)
      .then(function(response) {
        // Cache hit - return response
        if (response) {
          return response;
        }

        return fetch(event.request);
      }
    )
  );
});
複製程式碼

上面的程式碼裡我們定義了 fetch 事件,在 event.respondWith 裡,我們傳入了一個由 caches.match 產生的 promise.caches.match 查詢 request 中被 service worker 快取命中的 response 。 如果我們有一個命中的 response ,我們返回被快取的值,否則我們返回一個實時從網路請求 fetch 的結果。

sw-toolbox

當然,我也可以使用第三方庫,例如:lishaoy.net 使用了 sw-toolbox

sw-toolbox 使用非常簡單,下面 ? 就是 lishaoy.net 的一個例子 ?

  "serviceWorker" in navigator ? navigator.serviceWorker.register('/sw.js').then(function () {
    navigator.serviceWorker.controller ? console.log("Assets cached by the controlling service worker.") : console.log("Please reload this page to allow the service worker to handle network operations.")
  }).catch(function (e) {
    console.log("ERROR: " + e)
  }) : console.log("Service workers are not supported in the current browser.")
複製程式碼

以上是 註冊 一個 service woker

"use strict";
(function () {
    var cacheVersion = "20180527";
    var staticImageCacheName = "image" + cacheVersion;
    var staticAssetsCacheName = "assets" + cacheVersion;
    var contentCacheName = "content" + cacheVersion;
    var vendorCacheName = "vendor" + cacheVersion;
    var maxEntries = 100;
    self.importScripts("/lib/sw-toolbox/sw-toolbox.js");
    self.toolbox.options.debug = false;
    self.toolbox.options.networkTimeoutSeconds = 3;

    self.toolbox.router.get("/images/(.*)", self.toolbox.cacheFirst, {
        cache: {
            name: staticImageCacheName,
            maxEntries: maxEntries
        }
    });

    self.toolbox.router.get('/js/(.*)', self.toolbox.cacheFirst, {
        cache: {
            name: staticAssetsCacheName,
            maxEntries: maxEntries
        }
    });
    self.toolbox.router.get('/css/(.*)', self.toolbox.cacheFirst, {
        cache: {
            name: staticAssetsCacheName,
            maxEntries: maxEntries
        }
    
    ......

    self.addEventListener("install", function (event) {
        return event.waitUntil(self.skipWaiting())
    });
    self.addEventListener("activate", function (event) {
        return event.waitUntil(self.clients.claim())
    })
})();
複製程式碼

就這樣搞定了 ? (具體的用法可以去 sw-toolbox 檢視)

有的同學就問,service worker 這麼好用,這個快取空間到底是多大?其實,在 Chrome 可以看到,如圖

fstorage quota

可以看到,大概有 30G ,我的站點只用了 183MB ,完全夠用了 ?

最後,來兩張圖

from ServiceWorker Cache Storage

由於,文章篇幅過長,後續還會繼續總結 架構 方面的優化,例如

  • bigpipe分塊輸出
  • bigrender分塊渲染
  • ...

以及,渲染 方面的優化,例如

  • requestAnimationFrame
  • well-change
  • 硬體加速 GPU
  • ...

以及,效能測試工具,例如

  • PageSpeed
  • audits
  • ...

相關文章