如何讓瀏覽器更快地載入網路資源?

讓瀏覽器更快載入網路資源的速度

想要加快瀏覽器載入網路資源的速度,可以通過減少響應內容大小,比如使用 gzip 演算法壓縮響應體內容和 HTTP/2 的壓縮頭部功能;另一種更通用也更為重要的技術就是使用快取

下面兩張截圖分別是未使用快取以及使用瀏覽器預設快取的請求檔案所消耗的時間,可以看出使用快取之後載入時間大大縮短。

   

從服務端請求檔案所消耗的時間

   

從快取中獲取檔案所消耗的時間

Web 快取按儲存位置來區分,包括資料庫快取、服務端快取、CDN 快取和瀏覽器快取

本文我們著重介紹瀏覽器快取。

瀏覽器快取的實現方式主要有兩種:HTTP 和 ServiceWorker 。

HTTP 快取

使用快取最大的問題往往不在於將資源快取在什麼位置或者如何讀寫資源,而在於如何保證快取與實際資源一致的同時,提高快取的命中率。也就是說盡可能地讓瀏覽器從快取中獲取資源,但同時又要保證被使用的快取與服務端最新的資源保持一致。

為了達到這個目的,需要制定合適的快取過期策略(簡稱“快取策略”)

HTTP 支援的快取策略有兩種:強制快取和協商快取。

強制快取

強制快取是在瀏覽器載入資源的時候,先直接從快取中查詢請求結果,如果不存在該快取結果,則直接向服務端發起請求。

1. Expires

HTTP/1.0 中可以使用響應頭部欄位 Expires 來設定快取時間,它對應一個未來的時間戳。客戶端第一次請求時,服務端會在響應頭部新增 Expires 欄位。當瀏覽器再次傳送請求時,先會對比當前時間和 Expires 對應的時間,如果當前時間早於 Expires 時間,那麼直接使用快取;反之,需要再次傳送請求。

響應頭部中的 Expires 資訊

上述 Expires 資訊告訴瀏覽器:在 2020.10.10 日之前,可以直接使用該請求的快取。但是使用 Expires 響應頭時容易產生一個問題,那就是服務端和瀏覽器的時間很可能不同,因此這個快取過期時間容易出現偏差。同樣的,客戶端也可以通過修改系統時間來繼續使用快取或提前讓快取失效。

為了解決這個問題,HTTP/1.1 提出了 Cache-Control 響應頭部欄位。

2. Cache-Control

它的常用值有下面幾個:

  • no-cache,表示使用協商快取,即每次使用快取前必須向服務端確認快取資源是否更新;
  • no-store,禁止瀏覽器以及所有中間快取儲存響應內容;
  • public,公有快取,表示可以被代理伺服器快取,可以被多個使用者共享;
  • private,私有快取,不能被代理伺服器快取,不可以被多個使用者共享;
  • max-age,以秒為單位的數值,表示快取的有效時間;
  • must-revalidate,當快取過期時,需要去服務端校驗快取的有效性。

這幾個值可以組合使用,比如像下面這樣:

cache-control: public, max-age=31536000 //告訴瀏覽器該快取為公有快取,有效期 1 年。

需要注意的是,cache-control 的 max-age 優先順序高於 Expires,也就是說如果它們同時出現,瀏覽器會使用 max-age 的值。

注意,雖然你可能在其他資料中看到可以使用 meta 標籤來設定快取,比如像下面的形式:

<meta http-equiv="expires" content="Wed, 20 Jun 2021 22:33:00 GMT"

但在 HTML5 規範中,並不支援這種方式,所以儘量不要使用 meta 標籤來設定快取

協商快取

協商快取的更新策略是不再指定快取的有效時間了,而是瀏覽器直接傳送請求到服務端進行確認快取是否更新,如果請求響應返回的 HTTP 狀態為 304,則表示快取仍然有效。控制快取的難題就是從瀏覽器端轉移到了服務端。

1. Last-Modified 和 If-Modified-Since

服務端要判斷快取有沒有過期,只能將雙方的資源進行對比。若瀏覽器直接把資原始檔傳送給服務端進行比對的話,網路開銷太大,而且也會失去快取的意義,所以顯然是不可取的。有一種簡單的判斷方法,那就是通過響應頭部欄位 Last-Modified 和請求頭部欄位 If-Modified-Since 比對雙方資源的修改時間。

具體工作流程如下:

  • 瀏覽器第一次請求資源,服務端在返回資源的響應頭中加入 Last-Modified 欄位,該欄位表示這個資源在服務端上的最近修改時間;
  • 當瀏覽器再次向服務端請求該資源時,請求頭部帶上之前服務端返回的修改時間,這個請求頭叫 If-Modified-Since;
  • 服務端再次收到請求,根據請求頭 If-Modified-Since 的值,判斷相關資源是否有變化,如果沒有,則返回 304 Not Modified,並且不返回資源內容,瀏覽器使用資源快取值;否則正常返回資源內容,且更新 Last-Modified 響應頭內容。

這種方式雖然能判斷快取是否失效,但也存在兩個問題

  1. 精度問題,Last-Modified 的時間精度為秒,如果在 1 秒內發生修改,那麼快取判斷可能會失效;
  2. 準度問題,考慮這樣一種情況,如果一個檔案被修改,然後又被還原,內容並沒有發生變化,在這種情況下,瀏覽器的快取還可以繼續使用,但因為修改時間發生變化,也會重新返回重複的內容。

2. ETag 和 If-None-Match

為了解決精度問題和準度問題,HTTP 提供了另一種不依賴於修改時間,而依賴於檔案雜湊值的精確判斷快取的方式,那就是響應頭部欄位 ETag 和請求頭部欄位 If-None-Match

具體工作流程如下:

  • 瀏覽器第一次請求資源,服務端在返響應頭中加入 Etag 欄位,Etag 欄位值為該資源的雜湊值;
  • 當瀏覽器再次跟服務端請求這個資源時,在請求頭上加上 If-None-Match,值為之前響應頭部欄位 ETag 的值;
  • 服務端再次收到請求,將請求頭 If-None-Match 欄位的值和響應資源的雜湊值進行比對,如果兩個值相同,則說明資源沒有變化,返回 304 Not Modified;否則就正常返回資源內容,無論是否發生變化,都會將計算出的雜湊值放入響應頭部的 ETag 欄位中。

這種快取比較的方式也會存在一些問題,具體表現在以下兩個方面。

  1. 計算成本。生成雜湊值相對於讀取檔案修改時間而言是一個開銷比較大的操作,尤其是對於大檔案而言。如果要精確計算則需讀取完整的檔案內容,如果從效能方面考慮,只讀取檔案部分內容,又容易判斷出錯。
  2. 計算誤差。HTTP 並沒有規定雜湊值的計算方法,所以不同服務端可能會採用不同的雜湊值計算方式。這樣帶來的問題是,同一個資源,在兩臺服務端產生的 Etag 可能是不相同的,所以對於使用伺服器叢集來處理請求的網站來說,使用 Etag 的快取命中率會有所降低。

需要注意的是,強制快取的優先順序高於協商快取在協商快取中,Etag 優先順序比 Last-Modified 高。既然協商快取策略也存在一些缺陷,

那麼我們轉移到瀏覽器端看看 ServiceWorker 能不能給我們帶來驚喜。

ServiceWorker

ServiceWorker 是瀏覽器在後臺獨立於網頁執行的指令碼,也可以這樣理解,它是瀏覽器和服務端之間的代理伺服器。ServiceWorker 非常強大,可以實現包括推送通知和後臺同步等功能,更多功能還在進一步擴充套件,但其最主要的功能是實現離線快取。

1. 使用限制

越強大的東西往往越危險,所以瀏覽器對 ServiceWorker 做了很多限制:

在 ServiceWorker 中無法直接訪問 DOM,但可以通過 postMessage 介面傳送的訊息來與其控制的頁面進行通訊;

ServiceWorker 只能在本地環境下或 HTTPS 網站中使用;

ServiceWorker 有作用域的限制,一個 ServiceWorker 指令碼只能作用於當前路徑及其子路徑;

由於 ServiceWorker 屬於實驗性功能,所以相容性方面會存在一些問題,具體相容情況請看下面的截圖。

   

ServiceWorker 在瀏覽器中的支援情況

2. 使用方法

在使用 ServiceWorker 指令碼之前先要通過“註冊”的方式載入它。常見的註冊程式碼如下所示:

if ('serviceWorker' in window.navigator) {   window.navigator.serviceWorker     .register('./sw.js')     .then(console.log)     .catch(console.error) } else {   console.warn('瀏覽器不支援 ServiceWorker!')

首先考慮到瀏覽器的相容性,判斷 window.navigator 中是否存在 serviceWorker 屬性,然後通過呼叫這個屬性的 register 函式來告訴瀏覽器 ServiceWorker 指令碼的路徑。

瀏覽器獲取到 ServiceWorker 指令碼之後會進行解析,解析完成會進行安裝。可以通過監聽 “install” 事件來監聽安裝,但這個事件只會在第一次載入指令碼的時候觸發。要讓指令碼能夠監聽瀏覽器的網路請求,還需要啟用指令碼。

在指令碼被啟用之後,我們就可以通過監聽 fetch 事件來攔截請求並載入快取的資源了。

下面是一個利用 ServiceWorker 內部的 caches 物件來快取檔案的示例程式碼。

const CACHE_NAME = 'ws' let preloadUrls = ['/index.css'] self.addEventListener('install', function (event) {   event.waitUntil(     caches.open(CACHE_NAME)     .then(function (cache) {       return cache.addAll(preloadUrls);     })   ); }); self.addEventListener('fetch', function (event) {   event.respondWith(     caches.match(event.request)     .then(function (response) {       if (response) {         return response;       }       return caches.open(CACHE_NAME).then(function (cache) {           const path = event.request.url.replace(self.location.origin, '')           return cache.add(path)         })         .catch(e => console.error(e))     })   ); })

這段程式碼首先監聽 install 事件,在回撥函式中呼叫了 event.waitUntil() 函式並傳入了一個 Promise 物件。event.waitUntil 用來監聽多個非同步操作,包括快取開啟和新增快取路徑。如果其中一個操作失敗,則整個 ServiceWorker 啟動失敗。

然後監聽了 fetch 事件,在回撥函式內部呼叫了函式 event.respondWith() 並傳入了一個 Promise 物件,當捕獲到 fetch 請求時,會直接返回 event.respondWith 函式中 Promise 物件的結果。

在這個 Promise 物件中,我們通過 caches.match 來和當前請求物件進行匹配,如果匹配上則直接返回匹配的快取結果,否則返回該請求結果並快取。

總結

快取是解決效能問題的重要手段,使用快取的好處很多,除了能讓瀏覽器更快地載入網路資源之外,還會帶來其他好處,比如節省網路流量和頻寬,以及減少服務端的負擔。

本文介紹了 HTTP 快取策略及 ServiceWorker,HTTP 快取可以分為強制快取和協商快取,強制快取就是在快取有效期內直接使用瀏覽器快取;協商快取則需要先詢問服務端資源是否發生改變,如果未改變再使用瀏覽器快取。

ServiceWorker 可以用來實現離線快取,主要實現原理是攔截瀏覽器請求並返回快取的資原始檔。

最後留一道思考題:如果要讓瀏覽器不快取資源,你有哪些實現方式?