1. 程式人生 > 實用技巧 >PWA(Progressive Web App)

PWA(Progressive Web App)

一、背景

文章2017 前端大事件和趨勢回顧,2018 何去何從?中提到了2017年前端值得關注的十大事件,其中就提到了PWA。

大家都知道Native app體驗確實很好,下載到手機上之後入口也方便。它也有一些缺點:

  • 開發成本高(ios和安卓)
  • 軟體上線需要稽核
  • 版本更新需要將新版本上傳到不同的應用商店
  • 想使用一個app就必須去下載才能使用,即使是偶爾需要使用一下下

而web網頁開發成本低,網站更新時上傳最新的資源到伺服器即可,用手機帶的瀏覽器開啟就可以使用。但是除了體驗上比Native app還是差一些,還有一些明顯的缺點

  • 手機桌面入口不夠便捷,想要進入一個頁面必須要記住它的url或者加入書籤
  • 沒網路就沒響應,不具備離線能力
  • 不像APP一樣能進行訊息推送

那麼什麼是PWA呢?

二、What's PWA?

PWA全稱Progressive Web App,即漸進式WEB應用。

一個 PWA 應用首先是一個網頁, 可以通過 Web 技術編寫出一個網頁應用. 隨後新增上 App Manifest 和 Service Worker 來實現 PWA 的安裝和離線等功能
解決了哪些問題?

  • 可以新增至主螢幕,點選主螢幕圖示可以實現啟動動畫以及隱藏位址列
  • 實現離線快取功能,即使使用者手機沒有網路,依然可以使用一些離線功能
  • 實現了訊息推送

它解決了上述提到的問題,這些特性將使得 Web 應用漸進式接近原生 App。

三、PWA的實現

3.1 Manifest實現新增至主螢幕

index.html

<head>
  <title>Minimal PWA</title>
  <meta name="viewport" content="width=device-width, user-scalable=no" />
  <link rel="manifest" href="manifest.json" />
  <link rel="stylesheet" type="text/css" href="main.css">
  <link 
rel="icon" href="/e.png" type="image/png" /> </head>

manifest.json

{
  "name": "Minimal PWA", // 必填 顯示的外掛名稱
  "short_name": "PWA Demo", // 可選  在APP launcher和新的tab頁顯示,如果沒有設定,則使用name
  "description": "The app that helps you understand PWA", //用於描述應用
  "display": "standalone", // 定義開發人員對Web應用程式的首選顯示模式。standalone模式會有單獨的
  "start_url": "/", // 應用啟動時的url
  "theme_color": "#313131", // 桌面圖示的背景色
  "background_color": "#313131", // 為web應用程式預定義的背景顏色。在啟動web應用程式和載入應用程式的內容之間建立了一個平滑的過渡。
  "icons": [ // 桌面圖示,是一個數組
    {
    "src": "icon/lowres.webp",
    "sizes": "48x48",  // 以空格分隔的圖片尺寸
    "type": "image/webp"  // 幫助userAgent快速排除不支援的型別
  },
  {
    "src": "icon/lowres",
    "sizes": "48x48"
  },
  {
    "src": "icon/hd_hi.ico",
    "sizes": "72x72 96x96 128x128 256x256"
  },
  {
    "src": "icon/hd_hi.svg",
    "sizes": "72x72"
  }
  ]
}

Manifest參考文件:https://developer.mozilla.org/zh-CN/docs/Web/Manifest

可以開啟網站https://developers.google.cn/web/showcase/2015/chrome-dev-summit檢視新增至主螢幕的動圖。

如果用的是安卓手機,可以下載chrome瀏覽器自己操作看看

3.2 service worker實現離線快取

3.2.1 什麼是service worker

Service Worker 是 Chrome 團隊提出和力推的一個 WEB API,用於給 web 應用提供高階的可持續的後臺處理能力。

Service Workers 就像介於伺服器和網頁之間的攔截器,能夠攔截進出的HTTP 請求,從而完全控制你的網站。

最主要的特點

  • 在頁面中註冊並安裝成功後,運行於瀏覽器後臺,不受頁面重新整理的影響,可以監聽和截攔作用域範圍內所有頁面的 HTTP 請求。
  • 網站必須使用 HTTPS。除了使用本地開發環境除錯時(如域名使用 localhost)
  • 運行於瀏覽器後臺,可以控制開啟的作用域範圍下所有的頁面請求
  • 單獨的作用域範圍,單獨的執行環境和執行執行緒
  • 不能操作頁面 DOM。但可以通過事件機制來處理
  • 事件驅動型服務執行緒
為什麼要求網站必須是HTTPS的,大概是因為service worker許可權太大能攔截所有頁面的請求吧,如果http的網站安裝service worker很容易被攻擊

瀏覽器支援情況

瀏覽器支援情況詳見:https://caniuse.com/#feat=serviceworkers

生命週期

當用戶首次導航至 URL 時,伺服器會返回響應的網頁。

  • 第1步:當你呼叫 register() 函式時, Service Worker 開始下載。
  • 第2步:在註冊過程中,瀏覽器會下載、解析並執行 Service Worker ()。如果在此步驟中出現任何錯誤,register() 返回的 promise 都會執行 reject 操作,並且 Service Worker 會被廢棄。
  • 第3步:一旦 Service Worker 成功執行了,install 事件就會啟用
  • 第4步:安裝完成,Service Worker 便會啟用,並控制在其範圍內的一切。如果生命週期中的所有事件都成功了,Service Worker 便已準備就緒,隨時可以使用了!
chrome://serviceworker-internals來了解當前瀏覽器中所有已安裝Service Worker的詳細情況

3.2.2 HTTP快取與service worker快取

  • HTTP快取

Web 伺服器可以使用 Expires 首部來通知 Web 客戶端,它可以使用資源的當前副本,直到指定的“過期時間”。反過來,瀏覽器可以快取此資源,並且只有在有效期滿後才會再次檢查新版本。
使用 HTTP 快取意味著你要依賴伺服器來告訴你何時快取資源和何時過期。

  • service worker快取

Service Workers 的強大在於它們攔截 HTTP 請求的能力
進入任何傳入的 HTTP 請求,並決定想要如何響應。在你的 Service Worker 中,可以編寫邏輯來決定想要快取的資源,以及需要滿足什麼條件和資源需要快取多久。一切盡歸你掌控!

3.2.3 實現離線快取

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Hello Caching World!</title>
  </head>
  <body>
    <!-- Image -->
    <img src="/images/hello.png" />                 
    <!-- JavaScript -->
    <script async src="/js/script.js"></script>     
    <script>
      // 註冊 service worker
      if ('serviceWorker' in navigator) {           
        navigator.serviceWorker.register('/service-worker.js', {scope: '/'}).then(function (registration) {
          // 註冊成功
          console.log('ServiceWorker registration successful with scope: ', registration.scope);
        }).catch(function (err) {                   
          // 註冊失敗 :(
          console.log('ServiceWorker registration failed: ', err);
        });
      }
    </script>
  </body>
</html>

注:Service Worker 的註冊路徑決定了其 scope 預設作用頁面的範圍。
如果 service-worker.js 是在 /sw/ 頁面路徑下,這使得該 Service Worker 預設只會收到 頁面/sw/ 路徑下的 fetch 事件。
如果存放在網站的根路徑下,則將會收到該網站的所有 fetch 事件。
如果希望改變它的作用域,可在第二個引數設定 scope 範圍。示例中將其改為了根目錄,即對整個站點生效。

service-worker.js

var cacheName = 'helloWorld';     // 快取的名稱  
// install 事件,它發生在瀏覽器安裝並註冊 Service Worker 時        
self.addEventListener('install', event => { 
/* event.waitUtil 用於在安裝成功之前執行一些預裝邏輯
 但是建議只做一些輕量級和非常重要資源的快取,減少安裝失敗的概率
 安裝成功後 ServiceWorker 狀態會從 installing 變為 installed */
  event.waitUntil(
    caches.open(cacheName)                  
    .then(cache => cache.addAll([    // 如果所有的檔案都成功快取了,便會安裝完成。如果任何檔案下載失敗了,那麼安裝過程也會隨之失敗。        
      '/js/script.js',
      '/images/hello.png'
    ]))
  );
});
  
/**
為 fetch 事件新增一個事件監聽器。接下來,使用 caches.match() 函式來檢查傳入的請求 URL 是否匹配當前快取中存在的任何內容。如果存在的話,返回快取的資源。
如果資源並不存在於快取當中,通過網路來獲取資源,並將獲取到的資源新增到快取中。
*/
self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.match(event.request)                  
    .then(function (response) {
      if (response) {                            
        return response;                         
      }
      var requestToCache = event.request.clone();  //          
      return fetch(requestToCache).then(                   
        function (response) {
          if (!response || response.status !== 200) {      
            return response;
          }
          var responseToCache = response.clone();          
          caches.open(cacheName)                           
            .then(function (cache) {
              cache.put(requestToCache, responseToCache);  
            });
          return response;             
    })
  );
});

注:為什麼用request.clone()和response.clone()
需要這麼做是因為request和response是一個流,它只能消耗一次。因為我們已經通過快取消耗了一次,然後發起 HTTP 請求還要再消耗一次,所以我們需要在此時克隆請求
Clone the request—a request is a stream and can only be consumed once.

3.2.4 除錯相關

chrome瀏覽器開啟https://googlechrome.github.io/samples/service-worker/basic/index.html,這是一個實現了service worker離線快取功能的網站,開啟除錯工具

介紹一個圖中的1.和2.

  1. 勾選可以模擬網站離線情況,勾選後network會有一個黃色警告圖示,該網站已經離線。此時重新整理頁面,頁面仍然能夠正常顯示
  2. 當前service worker的scope。它能夠攔截https://googlechrome.github.i...,同樣也能夠攔截https://googlechrome.github.i...*/*.html下的請求
除錯面板具體代表的什麼參看https://x5.tencent.com/tbs/guide/serviceworker.html的第三部分

3.3 serice worker實現訊息推送

  • 步驟一、提示使用者並獲得他們的訂閱詳細資訊
  • 步驟二、將這些詳細資訊儲存在伺服器上
  • 步驟三、在需要時傳送任何訊息
不同瀏覽器需要用不同的推送訊息伺服器。以 Chrome 上使用 Google Cloud Messaging<GCM> 作為推送服務為例,第一步是註冊 applicationServerKey(通過 GCM 註冊獲取),並在頁面上進行訂閱或發起訂閱。每一個會話會有一個獨立的端點(endpoint),訂閱物件的屬性(PushSubscription.endpoint) 即為端點值。將端點發送給伺服器後,伺服器用這一值來發送訊息給會話的啟用的 Service Worker (通過 GCM 與瀏覽器客戶端溝通)。

步驟一和步驟二
index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Progressive Times</title>
    <link rel="manifest" href="/manifest.json">                                      
  </head>
  <body>
    <script>
      var endpoint;
      var key;
      var authSecret;
      var vapidPublicKey = 'BAyb_WgaR0L0pODaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY';
      // 方法很複雜,但是可以不用具體看,只是用來轉化vapidPublicKey用
      function urlBase64ToUint8Array(base64String) {                                  
        const padding = '='.repeat((4 - base64String.length % 4) % 4);
        const base64 = (base64String + padding)
          .replace(/\-/g, '+')
          .replace(/_/g, '/');
        const rawData = window.atob(base64);
        const outputArray = new Uint8Array(rawData.length);
        for (let i = 0; i < rawData.length; ++i) {
          outputArray[i] = rawData.charCodeAt(i);
        }
        return outputArray;
      }
      if ('serviceWorker' in navigator) {
        navigator.serviceWorker.register('sw.js').then(function (registration) {
          return registration.pushManager.getSubscription()                            
            .then(function (subscription) {
              if (subscription) {                                                      
                return;
              }
              return registration.pushManager.subscribe({                              
                  userVisibleOnly: true,
                  applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
                })
                .then(function (subscription) {
                  var rawKey = subscription.getKey ? subscription.getKey('p256dh') : '';
                  key = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : '';
                  var rawAuthSecret = subscription.getKey ? subscription.getKey('auth') : '';
                  authSecret = rawAuthSecret ?
                    btoa(String.fromCharCode.apply(null, new Uint8Array(rawAuthSecret))) : '';
                  endpoint = subscription.endpoint;
                  return fetch('./register', {                                         
                    method: 'post',
                    headers: new Headers({
                      'content-type': 'application/json'
                    }),
                    body: JSON.stringify({
                      endpoint: subscription.endpoint,
                      key: key,
                      authSecret: authSecret,
                    }),
                  });
                });
            });
        }).catch(function (err) {
          // 註冊失敗 :(
          console.log('ServiceWorker registration failed: ', err);
        });
      }
    </script>
  </body>
</html>

步驟三 伺服器傳送訊息給service worker

app.js

const webpush = require('web-push');                 
const express = require('express');
var bodyParser = require('body-parser');
const app = express();
webpush.setVapidDetails(                             
  'mailto:[email protected]',
  'BAyb_WgaR0L0pODaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY',
  'p6YVD7t8HkABoez1CvVJ5bl7BnEdKUu5bSyVjyxMBh0'
);
app.post('/register', function (req, res) {           
  var endpoint = req.body.endpoint;
  saveRegistrationDetails(endpoint, key, authSecret); 
  const pushSubscription = {                          
    endpoint: req.body.endpoint,
    keys: {
      auth: req.body.authSecret,
      p256dh: req.body.key
    }
  };
  var body = 'Thank you for registering';
  var iconUrl = 'https://example.com/images/homescreen.png';
  // 傳送 Web 推送訊息
  webpush.sendNotification(pushSubscription,          
      JSON.stringify({
        msg: body,
        url: 'http://localhost:3111/',
        icon: iconUrl
      }))
    .then(result => res.sendStatus(201))
    .catch(err => {
      console.log(err);
    });
});
app.listen(3111, function () {
  console.log('Web push app listening on port 3111!')
});

service worker監聽push事件,將通知詳情推送給使用者

service-worker.js

self.addEventListener('push', function (event) {
 // 檢查服務端是否發來了任何有效載荷資料
  var payload = event.data ? JSON.parse(event.data.text()) : 'no payload';
  var title = 'Progressive Times';
  event.waitUntil(
    // 使用提供的資訊來顯示 Web 推送通知
    self.registration.showNotification(title, {                           
      body: payload.msg,
      url: payload.url,
      icon: payload.icon
    })
  );
});

擴充套件知識:service worker的更新

總結

PWA的優勢

  • 可以將app的快捷方式放置到桌面上,全屏執行,與原生app無異
  • 能夠在各種網路環境下使用,包括網路差和斷網條件下,不會顯示undefind
  • 推送訊息的能力
  • 其本質是一個網頁,沒有原生app的各種啟動條件,快速響應使用者指令

PWA存在的問題

  • 支援率不高:現在ios手機端不支援pwa,IE也暫時不支援
  • Chrome在中國桌面版佔有率還是不錯的,安卓移動端上的佔有率卻很低
  • 各大廠商還未明確支援pwa
  • 依賴的GCM服務在國內無法使用
  • 微信小程式的競爭

儘管有上述的一些缺點,PWA技術仍然有很多可以使用的點。

  • service worker技術實現離線快取,可以將一些不經常更改的靜態檔案放到快取中,提升使用者體驗。
  • service worker實現訊息推送,使用瀏覽器推送功能,吸引使用者
  • 漸進式開發,儘管一些瀏覽器暫時不支援,可以利用上述技術給使用支援瀏覽器的使用者帶來更好的體驗。

轉自:https://segmentfault.com/a/1190000012353473