PWA全稱Progressive Web App,即漸進式WEB應用。javascript
一個 PWA 應用首先是一個網頁, 能夠經過 Web 技術編寫出一個網頁應用. 隨後添加上 App Manifest 和 Service Worker 來實現 PWA 的安裝和離線等功能php
上述這些特性將使得 Web 應用漸進式接近原生 App。css
上面所說PWA可實現Web App 添加至主屏、可實現離線緩存,在斷網或弱網狀態下依然可使用一些離線功能,不影響Web App體驗以及可實現用戶在不打開瀏覽器狀況下實現相似於原生App離線消息推送功能。html
那麼對於這些實現,PWA依賴於什麼?前端
主要依賴於manifest.json和service worker(在項目中可寫爲一個名爲SW.js的文件並引入項目)java
manifest.json以一個json格式的文件被引入項目,它主要用來實現PWA頁面的添加至主屏、定義App啓動時的URL(由於PWA App本質上仍是一個Web)等。ios
Service Worker是Chrome團隊提出的一個Web API,旨在給Web應用程序提供高級的可持續的後臺處理能力。相比於曾經的Web Worker這種脫離主線程以外的緩存解決方法,Service Worker是持久的。由於web worker是臨時的,每次作的事情的結果還不能被持久存下來,若是下次有一樣的複雜操做,還得費時間的從新來一遍。git
Service Workers 就像介於服務器和網頁之間的攔截器,可以攔截進出的HTTP 請求,從而徹底控制你的網站。
github
最主要的特色web
因爲 Service Worker 要求 HTTPS 的環境,咱們一般能夠藉助於 github page 進行學習調試。固然通常瀏覽器容許調試 Service Worker 的時候 host 爲 localhost
或者 127.0.0.1
也是 ok 的。
Service Worker 的緩存機制是依賴 Cache API 實現的
依賴 Promise 實現
要安裝 Service Worker, 咱們須要經過在 js 主線程(常規的頁面裏的 js )註冊 Service Worker 來啓動安裝,這個過程將會通知瀏覽器咱們的 Service Worker 線程的 javaScript 文件在什麼地方。
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('/sw.js', {scope: '/'})
.then(function (registration) {
// 註冊成功
console.log('ServiceWorker registration successful with scope: ', registration.scope);
})
.catch(function (err) {
// 註冊失敗:(
console.log('ServiceWorker registration failed: ', err);
});
});
}複製代碼
這段代碼首先是要判斷 Service Worker API 的可用狀況,支持的話我們才繼續談實現,不然免談了。
若是支持的話,在頁面 onload
的時候註冊位於 /sw.js
的 Service Worker。
每次頁面加載成功後,就會調用 register()
方法,瀏覽器將會判斷 Service Worker 線程是否已註冊並作出相應的處理。
register 方法的 scope 參數是可選的,用於指定你想讓 Service Worker 控制的內容的子目錄。本 demo 中服務工做線程文件位於根網域, 這意味着服務工做線程的做用域將是整個來源。
關於 register
方法的 scope 參數,須要說明一下:Service Worker 線程將接收 scope 指定網域目錄上全部事項的 fetch 事件,若是咱們的 Service Worker 的 javaScript 文件在 /a/b/sw.js
, 不傳 scope 值的狀況下, scope 的值就是 /a/b
。
scope 的值的意義在於,若是 scope 的值爲 /a/b
, 那麼 Service Worker 線程只能捕獲到 path 爲 /a/b
開頭的( /a/b/page1
, /a/b/page2
,...)頁面的 fetch 事件。經過 scope 的意義咱們也能看出 Service Worker 不是服務單個頁面的,因此在 Service Worker 的 js 邏輯中全局變量須要慎用。
then()
函數鏈式調用咱們的 promise,當 promise resolve 的時候,裏面的代碼就會執行。
最後面咱們鏈了一個 catch()
函數,當 promise rejected 纔會執行。
4.1.2 安裝
在你的 Service Worker 註冊成功以後呢,咱們的瀏覽器中已經有了一個屬於你本身 web App 的 worker context 啦, 在此時,瀏覽器就會快馬加鞭的嘗試爲你的站點裏面的頁面安裝並激活它,而且在這裏能夠把靜態資源的緩存給辦了。
install 事件咱們會綁定在 Service Worker 文件中,在 Service Worker 安裝成功後,install 事件被觸發。
install 事件通常是被用來填充你的瀏覽器的離線緩存能力。爲了達成這個目的,咱們使用了 Service Worker 新的標誌性的存儲 cache API — 一個 Service Worker 上的全局對象,它使咱們能夠存儲網絡響應發來的資源,而且根據它們的請求來生成key。這個 API 和瀏覽器的標準的緩存工做原理很類似,可是是隻對應你的站點的域的。它會一直持久存在,直到你告訴它再也不存儲,你擁有所有的控制權。
localStorage 的用法和 Service Worker cache 的用法很類似,可是因爲localStorage 是同步的用法,因此不容許在 Service Worker 中使用。 IndexedDB 也能夠在 Service Worker 內作數據存儲。
// 監聽 service worker 的 install 事件
this.addEventListener('install', function (event) {
// 若是監聽到了 service worker 已經安裝成功的話,就會調用 event.waitUntil 回調函數
event.waitUntil(
// 安裝成功後操做 CacheStorage 緩存,使用以前須要先經過 caches.open() 打開對應緩存空間。
caches.open('my-test-cache-v1').then(function (cache) {
// 經過 cache 緩存對象的 addAll 方法添加 precache 緩存
return cache.addAll([
'/',
'/index.html',
'/main.css',
'/main.js',
'/image.jpg'
]);
})
);
});複製代碼
這裏咱們 新增了一個 install 事件監聽器,接着在事件上接了一個 ExtendableEvent.waitUntil()
方法——這會確保 Service Worker 不會在 waitUntil() 裏面的代碼執行完畢以前安裝完成。
在 waitUntil()
內,咱們使用了 caches.open()
方法來建立了一個叫作 v1 的新的緩存,將會是咱們的站點資源緩存的第一個版本。它返回了一個建立緩存的 promise,當它 resolved 的時候,咱們接着會調用在建立的緩存實例(Cache API)上的一個方法 addAll()
,這個方法的參數是一個由一組相對於 origin 的 URL 組成的數組,這些 URL 就是你想緩存的資源的列表。
若是 promise 被 rejected,安裝就會失敗,這個 worker 不會作任何事情。這也是能夠的,由於你能夠修復你的代碼,在下次註冊發生的時候,又能夠進行嘗試。
當安裝成功完成以後,Service Worker 就會激活。在第一次你的 Service Worker 註冊/激活時,這並不會有什麼不一樣。可是當 Service Worker 更新的時候 ,就不太同樣了。
4.1.3 自定義請求響應
走到這一步,其實如今你已經能夠將你的站點資源緩存了,你須要告訴 Service Worker 讓它用這些緩存內容來作點什麼。有了 fetch 事件,這是很容易作到的。
每次任何被 Service Worker 控制的資源被請求到時,都會觸發 fetch 事件,這些資源包括了指定的 scope 內的 html 文檔,和這些 html 文檔內引用的其餘任何資源(好比 index.html
發起了一個跨域的請求來嵌入一個圖片,這個也會經過 Service Worker),這下 Service Worker 代理服務器的形象開始慢慢露出來了,而這個代理服務器的鉤子就是憑藉 scope 和 fetch 事件兩大利器就能把站點的請求管理的層次分明。
你能夠給 Service Worker 添加一個 fetch 的事件監聽器,接着調用 event 上的 respondWith()
方法來劫持咱們的 HTTP 響應,而後你能夠用本身的魔法來更新他們。
this.addEventListener('fetch', function (event) {
event.respondWith(
caches.match(event.request).then(function (response) {
// 來來來,代理能夠搞一些代理的事情
// 若是 Service Worker 有本身的返回,就直接返回,減小一次 http 請求
if (response) {
return response;
}
// 若是 service worker 沒有返回,那就得直接請求真實遠程服務
var request = event.request.clone(); // 把原始請求拷過來
return fetch(request).then(function (httpRes) {
// http請求的返回已被抓到,能夠處置了。
// 請求失敗了,直接返回失敗的結果就行了。。
if (!httpRes || httpRes.status !== 200) {
return httpRes;
}
// 請求成功的話,將請求緩存起來。
var responseClone = httpRes.clone();
caches.open('my-test-cache-v1').then(function (cache) {
cache.put(event.request, responseClone);
});
return httpRes;
});
})
);
});複製代碼
咱們能夠在 install
的時候進行靜態資源緩存,也能夠經過 fetch
事件處理回調來代理頁面請求從而實現動態資源緩存。
兩種方式能夠比較一下:
on install 的優勢是第二次訪問便可離線,缺點是須要將須要緩存的 URL 在編譯時插入到腳本中,增長代碼量和下降可維護性;
on fetch 的優勢是無需更改編譯過程,也不會產生額外的流量,缺點是須要多一次訪問才能離線可用。
除了靜態的頁面和文件以外,若是對 Ajax 數據加以適當的緩存能夠實現真正的離線可用, 要達到這一步可能須要對既有的 Web App 進行一些重構以分離數據和模板。
4.1.4 Service Worker版本更新
/sw.js
控制着頁面資源和請求的緩存,那麼若是緩存策略須要更新呢?也就是若是 /sw.js
有更新怎麼辦?/sw.js
自身該如何更新?
若是 /sw.js
內容有更新,當訪問網站頁面時瀏覽器獲取了新的文件,逐字節比對 /sw.js
文件發現不一樣時它會認爲有更新啓動 更新算法,因而會安裝新的文件並觸發 install 事件。可是此時已經處於激活狀態的舊的 Service Worker 還在運行,新的 Service Worker 完成安裝後會進入 waiting 狀態。直到全部已打開的頁面都關閉,舊的 Service Worker 自動中止,新的 Service Worker 纔會在接下來從新打開的頁面裏生效。
4.1.5 自動更新全部頁面
若是但願在有了新版本時,全部的頁面都獲得及時自動更新怎麼辦呢?能夠在 install 事件中執行 self.skipWaiting()
方法跳過 waiting 狀態,而後會直接進入 activate 階段。接着在 activate
事件發生時,經過執行 self.clients.claim()
方法,更新全部客戶端上的 Service Worker。
// 安裝階段跳過等待,直接進入 active
self.addEventListener('install', function (event) {
event.waitUntil(self.skipWaiting());
});
self.addEventListener('activate', function (event) {
event.waitUntil(
Promise.all([
// 更新客戶端
self.clients.claim(),
// 清理舊版本
caches.keys().then(function (cacheList) {
return Promise.all(
cacheList.map(function (cacheName) {
if (cacheName !== 'my-test-cache-v1') {
return caches.delete(cacheName);
}
})
);
})
])
);
});複製代碼
另外要注意一點,/sw.js
文件可能會由於瀏覽器緩存問題,當文件有了變化時,瀏覽器裏仍是舊的文件。這會致使更新得不到響應。如遇到該問題,可嘗試這麼作:在 Web Server 上添加對該文件的過濾規則,不緩存或設置較短的有效期。
4.1.6 手動更新Service Worker
在頁面中,可手動藉助 Registration.update()
更新。
var version = '1.0.1';
navigator.serviceWorker.register('/sw.js').then(function (reg) {
if (localStorage.getItem('sw_version') !== version) {
reg.update().then(function () {
localStorage.setItem('sw_version', version)
});
}
});複製代碼
Service Worker 的特殊之處除了由瀏覽器觸發更新以外,還應用了特殊的緩存策略: 若是該文件已 24 小時沒有更新,當 Update 觸發時會強制更新。這意味着最壞狀況下 Service Worker 會天天更新一次。
4.2 Service Worker生命週期
當用戶首次導航至 URL 時,服務器會返回響應的網頁。
index.html
<head>
<title>Minimal PWA</title>
<meta name="viewport" content="width=device-width, user-scalable=no" />
<link rel="manifest" href="manifest.json" />
<link rel="stylesheet" type="text/css" href="main.css">
<link rel="icon" href="/e.png" type="image/png" />
</head>複製代碼
manifest.json
{
"name": "Minimal PWA", // 必填 顯示的插件名稱
"short_name": "PWA Demo", // 可選 在APP launcher和新的tab頁顯示,若是沒有設置,則使用name
"description": "The app that helps you understand PWA", //用於描述應用
"display": "standalone", // 定義開發人員對Web應用程序的首選顯示模式。standalone模式會有單獨的
"start_url": "/", // 應用啓動時的url
"theme_color": "#313131", // 桌面圖標的背景色
"background_color": "#313131", // 爲web應用程序預約義的背景顏色。在啓動web應用程序和加載應用程序的內容之間建立了一個平滑的過渡。
"icons": [ // 桌面圖標,是一個數組
{
"src": "icon/lowres.webp",
"sizes": "48x48", // 以空格分隔的圖片尺寸
"type": "image/webp" // 幫助userAgent快速排除不支持的類型
},
{
"src": "icon/lowres",
"sizes": "48x48"
},
{
"src": "icon/hd_hi.ico",
"sizes": "72x72 96x96 128x128 256x256"
},
{
"src": "icon/hd_hi.svg",
"sizes": "72x72"
}
]
}複製代碼
index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hello Caching World!</title>
</head>
<body>
<!-- Image -->
<img src="/images/hello.png" />
<!-- JavaScript -->
<script async src="/js/script.js"></script>
<script> // 註冊 service worker if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/service-worker.js', {scope: '/'}).then(function (registration) { // 註冊成功 console.log('ServiceWorker registration successful with scope: ', registration.scope); }).catch(function (err) { // 註冊失敗 :( console.log('ServiceWorker registration failed: ', err); }); } </script>
</body>
</html>複製代碼
注:Service Worker 的註冊路徑決定了其 scope 默認做用頁面的範圍。
若是 service-worker.js 是在 /sw/ 頁面路徑下,這使得該 Service Worker 默認只會收到 頁面/sw/ 路徑下的 fetch 事件。
若是存放在網站的根路徑下,則將會收到該網站的全部 fetch 事件。
若是但願改變它的做用域,可在第二個參數設置 scope 範圍。示例中將其改成了根目錄,即對整個站點生效。
service-worker.js
var cacheName = 'helloWorld'; // 緩存的名稱
// install 事件,它發生在瀏覽器安裝並註冊 Service Worker 時
self.addEventListener('install', event => {
/* event.waitUtil 用於在安裝成功以前執行一些預裝邏輯
可是建議只作一些輕量級和很是重要資源的緩存,減小安裝失敗的機率
安裝成功後 ServiceWorker 狀態會從 installing 變爲 installed */
event.waitUntil(
caches.open(cacheName)
.then(cache => cache.addAll([ // 若是全部的文件都成功緩存了,便會安裝完成。若是任何文件下載失敗了,那麼安裝過程也會隨之失敗。
'/js/script.js',
'/images/hello.png'
]))
);
});
/**
爲 fetch 事件添加一個事件監聽器。接下來,使用 caches.match() 函數來檢查傳入的請求 URL 是否匹配當前緩存中存在的任何內容。若是存在的話,返回緩存的資源。
若是資源並不存在於緩存當中,經過網絡來獲取資源,並將獲取到的資源添加到緩存中。
*/
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.match(event.request)
.then(function (response) {
if (response) {
return response;
}
var requestToCache = event.request.clone(); //
return fetch(requestToCache).then(
function (response) {
if (!response || response.status !== 200) {
return response;
}
var responseToCache = response.clone();
caches.open(cacheName)
.then(function (cache) {
cache.put(requestToCache, responseToCache);
});
return response;
})
);
});複製代碼
注:爲何用request.clone()和response.clone()
須要這麼作是由於request和response是一個流,它只能消耗一次。由於咱們已經經過緩存消耗了一次,而後發起 HTTP 請求還要再消耗一次,因此咱們須要在此時克隆請求
不一樣瀏覽器須要用不一樣的推送消息服務器。以 Chrome 上使用 Google Cloud Messaging<GCM> 做爲推送服務爲例,第一步是註冊 applicationServerKey(經過 GCM 註冊獲取),並在頁面上進行訂閱或發起訂閱。每個會話會有一個獨立的端點(endpoint),訂閱對象的屬性(PushSubscription.endpoint) 即爲端點值。將端點發送給服務器後,服務器用這一值來發送消息給會話的激活的 Service Worker (經過 GCM 與瀏覽器客戶端溝通)。
步驟一+步驟二 index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Progressive Times</title>
<link rel="manifest" href="/manifest.json">
</head>
<body>
<script> var endpoint; var key; var authSecret; var vapidPublicKey = 'BAyb_WgaR0L0pODaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY'; // 方法很複雜,可是能夠不用具體看,只是用來轉化vapidPublicKey用 function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding) .replace(/\-/g, '+') .replace(/_/g, '/'); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; } if ('serviceWorker' in navigator) { navigator.serviceWorker.register('sw.js').then(function (registration) { return registration.pushManager.getSubscription() .then(function (subscription) { if (subscription) { return; } return registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) }) .then(function (subscription) { var rawKey = subscription.getKey ? subscription.getKey('p256dh') : ''; key = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : ''; var rawAuthSecret = subscription.getKey ? subscription.getKey('auth') : ''; authSecret = rawAuthSecret ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawAuthSecret))) : ''; endpoint = subscription.endpoint; return fetch('./register', { method: 'post', headers: new Headers({ 'content-type': 'application/json' }), body: JSON.stringify({ endpoint: subscription.endpoint, key: key, authSecret: authSecret, }), }); }); }); }).catch(function (err) { // 註冊失敗 :( console.log('ServiceWorker registration failed: ', err); }); } </script>
</body>
</html>複製代碼
步驟三 服務器發送消息給service worker
app.js
const webpush = require('web-push');
const express = require('express');
var bodyParser = require('body-parser');
const app = express();
webpush.setVapidDetails(
'mailto:contact@deanhume.com',
'BAyb_WgaR0L0pODaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY',
'p6YVD7t8HkABoez1CvVJ5bl7BnEdKUu5bSyVjyxMBh0'
);
app.post('/register', function (req, res) {
var endpoint = req.body.endpoint;
saveRegistrationDetails(endpoint, key, authSecret);
const pushSubscription = {
endpoint: req.body.endpoint,
keys: {
auth: req.body.authSecret,
p256dh: req.body.key
}
};
var body = 'Thank you for registering';
var iconUrl = 'https://example.com/images/homescreen.png';
// 發送 Web 推送消息
webpush.sendNotification(pushSubscription,
JSON.stringify({
msg: body,
url: 'http://localhost:3111/',
icon: iconUrl
}))
.then(result => res.sendStatus(201))
.catch(err => {
console.log(err);
});
});
app.listen(3111, function () {
console.log('Web push app listening on port 3111!')
});複製代碼
service worker監聽push事件,將通知詳情推送給用戶
service-worker.js
self.addEventListener('push', function (event) {
// 檢查服務端是否發來了任何有效載荷數據
var payload = event.data ? JSON.parse(event.data.text()) : 'no payload';
var title = 'Progressive Times';
event.waitUntil(
// 使用提供的信息來顯示 Web 推送通知
self.registration.showNotification(title, {
body: payload.msg,
url: payload.url,
icon: payload.icon
})
);
});複製代碼
儘管有上述的一些缺點,PWA技術仍然有不少可使用的點。
針對公司公測平臺項目啓發,我的探索思考對於PWA針對公司產品研發的可行性觀點以下:
PWA鑑於還沒有成熟,但最大優點是提高Web App的體驗,我的在瀏覽完美校園公測平臺的測試項目中對PWA的實際工做應用有所思考——首先,對於我的近期參與過的《失物招領》輕應用來講:失物招領中信息流列表內的各個「丟失」「撿到」的帖子性質實際上是一個長期存在的信息帖,由於有的失去物品或撿到物品可能找回難度很大,其次,對於失去物和撿到物的信息描述來講,每一個帖子的內容相比於社交類信息帖來講,信息更改不會很頻繁,涉及的交互主要多是頁面內的聯繫失主或撿到者功能,因此對於這種信息流更新不會太頻繁但又適合非聯網或弱網狀況下隨時隨地能查看帖子信息與發佈者聯繫(尤爲是經過獲取聯繫電話、QQ號碼或微信號這種直接聯繫方式)的狀況下,我的觀點適合採用PWA技術,或者PWA與上上週志兵所介紹的Index DB相結合的方式來對這種應用進行改造,能夠對後端服務器等成本有效減輕。其次,看到公測平臺內有《課程表》應用和《校歷》應用,一樣能夠採用PWA進行改造或開發,由於這兩種應用的信息更新頻率不會太快,如課程表和校歷可能每學期會學校更新一次,且有的更新幅度不會太大,更新點不會太多,適合Service Worker進行緩存處理(此處不表明Service Worker就不能處理相對大一點的緩存)。
對於PWA的使用須要根據項目性質進行評估,但願有朝一日能夠對一些內部項目實現PWA化,從前端角度減輕項目總體中的部分或總體成本,我的認爲,前端開發人員的核心能力和競爭力非幾個框架或工具的掌握,而是使用正確的技術手段減小項目的相關成本。
但我的能力尚淺薄,但願繼續努力,先以掌握並精通相關必要技術爲前提。