PWA學(xué)習(xí)總結(jié)

簡單介紹

PWA(Progressive Web App)漸進(jìn)式Web APP,它并不是單只某一項技術(shù),而是一系列技術(shù)綜合應(yīng)用的結(jié)果,其中主要包含的相關(guān)技術(shù)就是Service Worker、Cache Api、Fetch Api、Push API、Notification API 和 postMessage API。使用PWA可以給我們帶來什么好處呢?主要體現(xiàn)在如下幾方面

1 離線緩存
2 web頁面添加桌面快速入口
3 消息推送

相關(guān)知識

Service Worker

簡單來說,Service Worker 是一個可編程的 Web Worker,它就像一個位于瀏覽器與網(wǎng)絡(luò)之間的客戶端代理,可以攔截、處理、響應(yīng)流經(jīng)的 HTTP 請求。它沒有調(diào)用 DOM 和其他頁面 api 的能力,但他可以攔截網(wǎng)絡(luò)請求,包括頁面切換,靜態(tài)資源下載,ajax請求所引起的網(wǎng)絡(luò)請求。Service Worker 是一個獨(dú)立于JavaScript主線程的瀏覽器線程。Service Worker有如下特性:

  • 必須在 HTTPS 環(huán)境下才能工作(在開發(fā)模式下http://localhost也可以工作)
  • 不能直接操作 DOM,(但是可以通過postMessage發(fā)送某些信號,主進(jìn)程根據(jù)信號類型,進(jìn)行不同的操作)
  • 一個獨(dú)立的 worker 線程,獨(dú)立于當(dāng)前網(wǎng)頁進(jìn)程,有自己獨(dú)立的 worker context。
  • 運(yùn)行于瀏覽器后臺,可以控制打開的作用域范圍下所有的頁面請求
  • Service Worker 必須要在主線中進(jìn)行注冊
  • 一旦被 install,就永遠(yuǎn)存在,除非被手動 unregister
  • 用到的時候可以直接喚醒,不用的時候自動睡眠

注冊Service Work

我們需要在主線程中注冊Service Worker,并且一般是在頁面觸發(fā)load事件之后進(jìn)行注冊。當(dāng)Service Worker注冊成功后便會進(jìn)入其生命周期。scope代表Service Worker控制該路徑下的所有請求,如果請求路徑不是在該路徑之下,則請求不會被攔截。

// 注冊service worker
window.addEventListener('load', function () {
  navigator.serviceWorker.register('/sw.js', {scope: '/'})
    .then(function (registration) {

      // 注冊成功
      console.log('ServiceWorker registration successful with scope: ', registration.scope);
    })
    .catch(function (err) {

      // 注冊失敗:(
      console.log('ServiceWorker registration failed: ', err);
    });
});

Service Worker生命周期

Service Worker生命周期大致如下

install -> installed -> actvating -> Active -> Activated -> Redundant

Service Worker生命周期圖

在Service Worker注冊成功之后就會觸發(fā)install事件,在觸發(fā)install事件后,我們就可以開始緩存一些靜態(tài)資。waitUntil方法確保所有代碼執(zhí)行完畢后,Service Worker 才會完成Service Worker的安裝。需要注意的是只有CACHE_LIST中的資源全部安裝成功后,才會完成安裝,否則失敗,進(jìn)入redundant狀態(tài),所以這里的靜態(tài)資源最好不要太多。如果 sw.js 文件的內(nèi)容有改動,當(dāng)訪問網(wǎng)站頁面時瀏覽器獲取了新的文件,它會認(rèn)為有更新,于是會安裝新的文件并觸發(fā) install 事件。但是此時已經(jīng)處于激活狀態(tài)的舊的 Service Worker 還在運(yùn)行,新的 Service Worker 完成安裝后會進(jìn)入 waiting 狀態(tài)。直到所有已打開的頁面都關(guān)閉,舊的 Service Worker 自動停止,新的 Service Worker 才會在接下來打開的頁面里生效。為了能夠讓新的Service Worker及時生效,我們使用skipWaiting直接使Service Worker跳過等待時期,從而直接進(jìn)入下一個階段。

const CACHE_NAME = 'cache_v' + 2;
const CACGE_LIST = [
  '/',
  '/index.html',
  '/main.css',
  '/app.js',
  '/icon.png'
];

function preCache() {
  // 安裝成功后操作 CacheStorage 緩存,使用之前需要先通過 caches.open() 打開對應(yīng)緩存空間。
  return caches.open(CACHE_NAME).then(cache => {
    // 通過 cache 緩存對象的 addAll 方法添加 precache 緩存
    return cache.addAll(CACGE_LIST);
  })
}

// 安裝
self.addEventListener('install', function (event) {
  // 等待promise執(zhí)行完
  event.waitUntil(
    // 如果上一個serviceWorker不銷毀 需要手動skipWaiting()
    preCache().then(skipWaiting)
  );
});

在安裝成功后,便會觸發(fā)activate事件,在進(jìn)入這個生命周期后,我們一般會刪除掉之前已經(jīng)過期的版本(因為默認(rèn)情況下瀏覽器是不會自動刪除過期的版本的),并更新客戶端Service Worker(使用當(dāng)前處于激活狀態(tài)的Service Worker)。

// 刪除過期緩存
function clearCache() {
  return caches.keys().then(keys => {
    return Promise.all(keys.map(key => {
      if (key !== CACHE_NAME) {
        return caches.delete(key);
      }
    }))
  })
}

// 激活 activate 事件中通常做一些過期資源釋放的工作
self.addEventListener('activate', function (e) {
  e.waitUntil(
    Promise.all([
      clearCache(),
      self.clients.claim()
    ])
  );
});

在這里還有一個問題就是sw.js文件有可能會被瀏覽器緩存,所以我們一般需要設(shè)置sw.js不緩存或者較短的緩存時間
更多詳細(xì)參考 如何優(yōu)雅的為 PWA 注冊 Service Worker

Service Worker 攔截請求

之前說過,Service Worker 是可以攔截請求的,那么一定就會存在一個攔截請求的事件fetch。我們需要在sw.js去監(jiān)聽這個事件。

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open(CACHE_NAME).then(cache => {
      return cache.match(event.request).then(function (response) {

        // 如果 Service Worker 有自己的返回,就直接返回,減少一次 http 請求
        if (response) {
          console.log('cache 緩存', event.request.url, response);
          return response;
        } else {
            
            if (navigator.online) {
            
                return fetch(event.request).then(function(response) {
                    console.log('network', event.request.url, response);
            // 由于響應(yīng)是一個JavaScript或者HTML,會認(rèn)為這個響應(yīng)為一個流,而流是只能被消費(fèi)一次的,所以只能被讀一次
            // 第二次就會報錯 參考文章https://jakearchibald.com/2014/reading-responses/
            cache.put(event.request, response.clone());
            return response;
          }).catch(function(error) {
            console.error('請求失敗', error);
            throw error;
          });
          
            } else {
                // 斷網(wǎng)處理
                offlineRequest(fetchRequest);
            }
          
        }
      });
    })
  );
});

這里我們在fetch事件中監(jiān)聽請求事件,我們通過cache.match來進(jìn)行請求的比較,如果存再這個請求的響應(yīng)我們就直接返回緩存結(jié)果,否則就去請求。在這里我們通過cache.add來添加新的緩存,他實際上內(nèi)部是包含了fetch請求過程的(注意:Cache.put, Cache.add和Cache.addAll只能在GET請求下使用)。在match的時候,需要請求的url和header都一致才是相同的資源,可以設(shè)定第二個參數(shù)ignoreVary:true。caches.match(event.request, {ignoreVary: true})
表示只要請求url相同就認(rèn)為是同一個資源。另外需要提到一點(diǎn),F(xiàn)etch 請求默認(rèn)是不附帶 Cookies 等信息的,在請求靜態(tài)資源上這沒有問題,而且節(jié)省了網(wǎng)絡(luò)請求大小。但對于動態(tài)頁面,則可能會因為請求缺失 Cookies 而存在問題。此時可以給 Fetch 請求設(shè)置第二個參數(shù)。示例:fetch(fetchRequest, { credentials: 'include' } );

Cache API

Cache API 不僅在Service Worker中可以使用,在主頁面中也可以使用。我們通過 caches.open(cacheName)來打開一個緩存空間,在,默認(rèn)情況下,如果我們不手動去清除這個緩存空間,這個緩存會一直存在,不會過期。在使用Cache API之前,我們都需要通過caches.open先去打開這個緩存空間,然后在使用相應(yīng)的Cache方法。這里有幾個注意點(diǎn):

  • Cache.put, Cache.add和Cache.addAll只能在GET請求下使用
  • 自Chrome 46版本起,Cache API只保存安全來源的請求,即那些通過HTTPS服務(wù)的請求。
  • Cache API不支持HTTP緩存頭

在使用cache.add和cache.addAll的時候,是先根據(jù)url獲取到相應(yīng)的response,然后再添加到緩存中。過程類似于調(diào)用 fetch(), 然后使用 Cache.put() 將response添加到cache中

詳細(xì)MDN文檔

Fetch API

Fetch API不僅可以在主線程中進(jìn)行使用,也可以在Service Worker中進(jìn)行使用。fetch 和 XMLHttpRequest有兩種方式不同:

  • 當(dāng)接收到一個代表錯誤的 HTTP 狀態(tài)碼時,從 fetch()返回的 Promise 不會被標(biāo)記為 reject, 即使該 HTTP 響應(yīng)的狀態(tài)碼是 404 或 500。相反,它會將 Promise 狀態(tài)標(biāo)記為 resolve (但是會將 resolve 的返回值的 ok 屬性設(shè)置為 false ),僅當(dāng)網(wǎng)絡(luò)故障時或請求被阻止時,才會標(biāo)記為 reject。

  • 默認(rèn)情況下,fetch 不會從服務(wù)端發(fā)送或接收任何 cookies, 如果站點(diǎn)依賴于用戶 session,則會導(dǎo)致未經(jīng)認(rèn)證的請求(要發(fā)送 cookies,必須設(shè)置 credentials 選項)


// Example POST method implementation:

postData('http://example.com/answer', {answer: 42})
  .then(data => console.log(data)) // JSON from `response.json()` call
  .catch(error => console.error(error))

function postData(url, data) {
  // Default options are marked with *
  return fetch(url, {
    body: JSON.stringify(data), // must match 'Content-Type' header
    cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
    credentials: 'same-origin', // include(始終攜帶), same-origin(同源攜帶cookie), omit(始終不攜帶)
    headers: {
      'user-agent': 'Mozilla/4.0 MDN Example',
      'content-type': 'application/json'
    },
    method: 'POST', // *GET, POST, PUT, DELETE, etc.
    mode: 'cors', // no-cors, cors, *same-origin
    redirect: 'follow', // manual, *follow, error
    referrer: 'no-referrer', // *client, no-referrer
  })
  .then(response => response.json()) // parses response to JSON
}

更多信息請查閱:使用 Fetch

Notification

Notification API 用來進(jìn)行瀏覽器通知,當(dāng)用戶允許時,瀏覽器就可以彈出通知。這個API在主頁面和Service Worker中都可以使用,MDN文檔

  • 在主頁面中使用

// 先檢查瀏覽器是否支持
  if (!("Notification" in window)) {
    alert("This browser does not support desktop notification");
  }

  // 檢查用戶是否同意接受通知
  else if (Notification.permission === "granted") {
    // If it's okay let's create a notification
    new Notification(title, {
      body: desc,
      icon: '/icon.png',
      requireInteraction: true
    });
  }

  // 否則我們需要向用戶獲取權(quán)限
  else if (Notification.permission !== 'denied') {
    Notification.requestPermission(function (permission) {
      // 如果用戶同意,就可以向他們發(fā)送通知
      if (permission === "granted") {
        new Notification(title, {
          body: desc,
          icon: '/icon.png',
          requireInteraction: true
        });
      } else {
        console.warn('用戶拒絕通知');
      }
    });
  }

  • 在Service Worker中使用

// 發(fā)送 Notification 通知
function sendNotify(title, options={}, event) {

  if (Notification.permission !== 'granted') {
    console.log('Not granted Notification permission.');

    // 通過post一個message信號量,來在主頁面中詢問用戶獲取頁面通知權(quán)限
    postMessage({
      type: 'applyNotify'
    })
  } else {

    // 在Service Worker 中 觸發(fā)一條通知
    self.registration.showNotification(title || 'Hi:', Object.assign({
      body: '這是一個通知示例',
      icon: '/icon.png',
      requireInteraction: true
    }, options));
  }
  
}

我們可以看見當(dāng)我們在Service Worker中進(jìn)行消息提示時,用戶可能關(guān)閉了消息提示的功能,所以我們首先要再次詢問用戶是否開啟消息提示的功能,但是在Service Worker中是不能夠直接詢問用戶的,我們必須要在主頁面中去詢問,這個時候我們可以通過postMessage去發(fā)送一個信號量,根據(jù)這個信號量的類型,來做響應(yīng)的處理(例如:詢問消息提示的權(quán)限,DOM操作等等)


function postMessage(data) {
  self.clients.matchAll().then(clientList => {
    clientList.forEach(client => {
      // 當(dāng)前打開的標(biāo)簽頁發(fā)送消息
      if (client.visibilityState === 'visible') {
        client.postMessage(data);
      }
    })
  })
}

在這里我們只向打開的標(biāo)簽頁發(fā)送該信號量,避免重復(fù)詢問

message 事件

由于Service Worker是一個單獨(dú)的瀏覽器線程,與JavaScript主線程互不干擾,但是我們還是可以通過postMessage實現(xiàn)通信,而且可以通過post特定的消息,從而讓主線程去進(jìn)行相應(yīng)的DOM操作,實現(xiàn)間接操作DOM的方式。

  • 頁面發(fā)送消息給Service Worker
    在頁面上通過 navigator.serviceWorker.controller 獲得 ServiceWorker 的句柄。但只有 ServiceWorker 注冊成功后該句柄才會存在。

function sendMsg(msg) {
    const controller = navigator.serviceWorker.controller;

    if (!controller) {
        return;
    }

    controller.postMessage(msg, []);
}

// 在 serviceWorker 注冊成功后,頁面上即可通過 navigator.serviceWorker.controller 發(fā)送消息給它
navigator.serviceWorker
    .register('/test/sw.js', {scope: '/test/'})
    .then(registration => console.log('ServiceWorker 注冊成功!作用域為: ', registration.scope))
    .then(() => sendMsg('hello sw!'))
    .catch(err => console.log('ServiceWorker 注冊失敗: ', err));
    

在 ServiceWorker 內(nèi)部,可以通過監(jiān)聽 message 事件即可獲得消息:


self.addEventListener('message', function(ev) {
    console.log(ev.data);
});
  • Service Worker發(fā)送消息給頁面

// self.clients.matchAll方法獲取當(dāng)前serviceWorker實例所接管的所有標(biāo)簽頁,注意是當(dāng)前實例 已經(jīng)接管的
self.clients.matchAll().then(clientList => {
    clientList.forEach(client => {
        client.postMessage('Hi, I am send from Service worker!');
    })
});

在主頁面中監(jiān)聽

navigator.serviceWorker.addEventListener('message', event => {
  console.log(event.data);
}); 

Client.postMessage

manifest

3 manifest.json 作用
PWA 添加至桌面的功能實現(xiàn)依賴于 manifest.json,也就是說如果要實現(xiàn)添加至主屏幕這個功能,就必須要有這個文件

{
  "short_name": "短名稱",
  "name": "這是一個完整名稱",
  "icons": [
  {
    "src": "icon.png",
    "type": "image/png",
    "sizes": "144x144"
  }
],
  "start_url": "index.html"
}

<link rel="manifest" href="path-to-manifest/manifest.json">

name —— 網(wǎng)頁顯示給用戶的完整名稱

short_name —— 當(dāng)空間不足以顯示全名時的網(wǎng)站縮寫名稱

description —— 關(guān)于網(wǎng)站的詳細(xì)描述

start_url —— 網(wǎng)頁的初始 相對 URL(比如 /)

scope —— 導(dǎo)航范圍。比如,/app/的scope就限制 app 在這個文件夾里。

background-color —— 啟動屏和瀏覽器的背景顏色

theme_color —— 網(wǎng)站的主題顏色,一般都與背景顏色相同,它可以影響網(wǎng)站的顯示

orientation —— 首選的顯示方向:any, natural, landscape, landscape-primary, landscape-secondary, portrait, portrait-primary, 和 portrait-secondary。

display —— 首選的顯示方式:fullscreen, standalone(看起來像是native app),minimal-ui(有簡化的瀏覽器控制選項) 和 browser(常規(guī)的瀏覽器 tab)

icons —— 定義了 src URL, sizes和type的圖片對象數(shù)組。

詳細(xì)配置

MDN詳細(xì)配置

manifest驗證

相關(guān)問題

  • 對于不同的資源,我們可能有不同的緩存策略,怎么方便的去實現(xiàn)這些復(fù)雜的場景

使用workbox,如果使用webpack進(jìn)行項目打包,我們可以使用workbox-webpack-plugin插件

  • 為什么不適用其他的本地緩存方案

Web Storage(例如 LocalStorage 和 SessionStorage)是同步的,不支持網(wǎng)頁工作線程,并對大小和類型(僅限字符串)進(jìn)行限制。 Cookie 具有自身的用途,但它們是同步的,缺少網(wǎng)頁工作線程支持,同時對大小進(jìn)行限制。WebSQL 不具有廣泛的瀏覽器支持,因此不建議使用它。File System API 在 Chrome 以外的任意瀏覽器上都不受支持。目前正在 File and Directory Entries API 和 File API 規(guī)范中改進(jìn) File API,但該 API 還不夠成熟也未完全標(biāo)準(zhǔn)化,因此無法被廣泛采用。

同步的問題 就是負(fù)擔(dān)大,如果有大量請求緩存在本地緩存中,如果是同步,可能負(fù)擔(dān)重

  • 在將相應(yīng)存在cache中并返回給瀏覽器報錯

resulted in a network error response: a Response whose "body" is locked cannot be used to respond to a request

這是因為在使用put的時候,是流的一個pipe操作,流是只能被消費(fèi)一次的。我們可以clone這個response或者reques參考文章

  • 在經(jīng)過webpack打包后,所有的靜態(tài)資源都會帶有hash值,怎么辦

使用某些webpack插件,例如offline-plugin或者webpack-workbox-plugin

代碼示例

pwa-study

pwa-webpack-study

參考資料

最后(歡迎大家關(guān)注我)

DJL簫氏個人博客

博客GitHub地址(歡迎star)

簡書

掘金

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容