利用ServiceWorker實現頁面的快速載入和離線訪問

violinux666發表於2019-01-10

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

簡單的來說,ServiceWorker(後文簡稱sw)執行在頁面後臺,使用了sw的頁面可以利用sw來攔截頁面發出的請求,同時配合CacheAPI可以將請求快取到客戶本地

因此我們可以:

  • 將頁面的檔案儲存在客戶端,下次開啟頁面可以不向伺服器發出資源請求,極大的加快頁面載入速度
  • 離線開啟頁面的同時可以在sw發出請求,更新本地的資原始檔
  • 實現離線訪問頁面

但是也存在著一些問題

  • 由於開啟頁面不再向伺服器發出頁面請求,因此當伺服器上的頁面有新版本的時候客戶端無法及時升級
  • sw存在一定的相容性問題

sw-compatible

IE全面撲街,pc上相容性不太好,移動端安卓支援良好,ios要12+。但考慮到sw並不會影響的頁面的正常執行,所以專案上還是能投入生產的。

基本例子

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Sw demo</title>

</head>
<body>
    
</body>
<script src="index.js"></script>
<script>
    if(navigator.serviceWorker){
        navigator.serviceWorker.register('sw.js').then(function(reg){
            if(reg.installing){
                console.log('client-installing');
            }else if(reg.active){
                console.log('client-active')
            }
        })
    }
</script>
</html>
複製程式碼

index.js

document.body.innerHTML="hello world!";
複製程式碼

sw.js

var cacheStorageKey = 'one';

var cacheList = [
    "index.html",
    "index.js"
]
self.addEventListener('install', function (e) {
    console.log('sw-install');
    self.skipWaiting();
})
self.addEventListener('activate', function (e) {
    console.log('sw-activate');
    caches.open(cacheStorageKey).then(function (cache) {
        cache.addAll(cacheList)
    })
    var cacheDeletePromises = caches.keys().then(cacheNames => {
        return Promise.all(cacheNames.map(name => {
            if (name !== cacheStorageKey) {
                // if delete cache,we should post a message to client which can trigger a callback function
                console.log('caches.delete', caches.delete);
                var deletePromise = caches.delete(name);
                send_message_to_all_clients({ onUpdate: true })
                return deletePromise;
            } else {
                return Promise.resolve();
            }
        }));
    });
    e.waitUntil(
        Promise.all([cacheDeletePromises]
        ).then(() => {
            return self.clients.claim()
        })
    )
})
self.addEventListener('fetch', function (e) {
    e.respondWith(
        caches.match(e.request).then(function (response) {
            if (response != null) {
                console.log(`fetch:${e.request.url} from cache`);
                return response
            } else {
                console.log(`fetch:${e.request.url} from http`);
                return fetch(e.request.url)
            }
        })
    )
})
複製程式碼

說明

這樣就完成了一個簡單的sw頁面了,現在通過伺服器訪問頁面html、js資源將直接從客戶端本地讀取,實現頁面的快速開啟和離線訪問

  • 客戶端和sw都有不同的事件回撥,這些事件將在不同的sw生命週期中被觸發,後續會有詳細介紹
  • 首次開啟頁面的時候sw先進行install回撥,執行self.skipWaiting()後將接著執行activate,activate內對快取列表的檔案進行快取
  • cacheStorageKey是快取的識別碼,當cacheStorageKey的值變化,sw的activate會將舊的快取給刪除掉,重新呼叫cache.addAll設定快取
  • sw的fetch事件會攔截頁面發出的請求,將根據快取情況作出不同的處理

生命週期與事件

sw應用的生命週期我簡單抽象為三種

  • 安裝:頁面首次開啟,載入對應的sw檔案
  • 活動:載入過sw檔案後,開啟頁面
  • 更新:當伺服器的sw檔案與客戶端的不一致的時候開啟頁面

客戶端

名稱 installing active
安裝 觸發 不觸發
活動 不觸發 觸發
更新 不觸發 觸發

sw

名稱 install activate fetch
安裝 觸發 觸發 不觸發
活動 不觸發 不觸發 觸發
更新 觸發 觸發 不觸發

總結一下:

  • 客戶端除了在首次開啟的時候觸發installing,其他都是觸發的active
  • sw端活動狀態只執行fetch,安裝和更新狀態只執行install和activate

頁面與sw通訊

通訊方面我之前有翻譯過文章,連結地址,大家感興趣可以看看。這裡我直接展示把封裝好的通訊介面介面

有了通訊介面,我們就可以優化很多事情,比方說在 cacheStorageKey發生變化的時候通知頁面給予客戶一定的響應

客戶端

function send_message_to_sw(msg){
    return new Promise(function(resolve, reject){
        // Create a Message Channel
        var msg_chan = new MessageChannel();
        // Handler for recieving message reply from service worker
        msg_chan.port1.onmessage = function(event){
            if(event.data.error){
                reject(event.data.error);
            }else{
                resolve(event.data);
            }
        };
        // Send message to service worker along with port for reply
        navigator.serviceWorker.controller.postMessage(msg, [msg_chan.port2]);
    });
}
複製程式碼

sw

function send_message_to_client(client, msg){
  return new Promise(function(resolve, reject){
      var msg_chan = new MessageChannel();
      msg_chan.port1.onmessage = function(event){
          if(event.data.error){
              reject(event.data.error);
          }else{
              resolve(event.data);
          }
      };
      client.postMessage(msg, [msg_chan.port2]);
  });
}
function send_message_to_all_clients(msg){
  clients.matchAll().then(clients => {
      clients.forEach(client => {
          send_message_to_client(client, msg).then(m => console.log("SW Received Message: "+m));
      })
  })
}
複製程式碼

動態快取資原始檔

上述的做法需要事先寫好cacheList,有一定的維護量,現在介紹一種不需要維護cacheList的做法:

self.addEventListener('fetch', function (e) {
  e.respondWith(
    caches.match(e.request).then(res => {
      return res ||
        fetch(e.request)
          .then(res => {
            const resClone = res.clone();
            caches.open(cacheStorageKey).then(cache => {
              cache.put(e.request, resClone);
            })
            return res;
          })
    })
  )
});
複製程式碼

這樣做的話快取資源的操作將從activate轉移到fetch事件內,fetch事件先判斷有沒有快取,沒有快取的話將發出對應的請求並進行快取

這樣的做法的缺點是無法在首次載入頁面的時候就完成靜態化,因為sw的安裝宣告週期是不會觸發sw的fetch事件的。

頁面url帶引數

針對一些頁面渲染結果與url引數有關的情況,上述的架構無法完成對應的本地化需求。之前的做法是在cacheList加入了入口頁面的地址,無法適應帶動態引數url的情況。

在fetch內動態快取請求

具體做法在動態快取資原始檔章節有描述,不再重複描述。

使用通訊介面通知sw快取入口頁面

客戶端

navigator.serviceWorker.register(file).then(function (reg) {
    if (reg.installing) {
        //send_message_to_sw({pageUrl:location.href})
    }
    if (reg.active) {
        send_message_to_sw({pageUrl:location.href})
    }
    return reg;
})
複製程式碼

sw

self.addEventListener('message',function(e){
  var data=e.data;
  if(data.pageUrl){
    addPage(data.pageUrl)
  }
})
function addPage(page){
  caches.open(cacheStorageKey).then(function(cache){
    cache.add(page);
  })
}
複製程式碼

在客戶端的active發訊息給sw,sw就能夠獲取到對應的頁面url進行快取。

:客戶端的installing事件沒法使用訊息介面,大家可以在sw的activate事件向客戶端發出一個訊息請求獲取當前頁面url

常見問題

sw檔案至少要與入口頁面檔案在同一目錄下,如:

  • /sw.js 可以管理 /index.html
  • /js/sw.js 不能管理 /index.html

筆者在這裡踩了很久的坑...

webpack-sw-plugin

介紹一個筆者寫的webpack的sw外掛,在弄sw頁面的時候很方便,github地址

安裝

npm install --save-dev webpack-sw-plugin
複製程式碼

webpack配置

const WebpackSWPlugin = require('webpack-sw-plugin');
module.exports = {
    // entry
    // output
    plugins:[
        new WebpackSWPlugin()
    ]
}
複製程式碼

客戶端配置

import worker from 'webpack-sw-plugin/lib/worker';
worker.register({
    onUpdate:()=>{
        console.log('client has a new version. page will refresh in 5s....');
        setTimeout(function(){
            window.location.reload();
        },5000)
    }
});
複製程式碼

效果

  • 自動生成頁面與sw互動的體系,無需提供額外的sw檔案
  • 自動適配url帶引數的情況
  • 當webpack輸出檔案變化的時候,客戶端的onUpdate將會被觸發,上述例子中當輸出檔案變化時,客戶端將會在5秒後進行重新整理,重新整理後將會使用全新的檔案

相關文章