如何把網(wǎng)站改成PWA,首先我們要了解知道什么是PWA
1. PWA是什么
Progressive Web Apps (下文以“PWAs”代指) 是一個(gè)令人興奮的前端技術(shù)的革新。PWAs綜合了一系列技術(shù)使你的 web app表現(xiàn)得就像是 native mobile app。相比于純 web 解決方案和純 native 解決方案,PWAs對(duì)于開發(fā)者和用戶有以下優(yōu)點(diǎn):
- 你只需要基于開放的 W3C 標(biāo)準(zhǔn)的 web 開發(fā)技術(shù)來開發(fā)一個(gè)app。不需要多客戶端開發(fā)。
- 用戶可以在安裝前就體驗(yàn)?zāi)愕?app。
- 不需要通過 AppStore 下載 app。app 會(huì)自動(dòng)升級(jí)不需要用戶升級(jí)。
- 用戶會(huì)受到‘安裝’的提示,點(diǎn)擊安裝會(huì)增加一個(gè)圖標(biāo)到用戶首屏。
- 被打開時(shí),PWA 會(huì)展示一個(gè)有吸引力的閃屏。
- chrome 提供了可選選項(xiàng),可以使 PWA 得到全屏體驗(yàn)。
- 必要的文件會(huì)被本地緩存,因此會(huì)比標(biāo)準(zhǔn)的web app 響應(yīng)更快(也許也會(huì)比native app響應(yīng)快)
- 安裝及其輕量 -- 或許會(huì)有幾百 kb 的緩存數(shù)據(jù)。
- 網(wǎng)站的數(shù)據(jù)傳輸必須是 https 連接。
- PWAs 可以離線工作,并且在網(wǎng)絡(luò)恢復(fù)時(shí)可以同步最新數(shù)據(jù)。
雖然PWA不是所有瀏覽器都支持,但是我們不需要擔(dān)心, 因?yàn)閜wa是漸進(jìn)增強(qiáng)的, 你的app仍然可以運(yùn)行在不支持 PWA 技術(shù)的瀏覽器里。用戶不能離線訪問,不過其他功能都像原來一樣沒有影響。綜合利弊得失,沒有理由不把你的 app 改進(jìn)為 PWA。
2. 步驟
將你的網(wǎng)站改進(jìn)為一個(gè) Progressive Web App 總共有三個(gè)必要步驟:
2.1 第一步:開啟 HTTPS
由于一些顯而易見的原因,PWAs 需要 HTTPS 連接。HTTPS 在示例代碼中并不是必須的,因?yàn)?Chrome 允許使用 localhost 或者任何 127.x.x.x 的地址來測試。你也可以在 HTTP 連接下測試你的 PWA,你需要使用 Chrome ,并且輸入以下命令行參數(shù):
- --user-data-dir
- --unsafety-treat-insecure-origin-as-secure
2.2 第二步:創(chuàng)建一個(gè) Web App Manifest
manifest 文件提供了一些我們網(wǎng)站的信息,例如 name,description 和需要在主屏使用的圖標(biāo)的圖片,啟動(dòng)屏的圖片等。
manifest文件是一個(gè) JSON 格式的文件,位于你項(xiàng)目的根目錄。它必須用Content-Type: application/manifest+json 或者 Content-Type: application/json這樣的 HTTP 頭來請(qǐng)求。這個(gè)文件可以被命名為任何名字,在示例代碼中他被命名為 /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>中引入:
<link rel="manifest" href="/manifest.json">
manifest 中主要屬性有:
- name —— 網(wǎng)頁顯示給用戶的完整名稱
- short_name —— 當(dāng)空間不足以顯示全名時(shí)的網(wǎng)站縮寫名稱
- description —— 關(guān)于網(wǎng)站的詳細(xì)描述
- start_url —— 網(wǎng)頁的初始 相對(duì) URL(比如 /)
- scope —— 導(dǎo)航范圍。比如,/app/的scope就限制 app 在這個(gè)文件夾里。
- background-color —— 啟動(dòng)屏和瀏覽器的背景顏色
- theme_color —— 網(wǎng)站的主題顏色,一般都與背景顏色相同,它可以影響網(wǎng)站的顯示
- orientation —— 首選的顯示方向:any, natural, landscape, landscape-primary, landscape-secondary, portrait, portrait-primary, 和 portrait-secondary。
- display —— 首選的顯示方式:fullscreen, standalone(看起來像是native app),minimal-ui(有簡化的瀏覽器控制選項(xiàng)) 和 browser(常規(guī)的瀏覽器 tab)
- icons —— 定義了 src URL, sizes和type的圖片對(duì)象數(shù)組。
MDN提供了完整的manifest屬性列表:Web App Manifest properties
在開發(fā)者工具中的 Application tab 左邊有 Manifest 選項(xiàng),你可以驗(yàn)證你的 manifest JSON 文件,并提供了 “Add to homescreen”。
2020042301.png
2.3 第三步:創(chuàng)建一個(gè) Service Worker
Service Worker 是攔截和響應(yīng)你的網(wǎng)絡(luò)請(qǐng)求的編程接口。這是一個(gè)位于你根目錄的一個(gè)單獨(dú)的 JAVAscript 文件。
你的 js 文件(在示例代碼中是 /js/main.js)可以檢查是否支持 Service Worker,并且注冊(cè):
if ('serviceWorker' in navigator) {
// register service worker
navigator.serviceWorker.register('/service-worker.js');
}
如果你不需要離線功能,可以簡單的創(chuàng)建一個(gè)空的 /service-worker.js文件 —— 用戶會(huì)被提示安裝你的 app。
Service Worker 很復(fù)雜,你可以修改示例代碼來達(dá)到自己的目的。這是一個(gè)標(biāo)準(zhǔn)的 web worker,瀏覽器用一個(gè)單獨(dú)的線程來下載和執(zhí)行它。它沒有調(diào)用 DOM 和其他頁面 api 的能力,但他可以攔截網(wǎng)絡(luò)請(qǐng)求,包括頁面切換,靜態(tài)資源下載,ajax請(qǐng)求所引起的網(wǎng)絡(luò)請(qǐng)求。
這就是需要 HTTPS 的最主要的原因。想象一下第三方代碼可以攔截來自其他網(wǎng)站的 service worker, 將是一個(gè)災(zāi)難。
service worker 主要有三個(gè)事件: install,activate 和 fetch。
Install 事件
這個(gè)事件在app被安裝時(shí)觸發(fā)。它經(jīng)常用來緩存必要的文件。緩存通過 Cache API來實(shí)現(xiàn)。
首先,我們來構(gòu)造幾個(gè)變量:
- 緩存名稱(CACHE)和版本號(hào)(version)。你的應(yīng)用可以有多個(gè)緩存但是只能引用一個(gè)。我們?cè)O(shè)置了版本號(hào),這樣當(dāng)我們有重大更新時(shí),我們可以更新緩存,而忽略舊的緩存。
- 一個(gè)離線頁面的URL(offlineURL)。當(dāng)離線時(shí)用戶試圖訪問之前未緩存的頁面時(shí),這個(gè)頁面會(huì)呈現(xiàn)給用戶。
- 一個(gè)擁有離線功能的頁面必要文件的數(shù)組(installFilesEssential)。這個(gè)數(shù)組應(yīng)該包含靜態(tài)資源,比如 css 和 JavaScript 文件,但我也把主頁面(/)和圖標(biāo)文件寫進(jìn)去了。如果主頁面可以多個(gè)URL訪問,你應(yīng)該把他們都寫進(jìn)去,比如/和/index.html。注意,offlineURL也要被寫入這個(gè)數(shù)組。
- 可選的,描述文件數(shù)組(installFilesDesirable)。這些文件都很會(huì)被下載,但如果下載失敗不會(huì)中止安裝。
// configuration
const
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()方法添加文件到緩存,這個(gè)方法用到了基于 promise的 Cache API。當(dāng)必要的文件都被緩存后才會(huì)生成返回值。
// install static assets
function installStaticFiles() {
return caches.open(CACHE)
.then(cache => {
// cache desirable files
cache.addAll(installFilesDesirable);
// cache essential files
return cache.addAll(installFilesEssential);
});
}
最后,我們添加install的事件監(jiān)聽函數(shù)。 waitUntil方法確保所有代碼執(zhí)行完畢后,service worker 才會(huì)執(zhí)行 install。執(zhí)行 installStaticFiles()方法,然后執(zhí)行 self.skipWaiting()方法使service worker進(jìn)入 active狀態(tài)。
// application installation
self.addEventListener('install', event => {
console.log('service worker: install');
// cache core files
event.waitUntil(
installStaticFiles()
.then(() => self.skipWaiting())
);
});
Activate 事件
當(dāng) install完成后, service worker 進(jìn)入active狀態(tài),這個(gè)事件立刻執(zhí)行。你可能不需要實(shí)現(xiàn)這個(gè)事件監(jiān)聽,但是示例代碼在這里刪除老舊的無用緩存文件:
// clear old caches
function clearOldCaches() {
return caches.keys()
.then(keylist => {
return Promise.all(
keylist
.filter(key => key !== CACHE)
.map(key => caches.delete(key))
);
});
}
// application activated
self.addEventListener('activate', event => {
console.log('service worker: activate');
// delete old caches
event.waitUntil(
clearOldCaches()
.then(() => self.clients.claim())
);
});
注意,最后的self.clients.claim()方法設(shè)置本身為active的service worker。
Fetch 事件
當(dāng)有網(wǎng)絡(luò)請(qǐng)求時(shí)這個(gè)事件被觸發(fā)。它調(diào)用respondWith()方法來劫持 GET 請(qǐng)求并返回:
- 緩存中的一個(gè)靜態(tài)資源。
- 如果 #1 失敗了,就用 Fetch API(這與 service worker 的fetch 事件沒關(guān)系)去網(wǎng)絡(luò)請(qǐng)求這個(gè)資源。然后將這個(gè)資源加入緩存。
- 如果 #1 和 #2 都失敗了,那就返回一個(gè)適當(dāng)?shù)闹怠?/li>
// application fetch network data
self.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));
});
})
);
});
最后這個(gè)offlineAsset(url)方法通過幾個(gè)輔助函數(shù)返回一個(gè)適當(dāng)?shù)闹担?/p>
// 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 asset
function 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()方法檢查是否是一個(gè)圖片請(qǐng)求,如果是,那么返回一個(gè)帶有 “offline” 字樣的 SVG。如果不是,返回 offlineURL 頁面。
開發(fā)者工具提供了查看 Service Worker 相關(guān)信息的選項(xiàng):
2020042302.png
在開發(fā)者工具的 Cache Storage 選項(xiàng)列出了所有當(dāng)前域內(nèi)的緩存和所包含的靜態(tài)文件。當(dāng)緩存更新的時(shí)候,你可以點(diǎn)擊左下角的刷新按鈕來更新緩存:
2020042303.png
不出意料, Clear storage 選項(xiàng)可以刪除你的 service worker 和緩存:
2020042304.png
2.4 第四步:創(chuàng)建一個(gè)可用的離線頁面
離線頁面可以是一個(gè)靜態(tài)頁面,來說明當(dāng)前用戶請(qǐng)求不可用。然而,我們也可以在這個(gè)頁面上列出可以訪問的頁面鏈接。
在main.js中我們可以使用 Cache API 。然而API 使用promises,在不支持的瀏覽器中會(huì)引起所有javascript運(yùn)行阻塞。為了避免這種情況,我們?cè)诩虞d另一個(gè) /js/offlinepage.js 文件之前必須檢查離線文件列表和是否支持 Cache API 。
// load script to populate offline page list
if (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 節(jié)點(diǎn)中:
// cache name
const
CACHE = '::PWAsite',
offlineURL = '/offline/',
list = document.getElementById('cachedpagelist');
// fetch all caches
window.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 如何工作。我們會(huì)發(fā)現(xiàn)有很多問題需要我們進(jìn)一步解決:
- 預(yù)緩存的靜態(tài)資源修改后在下一次發(fā)版本時(shí)的文件名都不一樣,手動(dòng)寫死太低效,最好每次都自動(dòng)生成資源文件名。
- 緩存資源是以硬編碼字符串判斷是否有效,這樣每次發(fā)版本都需要手動(dòng)修改,才能更新緩存。并且每次都是全量更新。能否以文件的粒度進(jìn)行資源緩存呢?
- 請(qǐng)求代理沒有區(qū)分靜態(tài)資源和動(dòng)態(tài)接口。已經(jīng)緩存的動(dòng)態(tài)接口也會(huì)一直返回緩存,無法請(qǐng)求新數(shù)據(jù)。
上面只列出了三個(gè)明顯的問題,還有很多問題是沒有考慮到的。如果讓我們自己來解決這些問題,不僅是工作量很大,而且也很難寫出生產(chǎn)環(huán)境可用的 Service Worker。
3. workbox
既然如此,我們最好是站在巨人的肩膀上,這個(gè)巨人就是谷歌。workbox 是由谷歌瀏覽器團(tuán)隊(duì)發(fā)布,用來協(xié)助創(chuàng)建 PWA 應(yīng)用的 JavaScript 庫。當(dāng)然直接用 workbox 還是太復(fù)雜了,谷歌還很貼心的發(fā)布了一個(gè) webpack 插件,能夠自動(dòng)生成 Service Worker 和 靜態(tài)資源列表 - workbox-webpack-plugin。
只需簡單一步就能生成生產(chǎn)環(huán)境可用的 Service Worker :
const { GenerateSW } = require('workbox-webpack-plugin')
new GenerateSW()