將你的部落格升級為 PWA 漸進式Web離線應用

  • 2020 年 1 月 21 日
  • 筆記

什麼是 PWA

PWA 全稱 Progressive Web Apps(漸進式 Web 應用程式),旨在使用現有的 Web 技術提供用戶更優的使用體驗。 基本要求

  • 可靠(Reliable) 一方面是指 PWA 的安全性,PWA 只能運行在 HTTPS 上;另一方面是指在網路不穩定或者沒網情況下,PWA 依然可以訪問。
  • 快速響應(Fast) 快速響應用戶的交互行為,並且具有平滑流暢的動畫、載入速度、渲染速度和渲染性能等。
  • 粘性(Engaging) 通過添加到桌面以及離線消息推送,能帶來用戶的第二次訪問,並且依靠良好的用戶體驗吸引用戶再次訪問。

官網鏈接:Progressive Web Apps

PWA 的核心技術

PWA 不是一項單獨的技術,技術包括 Manifest、Service Worker、Push API & Notification API 、App Shell & App Skeleton 等等技術,接下來我們重點介紹幾項技術以及相關問題的解決方法。

manifest

manifest 是支援站點在主屏上創建圖標的技術方案,並且訂製 PWA 的啟動畫面的圖標和顏色等,如下圖:

chrome > 桌面圖標 > 啟動樣式 > 打開效果

manifest 內容

{    "name": "夢魘小棧-專註於分享",    "short_name": "夢魘小棧",    "description": "心,若沒有棲息的地方,到哪裡都是流浪......",    "start_url": "/",    "display": "standalone",    "orientation": "any",    "background_color": "#ffffff",    "theme_color": "#8a00f9",    "icons": [      {        "src": "images/icons/icon_32.png",        "sizes": "32x32",        "type": "image/png"      },      {        "src": "images/icons/icon_72.png",        "sizes": "72x72",        "type": "image/png"      },      {        "src": "images/icons/icon_128.png",        "sizes": "128x128",        "type": "image/png"      },      {        "src": "images/icons/icon_144.png",        "sizes": "144x144",        "type": "image/png"      },      {        "src": "images/icons/icon_192.png",        "sizes": "192x192",        "type": "image/png"      },      {        "src": "images/icons/icon_256.png",        "sizes": "256x256",        "type": "image/png"      },      {        "src": "images/icons/icon_512.png",        "sizes": "512x512",        "type": "image/png"      }    ]  }

manifest 屬性

  • name — 網頁顯示給用戶的完整名稱;
  • short_name — 這是為了在沒有足夠空間顯示 Web 應用程式的全名時使用;
  • description — 關於網站的詳細描述;
  • start_url — 網頁的初始相對 URL 比如 /)
  • display — 應用程式的首選顯示模式;
    • fullscreen - 全螢幕顯示;
    • standalone - 應用程式將看起來像一個獨立的應用程式;
    • minimal-ui - 應用程式將看起來像一個獨立的應用程式,但會有瀏覽器地址欄;
    • browser - 該應用程式在傳統的瀏覽器標籤或新窗口中打開.
  • orientation — 應用程式的首選顯示方向;
    • any
    • natural
    • landscape
    • landscape-primary
    • landscape-secondary
    • portrait
    • portrait-primary
    • portrait-secondary
  • background_color — 啟動屏的背景顏色;
  • theme_color — 網站的主題顏色;
  • icons — 定義了 src、sizes 和 type 的圖片對象數組,各種環境中用作應用程式圖標的影像對象數組.

MDN 提供了完整的 manifest 屬性列表: Web App Manifest properties

manifest 使用

manifest 功能雖然強大,但是技術上並不難,就是一個外鏈的 json 文件,通過 link 來引入:

<!-- 在 html 頁面中添加以下 link 標籤 -->  <link rel="manifest" href="/manifest.json" />

manifest 驗證

在開發者工具中的 Application Tab 左邊有 Manifest 選項,你可以驗證你的 manifest JSON 文件,並提供了 「Add to homescreen」 .

Service Worker

Service Worker 是 PWA 中最重要的概念之一,它是一個特殊的 Web Worker,獨立於瀏覽器的主執行緒運行,特殊在它可以攔截用戶的網路請求,並且操作快取,還支援 Push 和後台同步等功能。

註冊服務

在 install Service Worker 之前,要在主進程 JavaScript 程式碼裡面註冊它,註冊是為了告訴瀏覽器我們的 Service Worker 文件是哪個,然後在後台,Service Worker 就開始安裝激活。

if ('serviceWorker' in navigator) {    window.addEventListener('load', () => {      navigator.serviceWorker.register('/sw.js').then(() => {        console.log('註冊成功!')      })    })  }

註冊時,還可以指定可選參數 scope,scope 是 Service Worker 可以以訪問到的作用域,或者說是目錄。

navigator.serviceWorker.register('/sw.js', {    scope: '/app/'  })

註冊成功後,您可以通過轉至 chrome://inspect/#service-workers 並尋找您的網站來檢查 Service Worker 是否已啟用。

安裝 Service Worker 服務

install 事件綁定在 Service Worker 文件中,當安裝成功後,install 事件就會被觸發。 一般我們會在 install 事件裡面進行快取的處理,用到之前提到的 Cahce API,它是一個 Service Worker 上的全局對象,可以快取網路相應的資源,並根據他們的請求生成 key,這個 API 和瀏覽器標準的快取工作原理相似,但是只是針對自己的 scope 域的,快取會一直存在,知道手動清楚或者刷新。

const cacheName = 'bs-0-0-1' // 快取名稱  const cacheFiles = ['/', '/favicon.ico', '/images/icons/icon_32.png', '...'] // 需快取的文件    // 監聽 install 事件,安裝完成後,進行文件快取  self.addEventListener('install', e => {    e.waitUntil(      caches        .open(cacheName)        .then(cache => {          console.log('Opened cache')          return cache.addAll(cacheFiles)        })        .then(() => self.skipWaiting())    )  })    // e.waitUntil 確保 Service Worker 不會在 e.waitUntil() 執行完成之前安裝完成。  // caches.open(cacheName) 創建一個 cacheName 的新快取,返回一個快取的 promise 對象,當它 resolved 時候,我們在 then 方法裡面用 caches.addAll 來添加想要快取的列表,列表是一個數組,裡面的 URL 是相對於 origin 的。  // self.skipWaiting() 跳過 waiting 狀態,下面更新第3條~

更新 Service Worker 服務

當你的 Service Worker 需要更新時, 需要經過以下步驟

  1. 更新您的服務工作執行緒 JavaScript 文件。 用戶導航至您的站點時,瀏覽器會嘗試在後台重新下載定義 Service Worker 的腳本文件。 如果 Service Worker 文件與其當前所用文件存在位元組差異,則將其視為新 Service Worker。
  2. 新 Service Worker 將會啟動,且將會觸發 install 事件。
  3. 此時,舊 Service Worker 仍控制著當前頁面,因此新 Service Worker 將進入 waiting 狀態。
  4. 當網站上打開的頁面關閉時,舊 Service Worker 將會被終止,新 Service Worker 將會取得控制權。
  5. 新 Service Worker 取得控制權後,將會觸發其 activate 事件。

如果希望在有了新版本時,所有的頁面都得到及時自動更新怎麼辦呢?可以在 install 事件中執行 self.skipWaiting() 方法跳過 waiting 狀態,然後會直接進入 activate 階段。接著在 activate 事件發生時,通過執行 self.clients.claim() 方法,更新所有客戶端上的 Service Worker。

當 Service Worker 安裝完成後並進入激活狀態,會觸發 activate 事件。通過監聽 activate 事件你可以做一些預處理,如對舊版本的更新、對無用快取的清理等。

// 監聽 activate 事件,激活後通過cache的key來判斷是否更新、刪除 cache 中的靜態資源  self.addEventListener('activate', e => {    console.log('sw: activate')      e.waitUntil(      caches        .keys()        .then(keys => {          return Promise.all(            keys.map(key => {              if (key !== cacheName && key !== apiCacheName) {                return caches.delete(key)              }            })          )        })        .then(() => self.clients.claim()) // 更新客戶端    )  })

處理動態請求快取

在 Service Worker 的作用域中,當有網路請求時發生時,fetch 事件將被觸發。它調用 respondWith() 方法來劫持網路請求快取並返回:

var apiCacheName = 'api-0-1-1'  self.addEventListener('fetch', e => {    var currentUrl = e.request.url      // 只處理同源    if (new URL(currentUrl).hostname != location.hostname) {      return    }      // 需要快取的 xhr 請求    var cacheRequestUrls = ['/message.json', '/manifest.json']      // 判斷當前請求是否需要快取    var needCache = cacheRequestUrls.includes(new URL(currentUrl).pathname)      if (needCache) {      // 需要快取      // 使用 fetch 請求數據,並將請求結果 clone 一份快取到 cache      // 此部分快取後在 browser 中使用全局變數 caches 獲取      caches.open(apiCacheName).then(cache => {        return fetch(e.request).then(response => {          cache.put(e.request.url, response.clone())          return response        })      })    } else {      // 不需要快取,直接查詢 cache      // 如果有 cache 則直接返回,否則通過 fetch 請求      e.respondWith(        caches          .match(e.request)          .then(cache => {            return cache || fetch(e.request)          })          .catch(err => {            console.log('respondWithErr:', err)            return fetch(e.request)          })      )    }  })

到這裡,離線快取動靜態資源就完成了。

使用 Lighthouse 測試我們的應用

至此,我們完成了 PWA 的兩大基本功能:Web App Manifest 和 Service Worker 的離線快取。這兩大功能可以很好地提升用戶體驗與應用性能。我們用 Chrome 中的 Lighthouse 來檢測一下目前的應用:

可以看到,在 PWA 評分上,我們的這個 WebApp 已經非常不錯了。

完整程式碼 -> 夢魘小棧 PWA 完整程式碼

var cacheName = 'bs-0-0-2'  var apiCacheName = 'api-0-0-2'    var cacheFiles = [    '/',    '/favicon.ico?v=6.2.0',    '/css/main.css?v=6.2.0',    '/js/src/set.js',    '/js/src/utils.js',    '/js/src/motion.js',    '/js/src/bootstrap.js',    '/images/cursor.ico',    '/images/icons/icon_32.png',    '/images/icons/icon_72.png',    '/images/icons/icon_128.png',    '/images/icons/icon_192.png',    '/images/icons/icon_256.png',    '/images/icons/icon_512.png'  ]    // 監聽 install 事件,安裝完成後,進行文件快取  self.addEventListener('install', e => {    console.log('sw: install')      e.waitUntil(      caches        .open(cacheName)        .then(cache => {          console.log('Opened cache')          return cache.addAll(cacheFiles)        })        .then(() => self.skipWaiting())    )  })    // 監聽 activate 事件,激活後通過 cache 的 key 來判斷是否更新 cache 中的靜態資源  self.addEventListener('activate', e => {    console.log('sw: activate')      e.waitUntil(      caches        .keys()        .then(keys => {          return Promise.all(            keys.map(key => {              if (key !== cacheName && key !== apiCacheName) {                return caches.delete(key)              }            })          )        })        // 更新客戶端        .then(() => self.clients.claim())    )  })    self.addEventListener('fetch', e => {    var currentUrl = e.request.url      // 只處理同源    if (new URL(currentUrl).hostname != location.hostname) {      return    }      // 需要快取的 xhr 請求    var cacheRequestUrls = ['/message.json', '/manifest.json']      // 判斷當前請求是否需要快取    var needCache = cacheRequestUrls.includes(new URL(currentUrl).pathname)      if (needCache) {      // 需要快取      // 使用 fetch 請求數據,並將請求結果 clone 一份快取到 cache      // 此部分快取後在 browser 中使用全局變數 caches 獲取      caches.open(apiCacheName).then(cache => {        return fetch(e.request).then(response => {          cache.put(e.request.url, response.clone())          return response        })      })    } else {      // 不需要快取,直接查詢 cache      // 如果有 cache 則直接返回,否則通過 fetch 請求      e.respondWith(        caches          .match(new URL(currentUrl).pathname)          .then(cache => {            return cache || fetch(e.request)          })          .catch(err => {            console.log('respondWithErr:', err)            return fetch(e.request)          })      )    }  })

由於現在部落格僅需 Manifest、Service Worker 後面的技術、Push API & Notification API 、App Shell & App Skeleton 等打算以後有時間在考慮場景加上~

Tags: