將你的部落格升級為 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
需要更新時, 需要經過以下步驟
- 更新您的服務工作執行緒
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
等打算以後有時間在考慮場景加上~