PWA 離線方案研究報告

来源:https://www.cnblogs.com/Jcloud/archive/2023/12/12/17896526.html
-Advertisement-
Play Games

本文並不是介紹如何將一個網頁配置成離線應用並支持安裝下載的。研究PWA的目的僅僅是為了保證用戶的資源可以直接從本地載入,來忽略全國或者全球網路質量對頁面載入速度造成影響。當然,如果頁面上所需的資源,除了資源文件外並不需要任何的網路請求,那它除了不支持**安裝到桌面**,已經算是一個離線應用了。 ...


本文並不是介紹如何將一個網頁配置成離線應用並支持安裝下載的。研究PWA的目的僅僅是為了保證用戶的資源可以直接從本地載入,來忽略全國或者全球網路質量對頁面載入速度造成影響。當然,如果頁面上所需的資源,除了資源文件外並不需要任何的網路請求,那它除了不支持安裝到桌面,已經算是一個離線應用了。

什麼是PWA

PWA(Progressive Web App)是一種結合了網頁和原生應用程式功能的新型應用程式開發方法。PWA 通過使用現代 Web 技術,例如 Service Worker 和 Web App Manifest,為用戶提供了類似原生應用的體驗。

從用戶角度來看,PWA 具有以下特點:

  1. 可離線訪問:PWA 可以在離線狀態下載入和使用,使用戶能夠在沒有網路連接的情況下繼續瀏覽應用;

  2. 可安裝:用戶可以將 PWA 添加到主屏幕,就像安裝原生應用一樣,方便快捷地訪問;

  3. 推送通知:PWA 支持推送通知功能,可以向用戶發送實時更新和提醒;

  4. 響應式佈局:PWA 可以適應不同設備和屏幕大小,提供一致的用戶體驗。

從開發者角度來看,PWA 具有以下優勢:

  1. 跨平臺開發:PWA 可以在多個平臺上運行,無需單獨開發不同的應用程式;

  2. 更新便捷:PWA 的更新可以通過伺服器端更新 Service Worker 來實現,用戶無需手動更新應用;

  3. 可發現性:PWA 可以通過搜索引擎進行索引,增加應用的可發現性;

  4. 安全性:PWA 使用 HTTPS 協議傳輸數據,提供更高的安全性。

總之,PWA 是一種具有離線訪問、可安裝、推送通知和響應式佈局等特點的新型應用開發方法,為用戶提供更好的體驗,為開發者帶來更高的效率。

我們從PWA的各種能力中,聚焦下其可離線訪問的能力。

Service Worker

離線載入本質上是頁面所需的各種jscss以及頁面本身的html,都可以緩存到本地,不再從網路上請求。這個能力是通過Service Worker來實現的。

Service Worker 是一種在瀏覽器背後運行的腳本,用於處理網路請求和緩存數據。它可以攔截和處理網頁請求,使得網頁能夠在離線狀態下載入和運行。Service Worker 可以緩存資源,包括 HTML、CSS、JavaScript 和圖像等,從而提供更快的載入速度和離線訪問能力。它還可以實現推送通知和後臺同步等功能,為 Web 應用帶來更強大的功能和用戶體驗。

某些情況下,Service Worker 和瀏覽器插件的 background 很相似,但在功能和使用方式上有一些區別:

  • 功能差異: Service Worker 主要用於處理網路請求和緩存數據,可以攔截和處理網頁請求,實現離線訪問和資源緩存等功能。而瀏覽器插件的 background 主要用於擴展瀏覽器功能,例如修改頁面、攔截請求、操作 DOM 等。
  • 運行環境: Service Worker 運行在瀏覽器的後臺,獨立於網頁運行。它可以在網頁關閉後繼續運行,並且可以在多個頁面之間共用狀態。而瀏覽器插件的 background 也在後臺運行,但是它的生命周期與瀏覽器視窗相關,關閉瀏覽器視窗後插件也會被終止。
  • 許可權限制: 由於安全考慮,Service Worker 受到一定的限制,無法直接訪問 DOM,只能通過 postMessage() 方法與網頁進行通信。而瀏覽器插件的 background 可以直接操作 DOM,對頁面有更高的控制權。

總的來說,Service Worker 更適合用於處理網路請求和緩存數據,提供離線訪問和推送通知等功能;而瀏覽器插件的 background 則更適合用於擴展瀏覽器功能,操作頁面 DOM,攔截請求等。

註冊

註冊一個Service Worker其實是非常簡單的,下麵舉個簡單的例子

<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
  <title>Service Worker 示例</title>
</head>
<body>
  <script>
    if ('serviceWorker' in navigator) {
      window.addEventListener('load', function() {
        navigator.serviceWorker.register('/service-worker.js')
          .then(function(registration) {
            console.log('Service Worker 註冊成功:', registration.scope);
          })
          .catch(function(error) {
            console.log('Service Worker 註冊失敗:', error);
          });
      });
    }
  </script>
</body>
</html>




// service-worker.js

// 定義需要預緩存的文件列表
const filesToCache = [
  '/',
  '/index.html',
  '/styles.css',
  '/script.js',
  '/image.jpg'
];

// 安裝Service Worker時進行預緩存
self.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open('my-cache')
      .then(function(cache) {
        return cache.addAll(filesToCache);
      })
  );
});

// 激活Service Worker
self.addEventListener('activate', function(event) {
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.filter(function(cacheName) {
          return cacheName !== 'my-cache';
        }).map(function(cacheName) {
          return caches.delete(cacheName);
        })
      );
    })
  );
});

// 攔截fetch事件並從緩存中返迴響應
self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request)
      .then(function(response) {
        return response || fetch(event.request);
      })
  );
});





上述示例中,註冊Service Worker的邏輯包含在HTML文件的<script>標簽中。當瀏覽器載入頁面時,會檢查是否支持Service Worker,如果支持,則註冊Service Worker文件/service-worker.js

Service Worker文件中,首先定義了需要預緩存的文件列表filesToCache。在install事件中,將這些文件添加到緩存中。在activate事件中,刪除舊緩存。在fetch事件中,攔截請求並從緩存中返迴響應。

Service Worker文件service-worker.js需要放置在頁面的主功能變數名稱下。在調用navigator.serviceWorker.register('/service-worker.js')時,可以在第二個參數中設置scope,用來確定Service Worker的影響範圍,預設是sw文件所在path的作用域。

需要註意的是,如果sw文件被放置在/a目錄下,是不能設置作用域為/的。因為文件本身路徑的級別小於根路徑。

使用

當我們按照上面的示例,配置好了html及對應的sw.js後,啟動服務並刷新頁面,應該就能看到控制台列印出了Service Worker 註冊成功的日誌。

如果在chrome瀏覽器中,可以打開控制台,切換到應用Tab,就能看到我們剛註冊好的應用了。

此時在瀏覽器的緩存空間中,也能發現我們開闢的緩存my-cache,內部存儲著我們指定的預緩存文件index.html。由於我的項目只有根頁面,所以只有一個條目。

此時如果頁面所需的所有文件都被緩存了,即使將瀏覽器設置成斷網模式,刷新頁面也是能打開的。本文的目的並不是創建離線應用,下麵我們講講上面方式會面臨的問題。

如何確定預緩存範圍

如果我們的項目只有一個倉庫,可以使用一些webpack插件,可以直接幫我們生成sw文件。每次重新構建都會生成新的文件,這樣就不用擔心多存或者少存文件了。同時,在下一章節的刪除舊緩存中,每次更新版本號就好了。

Workbox 是一個用於創建離線優先的網路應用程式的JavaScript庫。它提供了一套工具和功能,幫助開發人員創建可靠的離線體驗,並使網頁應用程式能夠在網路連接不穩定或斷開的情況下正常工作。Workbox可以用於緩存和提供離線資源,實現離線頁面導航,處理後臺同步和推送通知等功能。它能夠簡化離線應用程式的開發過程,並提供強大的緩存管理和資源載入能力。

對於有統一配置後臺的微前端項目,這個問題有些棘手。

  1. 由於有後臺管理,更新某個模塊的文件很常見,但並不想每次都更新sw.js

  2. 由於資源的不確定性,無法在precache中列舉出所有的資源列表,即使列舉出了,可能用戶永遠也不會用到某個文件,造成緩存浪費或溢出。

  3. 出於第1、2條緣由,更新sw文件後,無法確定如何刪除舊緩存。

對於這個問題,首先確定的是,先在precache中列舉出所有的基礎底座的資源文件,並單獨占用一個cacheName

對於剩下的不確定性的業務文件,可以使用動態緩存的方式,這個會在後面具體講解,也是本文要研究的重點。

資源更新

由於刷新頁面後,所有資源都從緩存中獲取,此時修改html後,再刷新瀏覽器,頁面並沒有更新。

這個問題其實不用太擔心,雖然我們的資源都被緩存了,但是sw.js本身是不會被緩存的。即使我們在下一次更新中,刪除了頁面上註冊Service Worker的代碼,已經註冊的Service Worker也會一直激活,直到我們主動的刪除它。

對於一般的SPA項目,上線後資源一般是不變的,如果我們希望更新頁面,只需要更新sw.js就好。當註冊的Service Worker文件發生變化時,瀏覽器會自動下載新的Service Worker文件,併在下一次訪問頁面時激活新的Service Worker。

更新文件需要註意幾個問題:

  1. 刪除舊緩存:

示例代碼中,在activate階段,我們執行了刪除緩存的邏輯。真實環境中,一般會將cacheName帶上版本號,每次更新sw都更新下版本號。這樣每次都會將舊緩存刪掉,並重新開闢新版本的緩存。各瀏覽器對於緩存超出後的處理是不同的,例如chrome就是緩存逐出策略。及時的清理緩存,可以防止出現一些奇怪的問題。

const version = 'v1';

const preCacheName = 'pre-cache-'+ version;

// 將後文調用的 ’my-cache‘的位置替換為 preCacheName




  1. Service Worker 更新不及時:

同一個域下,只能有一個Service Worker被激活,只有所有該域下的頁面都關閉了,下一個註冊的Service Worker才能被激活並取代上一個。對於某些用戶來說,這個時間太長了。

因此,我們需要在install事件中,等待precache環節結束後,調用self.skipWaiting();來立即激活新的Service Worker,但並不會立即接管控制所有客戶端(即瀏覽器標簽頁)。這意味著舊的Service Worker仍然會處理當前打開的頁面,直到這些頁面被關閉或重新載入。

為了確保新的Service Worker可以立即接管所有客戶端,在activate事件中調用clients.claim()方法。這個方法會在新的Service Worker激活後,立即接管所有已打開的頁面,而不需要等待這些頁面重新載入。這樣可以確保新的Service Worker能夠立即生效,提供更新的功能和服務。

更改完後的代碼如下,這樣修改後,skipWaiting()clients.claim()方法會在非同步操作完成後被調用,確保新的Service Worker在安裝完成後立即激活並接管所有客戶端。

// 安裝Service Worker時進行預緩存
self.addEventListener('install', function (event) {
  event.waitUntil(
    (async function () {
      await caches
        .open(preCacheName)
        .then(function (cache) {
          return cache.addAll(filesToCache);
        })
        .then(() => {
          self.skipWaiting();
        });
    })()
  );
});

// 激活Service Worker
self.addEventListener('activate', function (event) {
  event.waitUntil((async function () {
    await clearOutdateResources();
    self.clients.claim();
  })());
});




現在,更新下index.html,然後將上述sw.js的更新保存,接著刷新兩次頁面(不要著急,給註冊和載入資源一些時間,可以在控制臺中觀察下Service Worker的活躍狀態以及緩存的變化)。

可以在某個時刻,發現同時存在兩個Service Worker,一個處於激活狀態,是我們正在使用的,另一個處於待激活狀態,因為正在進行install。此時緩存空間也會同時存在兩個版本的緩存,等新的Service Worker激活後,就會刪除舊緩存。然後就只存在一個最新的Service Worker了,同時緩存也只剩一個了。

現在每次用戶打開新的頁面,

  • 優先從緩存中獲取資源
  • 如果發現sw文件被更新,安裝新的文件
  • 文件內會下載新的資源,同時刪除舊緩存,並且接管所有頁面
  • 用戶下一次打開新頁面或刷新當前頁面,就會展示最新的內容

能力擴展

基礎操作搞定了。但是上面我們還欠了點技術債,即如果不確定到底有哪些資源,怎麼動態的做出緩存。不要著急,現在先進行下擴展閱讀。

緩存的幾種策略

當談到Service Worker緩存策略時,有以下幾種常見的策略:

  • Cache First(優先緩存):首先嘗試從緩存中獲取響應,如果緩存中存在該資源,則直接返回;如果沒有緩存或緩存過期,則向網路發送請求。
  • Network First(優先網路):首先嘗試從網路獲取響應,如果網路請求成功,則返回網路響應;如果網路請求失敗,則從緩存中獲取響應,即使緩存過期也會返回緩存的響應。
  • Cache Only(僅緩存):只從緩存中獲取響應,不向網路發送請求。適用於完全離線可訪問的資源。
  • Network Only(僅網路):只從網路獲取響應,不使用緩存。適用於需要實時數據的場景。
  • Stale-While-Revalidate(同時更新和使用緩存):首先嘗試從緩存中獲取響應,如果緩存過期,則向網路發送請求獲取最新響應,並更新緩存。同時返回緩存的響應,以便快速展示內容。

上文中我們使用的,就是緩存優先模式。對於不怎麼更新或者只有一個倉庫的應用來說,使用sw.js文件的更新來說已經足夠了。畢竟代碼寫的越多,bug就越多。同比,更新的越頻繁,系統就越不穩定。

Stale-While-Revalidate

其他策略如果有興趣,可以自行搜索,現在我們來講下動態緩存是怎麼實現的。畢竟對於微服務來說,不更新sw是最好的,如果能忘了它就更好了。

上文中我們介紹了Cache First,重新附下代碼

// 攔截fetch事件並從緩存中返迴響應
self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.match(event.request).then(function (response) {
      return response || fetch(event.request);
    })
  );
});




新增一個mock.js,腳本會向body中新增一個字元串。將js文件使用script的方式載入。

// mock.js
const div = document.createElement('div');
div.innerText = 'Hello World';
document.body.appendChild(div);

// index.html
<script src="./mock.js" type="text/javascript"></script>




同時調整下sw的攔截邏輯。

// 新增runtime緩存
const runtimeCacheName = 'runtime-cache-' + version;

// 符合條件也是緩存優先,但是每次都重新發起網路請求更新緩存
const isStaleWhileRevalidate = (request) => {
  const url = request.url;
  const index = ['http://127.0.0.1:5500/mock.js'].indexOf(url);
  return index !== -1;
};

self.addEventListener('fetch', function (event) {
  event.respondWith(
    // 嘗試從緩存中獲取響應
    caches.match(event.request).then(function (response) {
      var fetchPromise = fetch(event.request).then(function (networkResponse) {

        // 符合匹配條件才克隆響應並將其添加到緩存中
        if (isStaleWhileRevalidate(event.request)) {
          var responseToCache = networkResponse.clone();
          caches.open(runtimeCacheName).then(function (cache) {
            cache.put(event.request, responseToCache.clone());
          });
        }
        return networkResponse;
      });

      // 返回緩存的響應,然後更新緩存中的響應
      return response || fetchPromise;
    })
  );
});




現在每次用戶打開新的頁面,

  • 優先從緩存中獲取資源,同時發起一個網路請求
  • 有緩存則直接返回緩存,沒有則返回一個fetchPromise
  • fetchPromise內部更新符合緩存條件的請求
  • 用戶下一次打開新頁面或刷新當前頁面,就會展示最新的內容

通過修改isStaleWhileRevalidate中url的匹配條件,就能夠控制是否更新緩存。在上面的示例中,我們可以將index.htmlprecache列表中移除,放入runtime中,或者專門處理下index.html的放置規則,去更新precache中的緩存。最好不要出現多個緩存桶中存在同一個request的緩存,那樣就不知道走的到底是哪個緩存了。

一般來說,微前端的應用,資源文件都有個固定的存放位置,文件本身通過在文件名上增加hash或版本號來進行區分。我們在isStaleWhileRevalidate函數中匹配存放資源位置的路徑,這樣用戶在第二次打開頁面時,就可以直接使用緩存了。如果是內嵌頁面,可以與平臺溝通,是否可以在應用冷起的時候,偷偷訪問一個資源頁面,提前進行預載入,這樣就能在首次打開的時候也享受本地緩存了。

緩存過期

即使我們緩存了一些資源文件,例如Iconfont、字體庫等只會更新自身內容,但不會變化名稱的文件。僅使用Stale-While-Revalidate其實也是可以的。用戶會在第二次打開頁面時看到最新的內容。

但為了提高一些體驗,例如,用戶半年沒打開頁面了,突然在今天打開了一下,展示歷史的內容就不太合適了,這時候可以增加一個緩存過期的策略。

如果我們使用的是Workbox,通過使用ExpirationPlugin來實現的。ExpirationPluginWorkbox中的一個緩存插件,它允許為緩存條目設置過期時間。示例如下所示

import { registerRoute } from 'workbox-routing';
import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';

// 設置緩存的有效期為一小時
const cacheExpiration = {
  maxAgeSeconds: 60 * 60, // 一小時
};

// 使用CacheFirst策略,並應用ExpirationPlugin
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'image-cache',
    plugins: [
      new ExpirationPlugin(cacheExpiration),
    ],
  })
);

// 使用StaleWhileRevalidate策略,並應用ExpirationPlugin
registerRoute(
  ({ request }) => request.destination === 'script',
  new StaleWhileRevalidate({
    cacheName: 'script-cache',
    plugins: [
      new ExpirationPlugin(cacheExpiration),
    ],
  })
);




或者我們可以實現一下自己的緩存過期策略。首先是增加緩存過期時間。在原本的更新緩存的基礎上,設置自己的cache-control,然後再放入緩存中。示例中直接刪除了原本的cache-control,真正使用中,需要判斷下,比如no-cache類型的資源,就不要使用緩存了。

每次命中緩存時,都會判斷下是否過期,如果過期,則直接返回從網路中獲取的最新的請求,並更新緩存。

self.addEventListener('fetch', function (event) {
  event.respondWith(
    // 嘗試從緩存中獲取響應
    caches.match(event.request).then(function (response) {
      var fetchPromise = fetch(event.request).then(function (networkResponse) {
        if (isStaleWhileRevalidate(event.request)) {
          // 檢查響應的狀態碼是否為成功
          if (networkResponse.status === 200) {
            // 克隆響應並將其添加到緩存中
            var clonedResponse = networkResponse.clone();
            // 在存儲到緩存之前,設置正確的緩存頭部
            var headers = new Headers(networkResponse.headers);
            headers.delete('cache-control');
            headers.append('cache-control', 'public, max-age=3600'); // 設置緩存有效期為1小時

            // 創建新的響應對象並存儲到緩存中
            var cachedResponse = new Response(clonedResponse.body, {
              status: networkResponse.status,
              statusText: networkResponse.statusText,
              headers: headers,
            });

            caches.open(runtimeCacheName).then((cache) => {
              cache.put(event.request, cachedResponse);
            });
          }
        }
        return networkResponse;
      });

      // 檢查緩存的響應是否存在且未過期
      if (response && !isExpired(response)) {
        return response; // 返回緩存的響應
      }
      return fetchPromise;
    })
  );
});

function isExpired(response) {
  // 從響應的headers中獲取緩存的有效期信息
  var cacheControl = response.headers.get('cache-control');
  if (cacheControl) {
    var maxAgeMatch = cacheControl.match(/max-age=(\d+)/);
    if (maxAgeMatch) {
      var maxAgeSeconds = parseInt(maxAgeMatch[1], 10);
      var requestTime = Date.parse(response.headers.get('date'));
      var expirationTime = requestTime + maxAgeSeconds * 1000;

      // 檢查當前時間是否超過了緩存的有效期
      if (Date.now() < expirationTime) {
        return false; // 未過期
      }
    }
  }

  return true; // 已過期
}




從Service Worker發起的請求,可能會被瀏覽器自身的記憶體緩存或硬碟緩存捕獲,然後直接返回。

精確清理緩存

下麵的內容,預設為微前端應用。

隨著微前端應用的更新,會逐漸出現失效的資源文件一直出現在緩存中,時間長了可能會導致緩存溢出。

定時更新

例如以半年為期限,定期更新sw文件的版本號,每次更新都會一刀切的將上一個版本中的動態緩存幹掉,此操作會導致下次載入變慢,因為會重新通過網路請求的方式載入來創建緩存。但如果更新頻率控制得當,並且資源拆分合理,用戶感知不會很大。

處理不常用緩存

上文中的緩存過期策略,並不適用於此處。因為微服務中資源文件中,只要文件名不變,內容就應該不變。我們只是期望刪除超過一定時間沒有使用的條目,防止緩存溢出。這裡也使用Stale-While-Revalidate的原因是為了幫助我們識別長期不使用的js文件,方便刪除。

本來可以使用self.registration.periodicSync.register來創建一個周期性任務,但是由於相容性問題,放棄了。需要的可自行研究,附上網址

這裡我們換一個條件。每當有網路請求被觸發時,啟動一個延遲20s的debounce函數,來處理緩存問題。先把之前的清除舊版本緩存的函數改名成clearOldResources。然後設定緩存過期時間為10s,刷新兩次頁面來觸髮網路請求,20s之後,runtime緩存中的mock.js就會被刪除了。真實場景下,延遲函數和緩存過期都不會這麼短,可以設置成5min和3個月。

function debounce(func, delay) {
  let timerId;

  return function (...args) {
    clearTimeout(timerId);

    timerId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
}

const clearOutdateResources = debounce(function () {
  cache
    .open(runtimeCacheName)
    .keys()
    .then(function (requests) {
      requests.forEach(function (request) {
        cache.match(request).then(function (response) {
          // response為匹配到的Response對象
          if (isExpiredWithTime(response, 10)) {
            cache.delete(request);
          }
        });
      });
    });
});

function isExpiredWithTime(response, time) {
  var requestTime = Date.parse(response.headers.get('date'));
  if (!requestTime) {
    return false;
  }
  var expirationTime = requestTime + time * 1000;

  // 檢查當前時間是否超過了緩存的有效期
  if (Date.now() < expirationTime) {
    return false; // 未過期
  }
  return true; // 已過期
}




重新總結下微前端應用下的緩存配置:

  1. 使用版本號,並初始化preCacheruntimeCache

  2. preCache中預緩存基座數據,使用Cache First策略,sw不更新則基座數據不更新

  3. runtimeCache使用Stale-While-Revalidate策略負責動態緩存業務資源的數據,每次訪問頁面都動態更新一次

  4. 使用debounce函數,每次訪問頁面都會延遲清除過期的緩存

  5. 如果需要更新preCache中的基座數據,則需要升級版本號並重新安裝sw文件。新服務激活後會刪除上一個版本的數據

  6. runtimeCachepreCache不能同時存儲一個資源,否則可能導致混亂。

最終示例

下麵是最終的sw.js,我刪除掉了緩存過期的邏輯,如有需要請自行從上文代碼中獲取。順便我增加了一點點喪心病狂的錯誤處理邏輯。

理論上,index.html應該放入預緩存的列表裡,但我懶得寫在Stale-While-Revalidate里分別更新preCacheruntimeCache了,相信看完上面內容的你,一定可以自己實現對應邏輯。

如果你用了下麵的文件,每次刷新完頁面的20s後,runtime的緩存就會被清空,因為我們過期時間只設置了10s。而每次發起請求後的20s後就會進行過期判斷。

在真實的驗證過程中,有部分

const version = 'v1';

const preCacheName = 'pre-cache-' + version;
const runtimeCacheName = 'runtime-cache'; // runtime不進行整體清除

const filesToCache = []; // 這裡將index.html放到動態緩存里了,為了搭自動更新的便車。這個小項目也沒別的需要預緩存的了

const maxAgeSeconds = 10; // 緩存過期時間,單位s

const debounceClearTime = 20; // 延遲清理緩存時間,單位s

// 符合條件也是緩存優先,但是每次都重新發起網路請求更新緩存
const isStaleWhileRevalidate = (request) => {
  const url = request.url;
  const index = [`${self.location.origin}/mock.js`, `${self.location.origin}/index.html`].indexOf(url);
  return index !== -1;
};

/*********************上面是配置代碼***************************** */

const addResourcesToCache = async () => {
  return caches.open(preCacheName).then((cache) => {
    return cache.addAll(filesToCache);
  });
};

// 安裝Service Worker時進行預緩存
self.addEventListener('install', function (event) {
  event.waitUntil(
    addResourcesToCache().then(() => {
      self.skipWaiting();
    })
  );
});

// 刪除上個版本的數據
async function clearOldResources() {
  return caches.keys().then(function (cacheNames) {
    return Promise.all(
      cacheNames
        .filter(function (cacheName) {
          return ![preCacheName, runtimeCacheName].includes(cacheName);
        })
        .map(function (cacheName) {
          return caches.delete(cacheName);
        })
    );
  });
}

// 激活Service Worker
self.addEventListener('activate', function (event) {
  event.waitUntil(
    clearOldResources().finally(() => {
      self.clients.claim();
      clearOutdateResources();
    })
  );
});

// 緩存優先
const isCacheFirst = (request) => {
  const url = request.url;
  const index = filesToCache.findIndex((u) => url.includes(u));
  return index !== -1;
};

function addToCache(cacheName, request, response) {
  try {
    caches.open(cacheName).then((cache) => {
      cache.put(request, response);
    });
  } catch (error) {
    console.error('add to cache error =>', error);
  }
}

async function cacheFirst(request) {
  try {
    return caches
      .match(request)
      .then((response) => {
        if (response) {
          return response;
        }

        return fetch(request).then((response) => {
          // 檢查是否成功獲取到響應
          if (!response || response.status !== 200) {
            return response; // 返回原始響應
          }

          var clonedResponse = response.clone();
          addToCache(runtimeCacheName, request, clonedResponse);
          return response;
        });
      })
      .catch(() => {
        console.error('match in cacheFirst error', error);
        return fetch(request);
      });
  } catch (error) {
    console.error(error);
    return fetch(request);
  }
}

// 緩存優先,同步更新
async function handleFetch(request) {
  try {
    clearOutdateResources();
    // 嘗試從緩存中獲取響應
    return caches.match(request).then(function (response) {
      var fetchPromise = fetch(request).then(function (networkResponse) {
        // 檢查響應的狀態碼是否為成功
        if (!networkResponse || networkResponse.status !== 200) {
          return networkResponse;
        }
        // 克隆響應並將其添加到緩存中
        var clonedResponse = networkResponse.clone();
        addToCache(runtimeCacheName, request, clonedResponse);

        return networkResponse;
      });

      // 返回緩存的響應,然後更新緩存中的響應
      return response || fetchPromise;
    });
  } catch (error) {
    console.error(error);
    return fetch(request);
  }
}

self.addEventListener('fetch', function (event) {
  const { request } = event;

  if (isCacheFirst(request)) {
    event.respondWith(cacheFirst(request));
    return;
  }
  if (isStaleWhileRevalidate(request)) {
    event.respondWith(handleFetch(request));
    return;
  }
});

function debounce(func, delay) {
  let timerId;

  return function (...args) {
    clearTimeout(timerId);

    timerId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
}

const clearOutdateResources = debounce(function () {
  try {
    caches.open(runtimeCacheName).then((cache) => {
      cache.keys().then(function (requests) {
        requests.forEach(function (request) {
          cache.match(request).then(function (response) {
            const isExpired = isExpiredWithTime(response, maxAgeSeconds);
            if (isExpired) {
              cache.delete(request);
            }
          });
        });
      });
    });
  } catch (error) {
    console.error('clearOutdateResources error => ', error);
  }
}, debounceClearTime * 1000);

function isExpiredWithTime(response, time) {
  var requestTime = Date.parse(response.headers.get('date'));
  if (!requestTime) {
    return false;
  }
  var expirationTime = requestTime + time * 1000;

  // 檢查當前時間是否超過了緩存的有效期
  if (Date.now() < expirationTime) {
    return false; // 未過期
  }
  return true; // 已過期
}




註意

在真實的驗證過程中,有部分資源獲取不到date這個數據,因此為了保險,我們還是在存入緩存時,自己補充一個存入時間

 // 克隆響應並將其添加到緩存中
var clonedResponse = networkResponse.clone();
// 在存儲到緩存之前,設置正確的緩存頭部
var headers = new Headers(networkResponse.headers);

headers.append('sw-save-date', Date.now()); 

// 創建新的響應對象並存儲到緩存中
var cachedResponse = new Response(clonedResponse.body, {
  status: networkResponse.status,
  statusText: networkResponse.statusText,
  headers: headers,
});




在判斷過期時,取我們自己寫入的key即可。

function isExpiredWithTime(response, time) {
  var requestTime = Number(response.headers.get('sw-save-date'));
  if (!requestTime) {
    return false;
  }
  var expirationTime = requestTime + time * 1000;
  // 檢查當前時間是否超過了緩存的有效期
  if (Date.now() < expirationTime) {
    return false; // 未過期
  }
  return true; // 已過期
}


不可見響應

還記得上面為了安全考慮,在存入緩存時,對響應的狀態做了判斷,非200的都不緩存。然後就又發現異常場景了。

 // 檢查是否成功獲取到響應
if (!response || response.status !== 200) {
  return response; // 返回原始響應
}




opaque 響應通常指的是跨源請求(CORS)中的一種情況,在該情況下,瀏覽器出於安全考慮,不允許訪問服務端返回的響應內容。opaque 響應通常發生在服務工作者(Service Workers)進行的跨源請求中,且沒有CORS頭部的情況下。

opaque 響應的特征是:

  • 響應的內容無法被JavaScript訪問。
  • 響應的大小無法確定,因此Chrome開發者工具中會顯示為 (opaque)。
  • 響應的狀態碼通常是 0,即使實際上伺服器可能返回了不同的狀態碼。

因此我們需要做一些補充動作。不單是補充cors模式,還得同步設置下credentials

 const newRequest =
  request.url === 'index.html'
    ? request
    : new Request(request, { mode: 'cors', credentials: 'omit' });




在Service Workers發起網路請求時,如果頁面本身需要認證,那就像上面代碼那樣,對頁面請求做個判斷。request.url === 'index.html'是我寫的示例,真實請求中,需要拼出完整的url路徑。而對於資源文件,走非認證的cors請求即可。將請求的request改為我們變更後的newRequest,請求資源就可以正常的被緩存了。

var fetchPromise = fetch(newRequest).then(function (networkResponse)


銷毀

離線緩存用得好升職加薪,用不好就刪庫跑路。除了上面的一點點的防錯邏輯,整體的降級方案一定要有。

看到這裡,應該已經忘了Service Worker是如何被註冊上的吧。沒事,我們看個新的腳本。在原本的基礎上,我們加了個變數SW_FALLBACK,如果離線緩存出問題了,趕緊到管理後臺,把對應的值改成true。讓用戶多刷新兩次就好了。只要不是徹底的崩潰導致html無法更新,這個方案就沒問題。

// 如果有問題,將此值改成true
SW_FALLBACK = false;
 
if ('serviceWorker' in navigator) {
  if (!SW_FALLBACK) {
    navigator.serviceWorker
      .register('/eemf-service-worker.js')
      .then((registration) => {
        console.log('Service Worker 註冊成功!');
      })
      .catch((error) => {
        console.log('Service Worker 註冊失敗:', error);
      });
  } else {
    navigator.serviceWorker.getRegistration('/').then((reg) => {
      reg && reg.unregister();
      if(reg){
        window.location.reload();
      }
    });
  }
}




對於沒有管理後臺配置html的項目,可以將上面的腳本移動到sw-register.js的腳本中,在htmlscript的形式載入該腳本,並將該文件緩存設置為no-cache,也不要在sw中緩存該文件。這樣出問題後,覆寫下該文件即可。

總結

所有要說的,在上面都說完了。PWA的離線方案,是一種很好的解決方案,但是也有其局限性。本項目所用的demo已經上傳到了github,可自行查看。

參考文檔

作者:CHO 張鵬程

來源:京東雲開發者社區 轉載請註明來源


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 註意:本文基於 Android 11 進行分析 Qidi 2023.11.28 (MarkDown & Haroopad) 0. 簡介 Android RO (Resource Overlay) 機制 Overlay 實現的效果正如其字面意思,就是“在原有效果的基礎上再疊加一些效果”。 Androi ...
  • 原文地址:https://www.soughttech.com/front/article/7159/viewArticle 今天我偶然看到了參數slave_exec_mode。從手冊中的描述可以看出,該參數與MySQL複製有關。它是一個可以動態修改的變數。預設為STRICT mode(嚴格模式), ...
  • 一、介紹 單庫瓶頸:如果在項目中使用的都是單MySQL伺服器,則會隨著互聯網及移動互聯網的發展,應用系統的數據量也是成指數式增長,若採用單資料庫進行存儲,存在一下性能瓶頸: IO瓶頸:熱點數據太多,資料庫緩存不足,產生大量磁碟IO,效率低下,請求數據太多,帶寬不夠,網路IO瓶頸。 CPU瓶頸:排序、 ...
  • 1. Performance Schema Lock Tables MySQL安裝以後,我們會看到有這麼兩個資料庫:information_schema 和 performance_schema ,它們對於排查問題是非常有用的。 Performance Schema 是一種存儲引擎,預設情況下,它是 ...
  • 問題 最近有好幾個朋友問,如何將 performance_schema.events_statements_xxx 中的 TIMER 欄位(主要是TIMER_START和TIMER_END)轉換為日期時間。 因為 TIMER 欄位的單位是皮秒(picosecond),所以很多童鞋會嘗試直接轉換,但轉 ...
  • 關聯發散是開發常用的獲取特定彙總數據的方法,但是使用這類方法意味著承擔數據爆炸的風險。本篇通過一個典型案例,給出了“求所有值中大於本行值的最小值”的一個調優方案。 ...
  • 這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 Echarts繪製氣泡圖 氣泡圖是一種用於可視化三維數據的圖表類型,其中兩個變數用於確定數據點在平面上的位置,另一個變數用於確定氣泡的大小。Echarts是一款基於JavaScript的數據可視化庫,它提供了豐富的圖表類型,包括靈活多變的 ...
  • 人在身處逆境時,適應環境的能力實在驚人。人可以忍受不幸,也可以戰勝不幸,因為人有著驚人的潛力,只要立志發揮它,就一定能渡過難關。 Hooks 是 React 16.8 的新增特性。它可以讓你在不編寫 class 組件的情況下使用 state 以及其他的 React 特性。 React Hooks 表 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...