1. 程式人生 > >Web離線應用解決方案——ServiceWorker

Web離線應用解決方案——ServiceWorker

什麼是ServiceWorker?

 在介紹ServiceWorker之前,我們先來談談PWA。PWA (Progressive Web Apps) 是一種 Web App 新模型,並不是具體指某一種前沿的技術或者某一個單一的知識點,,這是一個漸進式的 Web App,是通過一系列新的 Web 特性,配合優秀的 UI 互動設計,逐步的增強 Web App 的使用者體驗。

  • Https環境部署
  • 響應式設計,一次部署,可以在移動裝置和 PC 裝置上執行 在不同瀏覽器下可正常訪問。
  • 瀏覽器離線和弱網環境可極速訪問。
  • 可以把 App Icon 入口新增到桌面。
  • 點選 Icon 入口有類似 Native App 的動畫效果。
  • 靈活的熱更新

  在PWA要求的各種能力上,關於離線環境的支援我們就需要仰賴ServiceWorker。Service workers 本質上充當Web應用程式與瀏覽器之間的代理伺服器,也可以在網路可用時作為瀏覽器和網路間的代理。它們旨在(除其他之外)使得能夠建立有效的離線體驗,攔截網路請求並基於網路是否可用以及更新的資源是否駐留在伺服器上來採取適當的動作。由於PWA是谷歌提出,那麼對ServiceWorker,同樣也提出一些能力要求:

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

  在目前階段,ServiceWorker的主要能力集中在網路代理和離線快取上。具體的實現上,可以理解為ServiceWorker是一個能在網頁關閉時仍然執行的WebWorker

ServiceWorker的生命週期

剛才講到ServiceWorker擁有離線能力的WebWorker,既然這麼強的能力,那就需要好好管理起來。所以我們要明白ServiceWorker的生命週期,也就是它從建立到銷燬的過程。在所有介紹ServiceWorker生命週期的文章中最常見的就是下面這張圖。

整個過程中一個ServiceWorker會經歷:安裝、啟用、等待、銷燬的階段。但實際上這張圖我感覺並沒有清晰的解釋ServiceWorker的宣告週期,所以我製作了下面這張圖。

 這張圖把ServiceWorker的宣告週期分為了兩部分,主執行緒中的狀態和ServiceWorker子執行緒中的狀態。子執行緒中的程式碼處在一個單獨的模組中,當我們需要使用ServiceWorker時,按照如下的方式來載入:

if (navigator.serviceWorker != null) {
      // 使用瀏覽器特定方法註冊一個新的service worker
      navigator.serviceWorker.register('sw.js')
      .then(function(registration) {
        window.registration = registration;
        console.log('Registered events at scope: ', registration.scope);
      });
    }

這個時候ServiceWorker處於Parsed解析階段。當解析完成後ServiceWorker處於Installing安裝階段,主執行緒的registration的installing屬性代表正在安裝的ServiceWorker例項,同時子執行緒中會觸發install事件,並在install事件中指定快取資源

var cacheStorageKey = 'minimal-pwa-3';

var cacheList = [
  '/',
  "index.html",
  "main.css",
  "e.png",
  "pwa-fonts.png"
]

// 當瀏覽器解析完sw檔案時,serviceworker內部觸發install事件
self.addEventListener('install', function(e) {
  console.log('Cache event!')
  // 開啟一個快取空間,將相關需要快取的資源新增到快取裡面
  e.waitUntil(
    caches.open(cacheStorageKey).then(function(cache) {
      console.log('Adding to Cache:', cacheList)
      return cache.addAll(cacheList)
    })
  )
})

這裡使用了Cache API來將資源快取起來,同時使用e.waitUntil接手一個Promise來等待資源快取成功,等到這個Promise狀態成功後,ServiceWorker進入installed狀態,意味著安裝完畢。這時候主執行緒中返回的registration.waiting屬性代表進入installed狀態的ServiceWorker。

/* In main.js */
navigator.serviceWorker.register('./sw.js').then(function(registration) {  
    if (registration.waiting) {
        // Service Worker is Waiting
    }
})

個ServiceWorker會立馬進入下一個階段,除非之前沒有新的ServiceWorker例項,如果之前已有ServiceWorker,這個版本只是對ServiceWorker進行了更新,那麼需要滿足如下任意一個條件,新的ServiceWorker才會進入下一個階段:

  • 在新的ServiceWorker執行緒程式碼裡,使用了self.skipWaiting() 
  • 或者當用戶導航到別的網頁,因此釋放了舊的ServiceWorker時候
  • 或者指定的時間過去後,釋放了之前的ServiceWorker

  這個時候ServiceWorker的生命週期進入Activating階段,ServiceWorker子執行緒接收到activate事件:

// 如果當前瀏覽器沒有啟用的service worker或者已經啟用的worker被解僱,
// 新的service worker進入active事件
self.addEventListener('activate', function(e) {
  console.log('Activate event');
  console.log('Promise all', Promise, Promise.all);
  // active事件中通常做一些過期資源釋放的工作
  var cacheDeletePromises = caches.keys().then(cacheNames => {
    console.log('cacheNames', cacheNames, cacheNames.map);
    return Promise.all(cacheNames.map(name => {
      if (name !== cacheStorageKey) { // 如果資源的key與當前需要快取的key不同則釋放資源
        console.log('caches.delete', caches.delete);
        var deletePromise = caches.delete(name);
        console.log('cache delete result: ', deletePromise);
        return deletePromise;
      } else {
        return Promise.resolve();
      }
    }));
  });

  console.log('cacheDeletePromises: ', cacheDeletePromises);
  e.waitUntil(
    Promise.all([cacheDeletePromises]
    )
  )
})

這個時候通常做一些快取清理工作,當e.waitUntil接收的Promise進入成功狀態後,ServiceWorker的生命週期則進入activated狀態。這個時候主執行緒中的registration的active屬性代表進入activated狀態的ServiceWorker例項

/* In main.js */
navigator.serviceWorker.register('./sw.js').then(function(registration) {  
    if (registration.active) {
        // Service Worker is Active
    }
})

到此一個ServiceWorker正式進入啟用狀態,可以攔截網路請求了。如果主執行緒有fetch方式請求資源,那麼就可以在ServiceWorker程式碼中觸發fetch事件:

fetch('./data.json')

這時在子執行緒就會觸發fetch事件:

self.addEventListener('fetch', function(e) {
  console.log('Fetch event ' + cacheStorageKey + ' :', e.request.url);
  e.respondWith( // 首先判斷快取當中是否已有相同資源
    caches.match(e.request).then(function(response) {
      if (response != null) { // 如果快取中已有資源則直接使用
        // 否則使用fetch API請求新的資源
        console.log('Using cache for:', e.request.url)
        return response
      }
      console.log('Fallback to fetch:', e.request.url)
      return fetch(e.request.url);
    })
  )
})

 然而這個時候並不意味著這那麼如果在install或者active事件中失敗,ServiceWorker則會直接進入Redundant狀態,瀏覽器會釋放資源銷燬ServiceWorker。
  現在如果沒有網路進入離線狀態,或者資源命中快取那麼就會優先讀取快取的資源:

快取資源更新

      那麼如果我們在新版本中更新了ServiceWorker子執行緒程式碼,當訪問網站頁面時瀏覽器獲取了新的檔案,逐位元組比對 /sw.js 檔案發現不同時它會認為有更新啟動 更新演算法open_in_new,於是會安裝新的檔案並觸發 install 事件。但是此時已經處於啟用狀態的舊的 Service Worker 還在執行,新的 Service Worker 完成安裝後會進入 waiting 狀態。直到所有已開啟的頁面都關閉,舊的 Service Worker 自動停止,新的 Service Worker 才會在接下來重新開啟的頁面裡生效。如果想要立即更新需要在新的程式碼中做一些處理。首先在install事件中呼叫self.skipWaiting()方法,然後在active事件中呼叫self.clients.claim()方法通知各個客戶端。 

// 當瀏覽器解析完sw檔案時,serviceworker內部觸發install事件
self.addEventListener('install', function(e) {
  debugger;
  console.log('Cache event!')
  // 開啟一個快取空間,將相關需要快取的資源新增到快取裡面
  e.waitUntil(
    caches.open(cacheStorageKey).then(function(cache) {
      console.log('Adding to Cache:', cacheList)
      return cache.addAll(cacheList)
    }).then(function() {
      console.log('install event open cache ' + cacheStorageKey);
      console.log('Skip waiting!')
      return self.skipWaiting();
    })
  )
})

// 如果當前瀏覽器沒有啟用的service worker或者已經啟用的worker被解僱,
// 新的service worker進入active事件
self.addEventListener('activate', function(e) {
  debugger;
  console.log('Activate event');
  console.log('Promise all', Promise, Promise.all);
  // active事件中通常做一些過期資源釋放的工作
  var cacheDeletePromises = caches.keys().then(cacheNames => {
    console.log('cacheNames', cacheNames, cacheNames.map);
    return Promise.all(cacheNames.map(name => {
      if (name !== cacheStorageKey) { // 如果資源的key與當前需要快取的key不同則釋放資源
        console.log('caches.delete', caches.delete);
        var deletePromise = caches.delete(name);
        console.log('cache delete result: ', deletePromise);
        return deletePromise;
      } else {
        return Promise.resolve();
      }
    }));
  });

  console.log('cacheDeletePromises: ', cacheDeletePromises);
  e.waitUntil(
    Promise.all([cacheDeletePromises]
    ).then(() => {
      console.log('activate event ' + cacheStorageKey);
      console.log('Clients claims.')
      return self.clients.claim();
    })
  )
})

注意這裡說的是瀏覽器獲取了新版本的ServiceWorker程式碼,如果瀏覽器本身對sw.js進行快取的話,也不會得到最新程式碼,所以對sw檔案最好配置成cache-control: no-cache或者新增md5。

實際過程中像我們剛才把index.html也放到了快取中,而在我們的fetch事件中,如果快取命中那麼直接從快取中取,這就會導致即使我們的index頁面有更新,瀏覽器獲取到的永遠也是都是之前的ServiceWorker快取的index頁面,所以有些ServiceWorker框架支援我們配置資源更新策略,比如我們可以對主頁這種做策略,首先使用網路請求獲取資源,如果獲取到資源就使用新資源,同時更新快取,如果沒有獲取到則使用快取中的資源。程式碼如下:

self.addEventListener('fetch', function(e) {
  console.log('Fetch event ' + cacheStorageKey + ' :', e.request.url);
  e.respondWith( // 該策略先從網路中獲取資源,如果獲取失敗則再從快取中讀取資源
    fetch(e.request.url)
    .then(function (httpRes) {

      // 請求失敗了,直接返回失敗的結果
      if (!httpRes || httpRes.status !== 200) {
          // return httpRes;
          return caches.match(e.request)
      }

      // 請求成功的話,將請求快取起來。
      var responseClone = httpRes.clone();
      caches.open(cacheStorageKey).then(function (cache) {
          return cache.delete(e.request)
          .then(function() {
              cache.put(e.request, responseClone);
          });
      });

      return httpRes;
    })
    .catch(function(err) { // 無網路情況下從快取中讀取
      console.error(err);
      return caches.match(e.request);
    })
  )
})

注意事項

ServiceWorker是一項新能力,目前IOS平臺對他的支援性並不友好,但是在安卓側已經沒有大問題。而微信平臺對它的支援也不錯。

 依賴項:

  • 依賴Cache API
  • 依賴Fetch API Promise API
  • Https環境

錯誤排查:

  • install或active事件失敗
  • 非Https環境
  • sw.js安裝路徑問題
  • scope設定

  同時這裡我也為大家錄製視訊,可以更清晰的看到這些細節。

轉發地址