如何把網站改成PWA,首先我們要了解知道什麼是PWA
1. PWA是什麼Progressive Web Apps (下文以“PWAs”代指) 是一個令人興奮的前端技術的革新。PWAs綜合了一系列技術使你的 web app表現得就像是 native mobile app。相比於純 web 解決方案和純 native 解決方案,PWAs對於開發者和使用者有以下優點:
你只需要基於開放的 W3C 標準的 web 開發技術來開發一個app。不需要多客戶端開發。使用者可以在安裝前就體驗你的 app。不需要通過 AppStore 下載 app。app 會自動升級不需要使用者升級。使用者會受到‘安裝’的提示,點選安裝會增加一個圖示到使用者首屏。被開啟時,PWA 會展示一個有吸引力的閃屏。chrome 提供了可選選項,可以使 PWA 得到全屏體驗。必要的檔案會被本地快取,因此會比標準的web app 響應更快(也許也會比native app響應快)安裝及其輕量 -- 或許會有幾百 kb 的快取資料。網站的資料傳輸必須是 https 連線。PWAs 可以離線工作,並且在網路恢復時可以同步最新資料。雖然PWA不是所有瀏覽器都支援,但是我們不需要擔心, 因為pwa是漸進增強的, 你的app仍然可以執行在不支援 PWA 技術的瀏覽器裡。使用者不能離線訪問,不過其他功能都像原來一樣沒有影響。綜合利弊得失,沒有理由不把你的 app 改進為 PWA。
2. 步驟將你的網站改進為一個 Progressive Web App 總共有三個必要步驟:
2.1 第一步:開啟 HTTPS由於一些顯而易見的原因,PWAs 需要 HTTPS 連線。HTTPS 在示例程式碼中並不是必須的,因為 Chrome 允許使用 localhost 或者任何 127.x.x.x 的地址來測試。你也可以在 HTTP 連線下測試你的 PWA,你需要使用 Chrome ,並且輸入以下命令列引數:
--user-data-dir--unsafety-treat-insecure-origin-as-secure2.2 第二步:建立一個 Web App Manifestmanifest 檔案提供了一些我們網站的資訊,例如 name,description 和需要在主屏使用的圖示的圖片,啟動屏的圖片等。
manifest檔案是一個 JSON 格式的檔案,位於你專案的根目錄。它必須用Content-Type: application/manifest+json 或者 Content-Type: application/json這樣的 HTTP 頭來請求。這個檔案可以被命名為任何名字,在示例程式碼中他被命名為 /manifest.json:
{ "name" : "PWA Website", "short_name" : "PWA", "description" : "An example PWA website", "start_url" : "/", "display" : "standalone", "orientation" : "any", "background_color" : "#ACE", "theme_color" : "#ACE", "icons": [ { "src" : "/images/logo/logo072.png", "sizes" : "72x72", "type" : "image/png" }, { "src" : "/images/logo/logo152.png", "sizes" : "152x152", "type" : "image/png" }, { "src" : "/images/logo/logo192.png", "sizes" : "192x192", "type" : "image/png" }, { "src" : "/images/logo/logo256.png", "sizes" : "256x256", "type" : "image/png" }, { "src" : "/images/logo/logo512.png", "sizes" : "512x512", "type" : "image/png" } ]}
在頁面的<head>中引入:
name —— 網頁顯示給使用者的完整名稱short_name —— 當空間不足以顯示全名時的網站縮寫名稱description —— 關於網站的詳細描述start_url —— 網頁的初始 相對 URL(比如 /)scope —— 導航範圍。比如,/app/的scope就限制 app 在這個資料夾裡。background-color —— 啟動屏和瀏覽器的背景顏色theme_color —— 網站的主題顏色,一般都與背景顏色相同,它可以影響網站的顯示orientation —— 首選的顯示方向:any, natural, landscape, landscape-primary, landscape-secondary, portrait, portrait-primary, 和 portrait-secondary。display —— 首選的顯示方式:fullscreen, standalone(看起來像是native app),minimal-ui(有簡化的瀏覽器控制選項) 和 browser(常規的瀏覽器 tab)icons —— 定義了 src URL, sizes和type的圖片物件陣列。MDN提供了完整的manifest屬性列表:Web App Manifest properties
在開發者工具中的 Application tab 左邊有 Manifest 選項,你可以驗證你的 manifest JSON 檔案,並提供了 “Add to homescreen”。
2020042301.png
2.3 第三步:建立一個 Service WorkerService Worker 是攔截和響應你的網路請求的程式設計介面。這是一個位於你根目錄的一個單獨的 javascript 檔案。
你的 js 檔案(在示例程式碼中是 /js/main.js)可以檢查是否支援 Service Worker,並且註冊:
if ('serviceWorker' in navigator) { // register service worker navigator.serviceWorker.register('/service-worker.js');}如果你不需要離線功能,可以簡單的建立一個空的 /service-worker.js檔案 —— 使用者會被提示安裝你的 app。
Service Worker 很複雜,你可以修改示例程式碼來達到自己的目的。這是一個標準的 web worker,瀏覽器用一個單獨的執行緒來下載和執行它。它沒有呼叫 DOM 和其他頁面 api 的能力,但他可以攔截網路請求,包括頁面切換,靜態資源下載,ajax請求所引起的網路請求。
這就是需要 HTTPS 的最主要的原因。想象一下第三方程式碼可以攔截來自其他網站的 service worker, 將是一個災難。
service worker 主要有三個事件: install,activate 和 fetch。
Install 事件這個事件在app被安裝時觸發。它經常用來快取必要的檔案。快取通過 Cache API來實現。
首先,我們來構造幾個變數:
快取名稱(CACHE)和版本號(version)。你的應用可以有多個快取但是隻能引用一個。我們設定了版本號,這樣當我們有重大更新時,我們可以更新快取,而忽略舊的快取。一個離線頁面的URL(offlineURL)。當離線時使用者試圖訪問之前未快取的頁面時,這個頁面會呈現給使用者。一個擁有離線功能的頁面必要檔案的陣列(installFilesEssential)。這個陣列應該包含靜態資源,比如 CSS 和 JavaScript 檔案,但我也把主頁面(/)和圖示檔案寫進去了。如果主頁面可以多個URL訪問,你應該把他們都寫進去,比如/和/index.html。注意,offlineURL也要被寫入這個陣列。可選的,描述檔案陣列(installFilesDesirable)。這些檔案都很會被下載,但如果下載失敗不會中止安裝。// configurationconst version = '1.0.0', CACHE = version + '::PWAsite', offlineURL = '/offline/', installFilesEssential = [ '/', '/manifest.json', '/css/styles.css', '/js/main.js', '/js/offlinepage.js', '/images/logo/logo152.png' ].concat(offlineURL), installFilesDesirable = [ '/favicon.ico', '/images/logo/logo016.png', '/images/hero/power-pv.jpg', '/images/hero/power-lo.jpg', '/images/hero/power-hi.jpg' ];installStaticFiles()方法新增檔案到快取,這個方法用到了基於 promise的 Cache API。當必要的檔案都被快取後才會生成返回值。
// install static assetsfunction installStaticFiles() { return caches.open(CACHE) .then(cache => { // cache desirable files cache.addAll(installFilesDesirable); // cache essential files return cache.addAll(installFilesEssential); });}最後,我們新增install的事件監聽函式。 waitUntil方法確保所有程式碼執行完畢後,service worker 才會執行 install。執行 installStaticFiles()方法,然後執行 self.skipWaiting()方法使service worker進入 active狀態。
// application installationself.addEventListener('install', event => { console.log('service worker: install'); // cache core files event.waitUntil( installStaticFiles() .then(() => self.skipWaiting()) );});Activate 事件當 install完成後, service worker 進入active狀態,這個事件立刻執行。你可能不需要實現這個事件監聽,但是示例程式碼在這裡刪除老舊的無用快取檔案:
// clear old cachesfunction clearOldCaches() { return caches.keys() .then(keylist => { return Promise.all( keylist .filter(key => key !== CACHE) .map(key => caches.delete(key)) ); });}// application activatedself.addEventListener('activate', event => { console.log('service worker: activate'); // delete old caches event.waitUntil( clearOldCaches() .then(() => self.clients.claim()) );});注意,最後的self.clients.claim()方法設定本身為active的service worker。
Fetch 事件當有網路請求時這個事件被觸發。它呼叫respondWith()方法來劫持 GET 請求並返回:
快取中的一個靜態資源。如果 #1 失敗了,就用 Fetch API(這與 service worker 的fetch 事件沒關係)去網路請求這個資源。然後將這個資源加入快取。如果 #1 和 #2 都失敗了,那就返回一個適當的值。// application fetch network dataself.addEventListener('fetch', event => { // abandon non-GET requests if (event.request.method !== 'GET') return; let url = event.request.url; event.respondWith( caches.open(CACHE) .then(cache => { return cache.match(event.request) .then(response => { if (response) { // return cached file console.log('cache fetch: ' + url); return response; } // make network request return fetch(event.request) .then(newreq => { console.log('network fetch: ' + url); if (newreq.ok) cache.put(event.request, newreq.clone()); return newreq; }) // app is offline .catch(() => offlineAsset(url)); }); }) );});最後這個offlineAsset(url)方法通過幾個輔助函式返回一個適當的值:
// is image URL?let iExt = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'].map(f => '.' + f);function isImage(url) { return iExt.reduce((ret, ext) => ret || url.endsWith(ext), false);}// return offline assetfunction offlineAsset(url) { if (isImage(url)) { // return image return new Response( '<svg role="img" viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg"><title>offline</title><path d="M0 0h400v300H0z" fill="#eee" /><text x="200" y="150" text-anchor="middle" dominant-baseline="middle" font-family="sans-serif" font-size="50" fill="#ccc">offline</text></svg>', { headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-store' }} ); } else { // return page return caches.match(offlineURL); }}offlineAsset()方法檢查是否是一個圖片請求,如果是,那麼返回一個帶有 “offline” 字樣的 SVG。如果不是,返回 offlineURL 頁面。
開發者工具提供了檢視 Service Worker 相關資訊的選項:
2020042302.png
在開發者工具的 Cache Storage 選項列出了所有當前域內的快取和所包含的靜態檔案。當快取更新的時候,你可以點選左下角的重新整理按鈕來更新快取:
2020042303.png
2020042304.png
2.4 第四步:建立一個可用的離線頁面離線頁面可以是一個靜態頁面,來說明當前使用者請求不可用。然而,我們也可以在這個頁面上列出可以訪問的頁面連結。
在main.js中我們可以使用 Cache API 。然而API 使用promises,在不支援的瀏覽器中會引起所有javascript執行阻塞。為了避免這種情況,我們在載入另一個 /js/offlinepage.js 檔案之前必須檢查離線檔案列表和是否支援 Cache API 。
// load script to populate offline page listif (document.getElementById('cachedpagelist') && 'caches' in window) { var scr = document.createElement('script'); scr.src = '/js/offlinepage.js'; scr.async = 1; document.head.appendChild(scr);}
/js/offlinepage.js locates the most recent cache by version name, 取到所有 URL的key的列表,移除所有無用 URL,排序所有的列表並且把他們加到 ID 為cachedpagelist的 DOM 節點中:
// cache nameconst CACHE = '::PWAsite', offlineURL = '/offline/', list = document.getElementById('cachedpagelist');// fetch all cacheswindow.caches.keys() .then(cacheList => { // find caches by and order by most recent cacheList = cacheList .filter(cName => cName.includes(CACHE)) .sort((a, b) => a - b); // open first cache caches.open(cacheList[0]) .then(cache => { // fetch cached pages cache.keys() .then(reqList => { let frag = document.createDocumentFragment(); reqList .map(req => req.url) .filter(req => (req.endsWith('/') || req.endsWith('.html')) && !req.endsWith(offlineURL)) .sort() .forEach(req => { let li = document.createElement('li'), a = li.appendChild(document.createElement('a')); a.setAttribute('href', req); a.textContent = a.pathname; frag.appendChild(li); }); if (list) list.appendChild(frag); }); }) });
上面這些程式碼demo在 https://github.com/craigbuckler/pwa-retrofit
上面只簡單講了 Service Worker 如何工作。我們會發現有很多問題需要我們進一步解決:
預快取的靜態資源修改後在下一次發版本時的檔名都不一樣,手動寫死太低效,最好每次都自動生成資原始檔名。快取資源是以硬編碼字串判斷是否有效,這樣每次發版本都需要手動修改,才能更新快取。並且每次都是全量更新。能否以檔案的粒度進行資源快取呢?請求代理沒有區分靜態資源和動態介面。已經快取的動態介面也會一直返回快取,無法請求新資料。上面只列出了三個明顯的問題,還有很多問題是沒有考慮到的。如果讓我們自己來解決這些問題,不僅是工作量很大,而且也很難寫出生產環境可用的 Service Worker。
3. workbox既然如此,我們最好是站在巨人的肩膀上,這個巨人就是谷歌。workbox 是由谷歌瀏覽器團隊釋出,用來協助建立 PWA 應用的 JavaScript 庫。當然直接用 workbox 還是太複雜了,谷歌還很貼心的釋出了一個 webpack 外掛,能夠自動生成 Service Worker 和 靜態資源列表 - workbox-webpack-plugin。
只需簡單一步就能生成生產環境可用的 Service Worker :
const { GenerateSW } = require('workbox-webpack-plugin')new GenerateSW()