前言
最近在給我的部落格網站 PWA 升級,順便就記錄下 React 同構應用在使用 PWA 時遇到的問題,這裡不會從頭開始介紹什麼是 PWA,如果你想學習 PWA 相關知識,可以看下下面我收藏的一些文章:
- 您的第一個 Progressive Web App
- 【Service Worker】生命週期那些事兒
- 【PWA學習與實踐】(1) 2018,開始你的PWA學習之旅
- Progressive Web Apps (PWA) 中文版
PWA 特性
PWA 不是單純的某項技術,而是一堆技術的集合,比如:Service Worker,manifest 新增到桌面,push、notification api 等。
而就在前不久時間,IOS 11.3 剛剛支援 Service worker 和類似 manifest 新增到桌面的特性,所以這次 PWA 改造主要還是實現這兩部分功能,至於其它的特性,等 iphone 支援了再升級吧。
Service Worker
service worker 在我看來,類似於一個跑在瀏覽器後臺的執行緒,頁面第一次載入的時候會載入這個執行緒,線上程啟用之後,通過對 fetch 事件,可以對每個獲取的資源進行控制快取等。
明確哪些資源需要被快取?
那麼在開始使用 service worker 之前,首先需要清楚哪些資源需要被快取?
快取靜態資源
首先是像 CSS、JS 這些靜態資源,因為我的部落格裡引用的指令碼樣式都是通過 hash 做持久化快取,類似於:main.ac62dexx.js
這樣,然後開啟強快取,這樣下次使用者下次再訪問我的網站的時候就不用重新請求資源。直接從瀏覽器快取中讀取。對於這部分資源,service worker 沒必要再去處理,直接放行讓它去讀取瀏覽器快取即可。
我認為如果你的站點載入靜態資源的時候本身沒有開啟強快取,並且你只想通過前端去實現快取,而不需要後端在介入進行調整,那可以使用 service worker 來快取靜態資源,否則就有點畫蛇添足了。
快取頁面
快取頁面顯然是必要的,這是最核心的部分,當你在離線的狀態下載入頁面會之後出現:
究其原因就是因為你在離線狀態下沒辦法載入頁面,現在有了 service worker,即使你在沒網路的情況下,也可以載入之前快取好的頁面了。
快取後端介面資料
快取介面資料是需要的,但也不是必須通過 service worker 來實現,前端存放資料的地方有很多,比如通過 localstorage,indexeddb 來進行儲存。這裡我也是通過 service worker 來實現快取介面資料的,如果想通過其它方式來實現,只需要注意好 url 路徑與資料對應的對映關係即可。
快取策略
明確了哪些資源需要被快取後,接下來就要談談快取策略了。
頁面快取策略
因為是 React 單頁同構應用,每次載入頁面的時候資料都是動態的,所以我採取的是:
- 網路優先的方式,即優先獲取網路上最新的資源。當網路請求失敗的時候,再去獲取 service worker 裡之前快取的資源
- 當網路載入成功之後,就更新 cache 中對應的快取資源,保證下次每次載入頁面,都是上次訪問的最新資源
- 如果找不到 service worker 中 url 對應的資源的時候,則去獲取 service worker 對應的
/index.html
預設首頁
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// sw.js self.addEventListener('fetch', (e) => { console.log('現在正在請求:' + e.request.url); const currentUrl = e.request.url; // 匹配上頁面路徑 if (matchHtml(currentUrl)) { const requestToCache = e.request.clone(); e.respondWith( // 載入網路上的資源 fetch(requestToCache).then((response) => { // 載入失敗 if (!response || response.status !== 200) { throw Error('response error'); } // 載入成功,更新快取 const responseToCache = response.clone(); caches.open(cacheName).then((cache) => { cache.put(requestToCache, responseToCache); }); console.log(response); return response; }).catch(function() { // 獲取對應快取中的資料,獲取不到則退化到獲取預設首頁 return caches.match(e.request).then((response) => { return response || caches.match('/index.html'); }); }) ); } }); |
為什麼存在命中不了快取頁面的情況?
- 首先需要明確的是,使用者在第一次載入你的站點的時候,載入頁面後才會去啟動 sw,所以第一次載入不可能通過 fetch 事件去快取頁面
- 我的部落格是單頁應用,但是使用者並不一定會通過首頁進入,有可能會通過其它頁面路徑進入到我的網站,這就導致我在 install 事件中根本沒辦法指定需要快取那些頁面
- 最終實現的效果是:使用者第一次開啟頁面,馬上斷掉網路,依然可以離線訪問我的站點
結合上面三點,我的方法是:第一次載入的時候會快取 /index.html
這個資源,並且快取頁面上的資料,如果使用者立刻離線載入的話,這時候並沒有快取對應的路徑,比如 /archives
資源訪問不到,這返回 /index.html
走非同步載入頁面的邏輯。
在 install 事件快取 /index.html
,保證了 service worker 第一次載入的時候快取預設頁面,留下退路。
1 2 3 4 5 6 7 8 9 10 11 12 |
import constants from './constants'; const cacheName = constants.cacheName; const apiCacheName = constants.apiCacheName; const cacheFileList = ['/index.html']; self.addEventListener('install', (e) => { console.log('Service Worker 狀態: install'); const cacheOpenPromise = caches.open(cacheName).then((cache) => { return cache.addAll(cacheFileList); }); e.waitUntil(cacheOpenPromise); }); |
在頁面載入完後,在 React 元件中立刻快取資料:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// cache.js import constants from '../constants'; const apiCacheName = constants.apiCacheName; export const saveAPIData = (url, data) => { if ('caches' in window) { // 偽造 request/response 資料 caches.open(apiCacheName).then((cache) => { cache.put(url, new Response(JSON.stringify(data), { status: 200 })); }); } }; // React 元件 import constants from '../constants'; export default class extends PureComponent { componentDidMount() { const { state, data } = this.props; // 非同步載入資料 if (state === constants.INITIAL_STATE || state === constants.FAILURE_STATE) { this.props.fetchData(); } else { // 服務端渲染成功,儲存頁面資料 saveAPIData(url, data); } } } |
這樣就保證了使用者第一次載入頁面,立刻離線訪問站點後,雖然無法像第一次一樣能夠服務端渲染資料,但是之後能通過獲取頁面,非同步載入資料的方式構建離線應用。
使用者第一次訪問站點,如果在不重新整理頁面的情況切換路由到其他頁面,則會非同步獲取到的資料,當下次訪問對應的路由的時候,則退化到非同步獲取資料。
當使用者第二次載入頁面的時候,因為 service worker 已經控制了站點,已經具備了快取頁面的能力,之後在訪問的頁面都將會被快取或者更新快取,當使用者離線訪問的的時候,也能訪問到服務端渲染的頁面了。
介面快取策略
談完頁面快取,再來講講介面快取,介面快取就跟頁面快取很類似了,唯一的不同在於:頁面第一次載入的時候不一定有快取,但是會有介面快取的存在(因為偽造了 cache 中的資料),所以快取策略跟頁面快取類似:
- 網路優先的方式,即優先獲取網路上介面資料。當網路請求失敗的時候,再去獲取 service worker 裡之前快取的介面資料
- 當網路載入成功之後,就更新 cache 中對應的快取介面資料,保證下次每次載入頁面,都是上次訪問的最新介面資料
所以程式碼就像這樣(程式碼類似,不再贅述):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
self.addEventListener('fetch', (e) => { console.log('現在正在請求:' + e.request.url); const currentUrl = e.request.url; if (matchHtml(currentUrl)) { // ... } else if (matchApi(currentUrl)) { const requestToCache = e.request.clone(); e.respondWith( fetch(requestToCache).then((response) => { if (!response || response.status !== 200) { return response; } const responseToCache = response.clone(); caches.open(apiCacheName).then((cache) => { cache.put(requestToCache, responseToCache); }); return response; }).catch(function() { return caches.match(e.request); }) ); } }); |
這裡其實可以再進行優化的,比如在獲取資料介面的時候,可以先讀取快取中的介面資料進行渲染,當真正的網路介面資料返回之後再進行替換,這樣也能有效減少使用者的首屏渲染時間。當然這可能會發生頁面閃爍的效果,可以新增一些動畫來進行過渡。
其它問題
到現在為止,已經基本上可以實現 service worker 離線快取應用的效果了,但是還有仍然存在一些問題:
快速啟用 service worker
預設情況下,頁面的請求(fetch)不會通過 sw,除非它本身是通過 sw 獲取的,也就是說,在安裝 sw 之後,需要重新整理頁面才能有效果。sw 在安裝成功並啟用之前,不會響應 fetch或push等事件。
因為站點是單頁面應用,這就導致了你在切換路由(沒有重新整理頁面)的時候沒有快取介面資料,因為這時候 service worker 還沒有開始工作,所以在載入 service worker 的時候需要快速地啟用它。程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
self.addEventListener('activate', (e) => { console.log('Service Worker 狀態: activate'); const cachePromise = caches.keys().then((keys) => { return Promise.all(keys.map((key) => { if (key !== cacheName && key !== apiCacheName) { return caches.delete(key); } return null; })); }); e.waitUntil(cachePromise); // 快速啟用 sw,使其能夠響應 fetch 事件 return self.clients.claim(); }); |
有的文章說還需要在 install 事件中新增 self.skipWaiting();
來跳過等待時間,但是我在實踐中發現即使不新增也可以正常啟用 service worker,原因不詳,有讀者知道的話可以交流下。
現在當你第一次載入頁面,跳轉路由,立刻離線訪問的頁面,也可以順利地載入頁面了。
不要強快取 sw.js
使用者每次訪問頁面的時候都會去重新獲取 sw.js,根據檔案內容跟之前的版本是否一致來判斷 service worker 是否有更新。所以如果你對 sw.js 開啟強快取的話,就將陷入死迴圈,因為每次頁面獲取到的 sw.js 都是一樣,這樣就無法升級你的 service worker。
另外對 sw.js 開啟強快取也是沒有必要的:
- 本身 sw.js 檔案本身就很小,浪費不了多少頻寬,覺得浪費可以使用協商快取,但額外增加開發負擔
- sw.js 是在頁面空閒的時候才去載入的,並不會影響使用者首屏渲染速度
避免改變 sw 的 URL
在 sw 中這麼做是“最差實踐”,要在原地址上修改 sw。
舉個例子來說明為什麼:
- index.html 註冊了 sw-v1.js 作為 sw
- sw-v1.js 對 index.html 做了快取,也就是快取優先(offline-first)
- 你更新了 index.html 重新註冊了在新地址的 sw sw-v2.js
如果你像上面那麼做,使用者永遠也拿不到 sw-v2.js,因為 index.html 在 sw-v1.js 快取中,這樣的話,如果你想更新為 sw-v2.js,還需要更改原來的 sw-v1.js。
測試
自此,我們已經完成了使用 service worker 對頁面進行離線快取的功能,如果想體驗功能的話,訪問我的部落格:https://lindongzhou.com
隨意瀏覽任意的頁面,然後關掉網路,再次訪問,之前你瀏覽過的頁面都可以在離線的狀態下進行訪問了。
IOS 需要 11.3 的版本才支援,使用 Safari 進行訪問,Android 請選擇支援 service worker 的瀏覽器
manifest 桌面應用
前面講完了如何使用 service worker 來離線快取你的同構應用,但是 PWA 不僅限於此,你還可以使用設定 manifest 檔案來將你的站點新增到移動端的桌面上,從而達到趨近於原生應用的體驗。
使用 webpack-pwa-manifest 外掛
我的部落格站點是通過 webpack 來構建前端程式碼的,所以我在社群裡找到 webpack-pwa-manifest 外掛用來生成 manifest.json。
首先安裝好 webpack-pwa-manifest 外掛,然後在你的 webpack 配置檔案中新增:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
// webpack.config.prod.js const WebpackPwaManifest = require('webpack-pwa-manifest'); module.exports = webpackMerge(baseConfig, { plugins: [ new WebpackPwaManifest({ name: 'Lindz\'s Blog', short_name: 'Blog', description: 'An isomorphic progressive web blog built by React & Node', background_color: '#333', theme_color: '#333', filename: 'manifest.[hash:8].json', publicPath: '/', icons: [ { src: path.resolve(constants.publicPath, 'icon.png'), sizes: [96, 128, 192, 256, 384, 512], // multiple sizes destination: path.join('icons') } ], ios: { 'apple-mobile-web-app-title': 'Lindz\'s Blog', 'apple-mobile-web-app-status-bar-style': '#000', 'apple-mobile-web-app-capable': 'yes', 'apple-touch-icon': '//xxx.com/icon.png', }, }) ] }) |
簡單地闡述下配置資訊:
- name: 應用名稱,就是圖示下面的顯示名稱
- short_name: 應用名稱,但 name 無法顯示完全時候則顯示這個
- background_color、theme_color:顧名思義,相應的顏色
- publicPath: 設定 cdn 路徑,跟 webpack 裡的 publicPath 一樣
- icons: 設定圖示,外掛會自動幫你生成不同 size 的圖片,但是圖片大小必須大於最大 sizes
- ios: 設定在 safari 中如何去新增桌面應用
設定完之後,webpack 會在構建過程中生成相應的 manifest 檔案,並在 html 檔案中引用,下面就是生成 manifest 檔案:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
{ "icons": [ { "src": "/icons/icon_512x512.79ddc5874efb8b481d9a3d06133b6213.png", "sizes": "512x512", "type": "image/png" }, { "src": "/icons/icon_384x384.09826bd1a5d143e05062571f0e0e86e7.png", "sizes": "384x384", "type": "image/png" }, { "src": "/icons/icon_256x256.d641a3644ce20c06855db39cfb2f7b40.png", "sizes": "256x256", "type": "image/png" }, { "src": "/icons/icon_192x192.8f11e077242cccd9c42c0cbbecd5149c.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icons/icon_128x128.cc0714ab18fa6ee6de42ef3d5ca8fd09.png", "sizes": "128x128", "type": "image/png" }, { "src": "/icons/icon_96x96.dbfccb1a5cef8093a77c079f761b2d63.png", "sizes": "96x96", "type": "image/png" } ], "name": "Lindz's Blog", "short_name": "Blog", "orientation": "portrait", "display": "standalone", "start_url": ".", "description": "An isomorphic progressive web blog built by React & Node", "background_color": "#333", "theme_color": "#333" } |
html 中會引用這個檔案,並且加上對 ios 新增桌面應用的支援,就像這樣。
1 2 3 4 5 6 7 8 9 10 |
<!DOCTYPE html> <html lang=en> <head> <meta name=apple-mobile-web-app-title content="Lindz's Blog"> <meta name=apple-mobile-web-app-capable content=yes> <meta name=apple-mobile-web-app-status-bar-style content=#838a88> <link rel=apple-touch-icon href=xxxxx> <link rel=manifest href=/manifest.21d63735.json> </head> </html> |
就這麼簡單,你就可以使用 webpack 來新增你的桌面應用了。
測試
新增完之後你可以通過 chrome 開發者工具 Application – Manifest 來檢視你的 mainfest 檔案是否生效:
這樣說明你的配置生效了,安卓機會自動識別你的配置檔案,並詢問使用者是否新增。
結尾
講到這差不多就完了,等以後 IOS 支援 PWA 的其它功能的時候,到時候我也會相應地去實踐其它 PWA 的特性的。現在 IOS 11.3 也僅僅支援 PWA 中的 service worker 和 app manifest 的功能,但是相信在不久的將來,其它的功能也會相應得到支援,到時候相信 PWA 將會在移動端綻放異彩的。