將你的部落格升級為 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— 應用程式的首選顯示方向;anynaturallandscapelandscape-primarylandscape-secondaryportraitportrait-primaryportrait-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 需要更新時, 需要經過以下步驟
- 更新您的服務工作執行緒
JavaScript文件。 用戶導航至您的站點時,瀏覽器會嘗試在後台重新下載定義Service Worker的腳本文件。 如果Service Worker文件與其當前所用文件存在位元組差異,則將其視為新 Service Worker。 - 新
Service Worker將會啟動,且將會觸發install事件。 - 此時,舊
Service Worker仍控制著當前頁面,因此新Service Worker將進入waiting狀態。 - 當網站上打開的頁面關閉時,舊
Service Worker將會被終止,新Service Worker將會取得控制權。 - 新
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 等打算以後有時間在考慮場景加上~
