漸進式web應用開發--擁抱離線優先(三)

龍恩0707發表於2019-07-17

閱讀目錄

一:什麼是離線優先?

傳統的web應用完全依賴於伺服器端,比如像很早以前jsp,php,asp時代,所有的資料,內容和應用邏輯都在伺服器端,客戶端僅僅做一些html內容渲染到頁面上去。但是隨著技術在不斷的改變,現在很多業務邏輯也放在前端,前後端分離,前端是做模板渲染工作,後端只做業務邏輯開發,只提供資料介面。但是我們的web前端開發在資料層這方面來講還是依賴於伺服器端。如果網路中斷或伺服器介面掛掉了,都會影響資料頁面展示。因此我們需要使用離線優先這個技術來更優雅的處理這個問題。

擁抱離線優先的真正含義是:儘管應用程式的某些功能在使用者離線時可能不能正常使用,但是更多的功能應該保持可用狀態。

離線優先它可以優雅的處理這些異常情況下問題,當使用者離線時,使用者正在檢視資料可能是之前的資料,但是仍然可以訪問之前的頁面,之前的資料不會丟失,這就意味著使用者可以放心使用某些功能。那麼要做到離線時候還可以訪問,就需要我們快取哦。

二:常用的快取模式

在為我們的網站使用快取之前,我們需要先熟悉一些常見的快取設計模式。如果我們要做一個股票K線圖的話,因為股票資料是實時更新的,因此我們需要實時的去請求網路最新的資料(當然實時肯定使用websocket技術,而不是http請求,我這邊是假如)。只有當網路請求失敗的時候,我們再從快取裡面去讀取資料。但是對於股票K線圖中的一些圖示展示這樣的,因為這些圖示是一般不會變的,所以我們更傾向於使用快取裡面的資料。只有在快取裡面找不到的情況下,再從網路上請求資料。

所以有如下幾種快取模式:

1. 僅快取
2. 快取優先,網路作為回退方案。
3. 僅網路。
4. 網路優先,快取作為回退方案。
5. 網路優先,快取作為回退方案, 通用回退。

1. 僅快取

什麼是僅快取呢?僅快取是指 從快取中響應所有的資源請求,如果快取中找不到的話,那麼請求就會失敗。那麼僅快取對於靜態資源是實用的。因為靜態資源一般是不會發生變化,比如圖示,或css樣式等這些,當然如果css樣式發生改變的話,在字尾可以加上時間戳這樣的。比如 base.css?t=20191011 這樣的,如果時間戳沒有發生改變的話,那麼我們直接從快取裡面讀取。
因此我們的 sw.js 程式碼可以寫成如下(注意:該篇文章是在上篇文章基礎之上的,如果想看上篇文章,請點選這裡

self.addEventListener("fetch", function(event) {
  event.respondWith(
    caches.match(event.request)
  )
});

如上程式碼,直接監聽 fetch事件,該事件能監聽到頁面上所有的請求,當有請求過來的時候,它使用快取裡面的資料依次去匹配當前的請求,如果匹配到了,就拿快取裡面的資料,如果沒有匹配到,則請求失敗。

2. 快取優先,網路作為回退方案

該模式是:先從快取裡面讀取資料,當快取裡面沒有匹配到資料的時候,service worker才會去請求網路並返回。

程式碼變成如下:

self.addEventListener("fetch", function(event) {
  event.respondWith(
    caches.match(event.request).then(function(response) {
      return response || fetch(event.request);
    })
  )
});

如上程式碼,使用fetch去監聽所有請求,然後先使用快取依次去匹配請求,不管是匹配成功還是匹配失敗都會進入then回撥函式,當匹配失敗的時候,我們的response值就為 undefined,如果為undefined的話,那麼就網路請求,否則的話,從拿快取裡面的資料。

3. 僅網路

傳統的web模式,就是這種模式,從網路裡面去請求,如果網路不通,則請求失敗。因此程式碼變成如下:

self.addEventListener("fetch", function(event) {
  event.respondWith(
    fetch(event.request)
  )
});

4. 網路優先,快取作為回退方案。

先從網路發起請求,如果網路請求失敗的話,再從快取裡面去匹配資料,如果快取裡面也沒有找到的話,那麼請求就會失敗。

因此程式碼如下:

self.addEventListener("fetch", function(event) {
  event.respondWith(
    fetch(event.request).catch(function() {
      return caches.match(event.request);
    })
  )
});

5. 網路優先,快取作為回退方案, 通用回退

該模式是先請求網路,如果網路失敗的話,則從快取裡面讀取,如果快取裡面讀取失敗的話,我們提供一個預設的顯示給頁面展示。

比如顯示一張圖片。如下程式碼:

self.addEventListener("fetch", function(event) {
  event.respondWith(
    fetch(event.request).catch(function() {
      return caches.match(event.request).then(function(response) {
        return response || caches.match("/xxxx.png");
      })
    });
  )
});

三:混合與匹配,創造新模式

上面是我們五種快取模式。下面我們需要將這些模式要組合起來使用。

1. 快取優先,網路作為回退方案, 並更新快取。

對於不經常改變的資源,我們可以先快取優先,網路作為回退方案,第一次請求完成後,我們把請求的資料快取起來,下次再次執行的時候,我們先從快取裡面讀取。

因此程式碼如下:

self.addEventListener("fetch", function(event) {
  event.respondWith(
    caches.open("cache-name").then(function(cache) {
      return cache.match(event.request).then(function(cachedResponse){
        return cachedResponse || fetch(event.request).then(function(networkResponse){
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
      })
    })
  )
});

如上程式碼,我們首先開啟快取,然後使用請求匹配快取,不管匹配成功了還是匹配失敗了,都會進入then回撥函式,如果匹配到了,說明快取裡面有對應的資料,那麼直接從快取裡面返回,如果快取裡面 cachedResponse 值為undefined,沒有的話,那麼就重新使用fetch請求網路,然後把請求的資料 networkResponse 重新返回回來,並且克隆一份 networkResponse 放入快取裡面去。

2. 網路優先,快取作為回退方案,並頻繁更新快取

如果一些經常要實時更新的資料的話,比如百度上的一些實時新聞,那麼都需要對網路優先,快取作為回退方案來做,那麼該模式下首先會從網路中獲取最新版本,當網路請求失敗的時候才回退到快取版本,當網路請求成功的時候,它會將當前返回最新的內容重新賦值給快取裡面去。這樣就保證快取永遠是上一次請求成功的資料。即使網路斷開了,還是會使用之前最新的資料的。

因此程式碼可以變成如下:

self.addEventListener("fetch", function(event) {
  event.respondWith(
    caches.open("cache-name").then(function(cache) {
      return fetch(event.request).then(function(networkResponse) {
        cache.put(event.request, networkResponse.clone());
        return networkResponse;
      }).catch(function() {
        return caches.match(event.request);
      });
    })
  )
});

如上程式碼,我們使用fetch事件監聽所有的請求,然後開啟快取後,我們先請網路請求,請求成功後,返回最新的內容,此時此刻同時把該返回的內容克隆一份放入快取裡面去。但是當網路異常的情況下,我們就匹配快取裡面最新的資料。但是在這種情況下,如果我們第一次網路請求失敗後,由於第一次我們沒有做快取,因此快取也會失敗,最後就會顯示失敗的頁面了。

3. 快取優先,網路作為回退方案,並頻繁更新快取

對於一些經常改變的資原始檔,我們可以先快取優先,然後再網路作為回退方案,也就是說先快取裡面找到,也總會從網路上請求資源,這種模式可以先使用快取快速響應頁面,同時會重新請求來獲取最新的內容來更新快取,在我們使用者下次請求該資源的時候,那麼它就會拿到快取裡面最新的資料了,這種模式是將快速響應和最新的響應模式相結合。

因此我們的程式碼改成如下:

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open("cache-name").then(function(cache) {
      return cache.match(event.request).then(function(cachedResponse) {
        var fetchPromise = fetch(event.request).then(function(networkResponse) {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        return cachedResponse || fetchPromise;
      });
    })
  )
});

如上程式碼,我們首先開啟一個快取,然後我們試圖匹配請求,不管是否匹配成功,我們都會進入then函式,在該回撥函式內部,會先重新請求一下,請求成功後,把最新的內容返回回來,並且以此同時把該請求數的資料克隆一份出來放入快取裡面去。最後把請求的資原始檔返回儲存到 fetchPromise 該變數裡面,最後我們先返回快取裡面的資料,如果快取裡面沒有資料,我們再返回網路fetchPromise 返回的資料。

如上就是我們3種常見的模式。下面我們就需要來規劃我們的快取策略了。

四:規劃快取策略

在我們之前講解的demo中(https://www.cnblogs.com/tugenhua0707/p/11148968.html), 都是基於網路優先,快取作為回退方案模式的。我們之前使用這個模式給使用者體驗還是挺不錯的,首先先請求網路,當網路斷開的時候,我們從快取裡面拿到資料。
這樣就不會使頁面異常或空白。但是上面我們已經瞭解到了快取了,我們可以再進一步優化了。

我們現在可以使用離線優先的方式來構建我們的應用程式了,對應我們專案經常會改變的資源我們優先使用網路請求,如果網路不可以用的話,我們使用快取裡面的資料。

首先還是看下我們專案的整個目錄結構如下:

|----- 專案
|  |--- public
|  | |--- js               # 存放所有的js
|  | | |--- main.js        # js入口檔案
|  | |--- style            # 存放所有的css
|  | | |--- main.styl      # css 入口檔案
|  | |--- index.html       # index.html 頁面
|  | |--- images
|  |--- package.json
|  |--- webpack.config.js
|  |--- node_modules
|  |--- sw.js

我們的首頁 index.html 程式碼如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>service worker 實列</title>
  <link rel="stylesheet" href="/main.css" />
</head>
<body>
  <div id="app">22222</div>
  <img src="/public/images/xxx.jpg" />
  <script type="text/javascript" src="/main.js"></script>
</body>
</html>

首頁是由靜態的index.html 組成的,它一般很少會隨著版本的改變而改變的,它頁面中會請求多個圖片,請求多個css樣式,和請求多個js檔案。在index.html中所有的靜態資原始檔(圖片、css、js)等在我們的service worker安裝過程中會快取下來的,那麼這些資原始檔適合的是 "快取優先,網路作為回退方案" 模式來做。這樣的話,頁面載入會更快。

但是index.html呢?這個頁面一般情況下很少改變,我們一般會想到 "快取優先,網路作為回退方案" 來考慮,但是如果該頁面也改動了程式碼呢?我們如果一直使用快取的話,那麼我們就得不到最新的程式碼了,如果我們想我們的index.html拿到最新的資料,我們不得不重新更新我們的service worker,來獲取最新的快取檔案。但是我們從之前的知識點我們知道,在我們舊的service worker 釋放頁面的同時,新的service worker被啟用之前,頁面也不是最新的版本的。必須要等第二次重新重新整理頁面的時候才會看到最新的頁面。那麼我們的index.html頁面要如何做呢?

1) 如果我們使用 "快取優先,網路作為回退方案" 模式來提供服務的話,那麼這樣做的話,當我們改變頁面的時候,它就有可能不會使用最新版本的頁面。

2)如果我們使用 "網路優先,快取作為回退方案 " 模式來做的話,這樣確實可以通過請求來顯示最新的頁面,但是這樣做也有缺點,比如我們的index.html頁面沒有改過任何東西的話,也要從網路上請求,而不是從快取裡面讀取,導致載入的時間會慢一點。

3) 使用 快取優先,網路作為 回退方案,並頻繁更新快取模式。該模式總是從快取裡面讀取 index.html頁面,那麼它的響應時間相對來說是非常快的,並且從快取裡面讀取頁面後,我們同時會請求下,然後返回最新的資料,我們把最新的資料來更新快取,因此我們下一次進來頁面的時候,會使用最新的資料。

因此對於我們的index.html頁面,我們適合使用第三種方案來做。

因此對於我們這個簡單的專案來講,我們可以總結如下:

1. 使用 "快取優先,網路作為回退方案,並頻繁更新快取" 模式來返回index.html檔案。
2. 使用 "快取優先,網路作為回退方案" 來返回首頁需要的所有靜態檔案。

因此我們可以使用上面兩點,來實現我們的快取策略。

五:實現快取策略

現在我們來更新下我們的 sw.js 檔案,該檔案來快取我們index.html,及在index.html使用到的所有靜態資原始檔。

index.html 程式碼改成如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>service worker 實列</title>
</head>
<body>
  <div id="app">22222</div>
  <img src="/public/images/xxx.jpg" />
</body>
</html>

js/main.js 程式碼變為如下:

// 載入css樣式
require('../styles/main.styl');

if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register('/sw.js', {scope: '/'}).then(function(registration) {
    console.log("Service Worker registered with scope: ", registration.scope);
  }).catch(function(err) {
    console.log("Service Worker registered failed:", err);
  });
}

sw.js 程式碼變成如下:

var CACHE_NAME = "cacheName";

var CACHE_URLS = [
  "/public/index.html",      // html檔案
  "/main.css",               // css 樣式表
  "/public/images/xxx.jpg",  // 圖片
  "/main.js"                 // js 檔案 
];

// 監聽 install 事件,把所有的資原始檔快取起來
self.addEventListener("install", function(event) {
  event.waitUntil(
    caches.open(CACHE_NAME).then(function(cache) {
      return cache.addAll(CACHE_URLS);
    })
  )
});

// 監聽fetch事件,監聽所有的請求

self.addEventListener("fetch", function(event) {
  var requestURL = new URL(event.request.url);
  console.log(requestURL);
  if (requestURL.pathname === '/' || requestURL.pathname === "/index.html") {
    event.respondWith(
      caches.open(CACHE_NAME).then(function(cache) {
        return cache.match("/index.html").then(function(cachedResponse) {
          var fetchPromise = fetch("/index.html").then(function(networkResponse) {
            cache.put("/index.html", networkResponse.clone());
            return networkResponse;
          });
          return cachedResponse || fetchPromise;
        })
      })
    )
  } else if (CACHE_URLS.includes(requestURL.href) || CACHE_URLS.includes(requestURL.pathname)) {
    event.respondWith(
      caches.open(CACHE_NAME).then(function(cache) {
        return cache.match(event.request).then(function(response) {
          return response || fetch(event.request);
        });
      })
    )
  } 
});

self.addEventListener("activate", function(e) {
  e.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.map(function(cacheName) {
          if (CACHE_NAME !== cacheName && cacheName.startWith("cacheName")) {
            return caches.delete(cacheName);
          }
        })
      )
    })
  )
});

如上程式碼中的fetch事件,var requestURL = new URL(event.request.url);console.log(requestURL); 列印資訊如下所示:

如上我們使用了 new URL(event.request.url) 來決定如何處理不同的請求。且可以獲取到不同的屬性,比如host, hostname, href, origin 等這樣的資訊到。

如上我們監聽 fetch 事件中所有的請求,判斷 requestURL.pathname 是否是 "/" 或 "/index.html", 如果是index.html 頁面的話,對於 index.html 的來說,使用上面的原則是:使用 "快取優先,網路作為回退方案,並頻繁更新快取", 所以如上程式碼,我們首先開啟我們的快取,然後使用快取匹配 "/index.html",不管匹配是否成功,都會進入then回撥函式,然後把快取返回,在該函式內部,我們會重新請求,把請求最新的內容儲存到快取裡面去,也就是說更新我們的快取。當我們第二次訪問的時候,使用的是最新快取的內容。

如果我們請求的資原始檔不是 index.html 的話,我們接著會判斷下,CACHE_URLS 中是否包含了該資原始檔,如果包含的話,我們就從快取裡面去匹配,如果快取沒有匹配到的話,我們會重新請求網路,也就是說我們對於頁面上所有靜態資原始檔話,使用 "快取優先,網路作為回退方案" 來返回首頁需要的所有靜態檔案。

因此我們現在再來訪問我們的頁面的話,如下所示:

如上所示,我們可以看到,我們第一次請求的時候,載入index.html 及 其他的資原始檔,我們可以從上圖可以看到 載入時間的毫秒數,雖然從快取裡面讀取第一次資料後,但是由於我們的index.html 總是會請求下,把最新的資源再返回回來,然後更新快取,因此我們可以看到我們第二次載入index.html 及 所有的service worker中的資原始檔,可以看到第二次的載入時間更快,並且當我們修改我們的index.html 後,我們重新整理下頁面後,第一次還是從快取裡面讀取最新的資料,當我們第二次重新整理的時候,頁面才會顯示我們剛剛修改的index.html頁面的最新頁面了。因此就驗證了我們之前對於index.html 處理的邏輯。

使用 快取優先,網路作為 回退方案,並頻繁更新快取模式。該模式總是從快取裡面讀取 index.html頁面,那麼它的響應時間相對來說是非常快的,並且從快取裡面讀取頁面後,我們同時會請求下,然後返回最新的資料,我們把最新的資料來更新快取,因此我們下一次進來頁面的時候,會使用最新的資料。
github簡單的demo

相關文章