Progressive Web App, 簡稱 PWA,是提高 Web App 的體驗的一種新方法,能給用戶原生應用的體驗。css
PWA 能作到原生應用的體驗不是靠特指某一項技術,而是通過應用一些新技術進行改進,在安全、性能和體驗三個方面都有很大提高,PWA 本質上是 Web App,藉助一些新技術也具有了 Native App 的一些特性,兼具 Web App 和 Native App 的優勢。html
技術依賴:java
PWA應用應該是:jquery
Progressive web app advantages. To find out how to implement PWAs, consult the guides listed in the below section.webpack
強調是漸進式的,改造過程當中能夠逐步進行,下降站點的改形成本,新技術支持程度不完整,跟着新技術逐步進化。 PWA 涉及到從安全、性能和體驗等方面的優化,能夠考慮如下步驟:git
Service Worker,是一個瀏覽器和network之間的代理,解決的是如何緩存頁面的資產和若是在脫機狀態下仍然正常工做的問題。獨立於當前網頁進程,有本身獨立的 worker context,沒有對於DOM的訪問權限,與傳統的API不一樣,它是非阻塞的,並基於promise方法在就緒時返回結果。它不但只是離線能力,還有消息通知、添加桌面圖標等功能。github
A more detailed introduction to The Service Worker Lifecycleweb
A service worker goes through three steps in its lifecycle:算法
在install Server Worker以前,要在主進程JavaScript代碼裏面註冊它,註冊是爲了告訴瀏覽器咱們的Servic e Worker文件是哪一個,而後在後臺,Service Worker就開始安裝激活。chrome
註冊代碼能夠放到html文件的
<script></script>
標籤中,也能夠單獨放到main.js
文件在引入html文件中。
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js')
.then(function(registration) {
console.log('Registration successful, scope is:', registration.scope);
})
.catch(function(error) {
console.log('Service worker registration failed, error:', error);
});
}
複製代碼
代碼中,先檢測是瀏覽器是否是支持Service Worker,若是支持,就用navigator.serviceWorker.register
註冊,若是成功,就會在promise的 .then
裏面獲得registration
.
service-worker.js文件就是咱們要編寫Service Worker功能的文件。
註冊時,還能夠指定可選參數scope,scope是Service Worker 能夠以訪問到的做用域,或者說是目錄。
navigator.serviceWorker.register('/service-worker.js', {
scope: '/app/'
});
複製代碼
代碼中指定做用域是/app/
,意思就是說,Service Workder 能夠控制的path是相似於app
/app/home/
/app/abbout/
等內部目錄,而不能訪問 /
'/images'等 /app
更上一次層的path。
若是Service Worker 已經安裝了,再次註冊會返回當前活動的registration對象。
chrome瀏覽器已經很好的支持了Service Worker的debug功能,可在瀏覽器輸入chrome://inspect/#service-workers
查看是否註冊成功了。 或者在控制檯的application選項查看。
install事件綁定在Service Worker文件中,當安裝成功後,install事件就會被觸發。 通常咱們會在install事件裏面進行緩存的處理,用到以前提到的Cahce API
,它是一個Service Worker上的全局對象[5],能夠緩存網絡相應的資源,並根據他們的請求生成key,這個API和瀏覽器標準的緩存工做原理類似,可是隻是針對本身的scope域的,緩存會一直存在,知道手動清楚或者刷新。
var cacheName = 'cachev1'
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(cacheName).then(function(cache) {
return cache.addAll(
[
'/css/bootstrap.css',
'/css/main.css',
'/js/bootstrap.min.js',
'/js/jquery.min.js',
'/offline.html'
]
);
})
);
});
複製代碼
event.waitUntil()
來確保,Service Worker不會在waitUntil()
執行完成以前安裝完成。caches.open()
建立一個cachev1的新緩存,返回一個緩存的promise對象,當它resolved時候,咱們在then方法裏面用caches.addAll
來添加想要緩存的列表,列表是一個數組,裏面的URL是相對於origin的。當 Service Worker 安裝完成後並進入激活狀態,會觸發 activate 事件。經過監聽 activate 事件你能夠作一些預處理,如對舊版本的更新、對無用緩存的清理等。
service-worker.js
控制着頁面資源和請求的緩存,若是 js 內容有更新,當訪問網站頁面時瀏覽器獲取了新的文件,逐字節比對js 文件發現不一樣時它會認爲有更新啓動 更新算法,因而會安裝新的文件並觸發 install 事件。可是此時已經處於激活狀態的舊的 Service Worker 還在運行,新的 Service Worker 完成安裝後會進入 waiting 狀態。直到全部已打開的頁面都關閉,舊的 Service Worker 自動中止,新的 Service Worker 纔會在接下來從新打開的頁面裏生效。
若是但願在有了新版本時,全部的頁面都獲得及時自動更新怎麼辦呢?能夠在 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 !== 'cachev1') {
return caches.delete(cacheName);
}
})
);
})
])
);
});
複製代碼
當js 文件可能會由於瀏覽器緩存問題,當文件有了變化時,瀏覽器裏仍是舊的文件。這會致使更新得不到響應。如遇到該問題,可嘗試這麼作:在 Web Server 上添加對該文件的過濾規則,不緩存或設置較短的有效期。
或者手動調用update()
來更新
navigator.serviceWorker.register('/service-worker.js').then(reg => {
// sometime later…
reg.update();
});
複製代碼
能夠結合localStorage來使用,沒必要每次加載更新
var version = 'v1';
navigator.serviceWorker.register('/service-worker.js').then(function (reg) {
if (localStorage.getItem('sw_version') !== version) {
reg.update().then(function () {
localStorage.setItem('sw_version', version)
});
}
});
複製代碼
示意圖
每一個狀態都會有ing
,進行態。
選擇正確的存儲機制對於本地設備存儲和基於雲的服務器存儲都很是重要。 良好的存儲引擎可確保以可靠的方式保存信息,並減小帶寬和提高響應能力。正確的存儲緩存策略是實現離線移動網頁體驗的核心構建基塊。
存儲的類別,存儲的持久化,瀏覽器支持狀況等緣由,如何更高效的存儲是咱們討論的重點。
Best Practices for Using IndexedDB
Inspect and Manage Storage, Databases, and Caches
針對於離線存儲數據,建議能夠有:
基本原理
上面的兩個API都是異步的(IndexedDB是基於事件的,而Cache API是基於Promise的)。他們能夠與web Workers
windows
service workers
一塊兒使用。IndexedDB基本能夠在全部瀏覽器環境使用(參看上面的CanIUse),Service Wokers和Cahce API的支持狀況,能夠經過上面的圖看到,已經支持Chrome,Firefox,Opera。IndexedDB的Promise包裝器隱藏了IndexedDB庫自帶的一些強大但同時很是複雜的machinery(例如:事務處理 transactions,架構版本schema versioning)。IndexedDB將支持observers,這個特性能夠輕鬆實現標籤之間的同步。
對於PWA,咱們能夠緩存靜態資源,從而使用 Cache API 編寫的應用 Application Shell(JS/CSS/HTML 文件),並從 IndexedDB 填充離線頁面數據。
對於Web Storage(LocalStorage/SessionStorage)是同步的,不支持 Web worker線程,而且有大小和類型(僅限字符串)的限制。
容許將站點添加至主屏幕,是 PWA 提供的一項重要功能。雖然目前部分瀏覽器已經支持向主屏幕添加網頁快捷方式以方便用戶快速打開站點,可是 PWA 添加到主屏幕的不只僅是一個網頁快捷方式,它將提供更多的功能,讓 PWA 具備更加原生的體驗。
PWA 添加至桌面的功能實現依賴於 manifest.json
。
爲了實現 PWA 應用添加至桌面的功能,除了要求站點支持 HTTPS 以外,還須要準備 manifest.json 文件去配置應用的圖標、名稱等信息。舉個例子,一個基本的 manifest.json 應包含以下信息:
{
"name": "Easyify Docs",
"short_name": "Easyify Docs",
"start_url": "/",
"theme_color": "#FFDF00",
"background_color": "#FFDF00",
"display":"standalone",
"description": "A compilation tools for FE, built with webpack4.x, compile faster and smart, make work easier.",
"icons": [
{
"src": "./_assets/icons/32.png",
"sizes": "32x32",
"type": "image/png"
}
],
...
}
複製代碼
使用 link 標籤將 manifest.json 部署到 PWA 站點 HTML 頁面的頭部,以下所示:
<link rel="manifest" href="path-to-manifest/manifest.json">
複製代碼
參數解釋:
name: {string} 應用名稱,用於安裝橫幅、啓動畫面顯示
short_name: {string} 應用短名稱,用於主屏幕顯示
icons: {Array.<ImageObject>} 應用圖標列表
src: {string} 圖標 url
type {string=} 圖標的 mime 類型,非必填項,該字段可以讓瀏覽器快速忽略掉不支持的圖標類型
sizes {string} 圖標尺寸,格式爲widthxheight,寬高數值以 css 的 px 爲單位。若是須要填寫多個尺寸,則使用空格進行間隔,如"48x48 96x96 128x128"
start_url: {string=} 應用啓動地址
scope: {string} 做用域
// scope 應遵循以下規則:
//若是沒有在 manifest 中設置 scope,則默認的做用域爲 manifest.json 所在文件夾;
//scope 能夠設置爲 ../ 或者更高層級的路徑來擴大PWA的做用域;
//start_url 必須在做用域範圍內;
//若是 start_url 爲相對地址,其根路徑受 scope 所影響;
//若是 start_url 爲絕對地址(以 / 開頭),則該地址將永遠以 / 做爲根地址;
background_color: {Color} css色值 能夠指定啓動畫面的背景顏色。
display: {string} 顯示類型
//fullscreen 應用的顯示界面將佔滿整個屏幕
//standalone 瀏覽器相關UI(如導航欄、工具欄等)將會被隱藏
//minimal-ui 顯示形式與standalone相似,瀏覽器相關UI會最小化爲一個按鈕,不一樣瀏覽器在實現上略有不一樣
//browser 瀏覽器模式,與普通網頁在瀏覽器中打開的顯示一致
orientation: string 應用顯示方向
//orientation屬性的值有如下幾種:
//landscape-primary
//landscape-secondary
//landscape
//portrait-primary
//portrait-secondary
//portrait
//natural
//any
theme_color: {Color} // css色值theme_color 屬性能夠指定 PWA 的主題顏色。能夠經過該屬性來控制瀏覽器 UI 的顏色。好比 PWA 啓動畫面上狀態欄、內容頁中狀態欄、地址欄的顏色,會被 theme_color 所影響。
related_applications: Array.<AppInfo> 關聯應用列表 能夠引導用戶下載原生應用
platform: {string} 應用平臺
id: {string} 應用id
複製代碼
咱們都是通知就是在咱們設備上彈出的消息。通知能夠是本地觸發的,也能夠是服務器推送的,並且咱們的應用當時並無運行。消息推送可使App的更新提醒,也多是咱們感興趣的內容。
當咱們的web能夠實現push的時候,web的體驗就裏Native APP更近一步了。
Push Notifications 由兩個API構成:
這兩個API都是創建在在Service Worker API基礎上的,Service Worker 在後臺響應推送消息時間,並把他們傳遞給應用。
Notification
在建立通知以前,應該先獲取用戶的許可:
// main.js
Notification.requestPermission(function(status) {
console.log('Notification permission status:', status);
//status 會有三個取值default granted denied 分別表明: 默認值(每次訪問頁面都詢問)、 容許、拒絕
});
複製代碼
獲取到用戶的許可以後,就能夠經過 showNotification()
方法來限制主應用程序的通知。
// main.js
function displayNotification() {
if (Notification.permission == 'granted') {
navigator.serviceWorker.getRegistration().then(function(reg) {
reg.showNotification('Hello world!');
});
}
}
複製代碼
要注意showNotification
,在Service Woker註冊對象上調用該方法。將在活動Service Worker上建立通知,以便監聽與通知交互觸發的事件。
showNotification
方法有可選項參數options
,用於配置通知。
// main.js
function displayNotification() {
if (Notification.permission == 'granted') {
navigator.serviceWorker.getRegistration().then(function(reg) {
var options = {
body: 'Here is a notification body!', // 對通知添加描述
icon: 'images/example.png', // 添加一個icon圖像
vibrate: [100, 50, 100], // 指定通知的電話振動模式,手機將振動100ms,暫停50ms,再次振動100ms
data: {
dateOfArrival: Date.now(),
primaryKey: 1
}, // 給通知添加自定義數據,當監聽到通知的時候,能夠捕獲到這些數據,方便使用。
actions: [
{action: 'explore', title: 'Explore this new world',
icon: 'images/checkmark.png'},
{action: 'close', title: 'Close notification',
icon: 'images/xmark.png'},
] // 自定義的操做
};
reg.showNotification('Hello world!', options);
});
}
}
複製代碼
用戶收到通知以後,經過對通知的操做,就會觸發監聽的Notifications的相關事件,好比在關閉通知的時候就會有notificationclose
事件。
// service-worker.js
self.addEventListener('notificationclick', function(e) {
var notification = e.notification;
var primaryKey = notification.data.primaryKey;
var action = e.action;
if (action === 'close') {
notification.close();
} else {
clients.openWindow('http://www.example.com');
notification.close();
}
});
複製代碼
Push
通知操做要結合push,才能實現與用戶的交互,主動通知、提醒用戶
每一個瀏覽器都有一個push service
(推送服務),當用戶受權當前網站的push權限的時候,就能夠將當前網站訂閱到瀏覽器的push service
。這就會建立一個訂約對象,其中包含推送服務的endpoint和公鑰(keys)。當下發push消息的時候,就會發送到endpoint這個URL,並用公鑰進行加密,push service
就會發送到正確的客戶端。
推送服務如何知道將消息發送到哪一個客戶端?端點URL包含惟一標識符。此標識符用於路由您發送到正確設備的消息,並在瀏覽器處理時標識應處理請求的Service Worker。
推送通知和Service Worker是匹配工做的,因此要求推送通知也必須是HTTPS,這就確保了服務器和push service之間通訊是安全的,而且從push service到用戶也是安全的。
可是,HTTPS不能確保push service自己是安全的。咱們必須確保從服務器發送到客戶端的數據不會被任何第三方篡改或直接檢查。因此必須加密服務器上的消息。
整個發送接收展現的過程
在客戶端:
1.訂閱推送服務
2.將訂閱對象發送到服務器
在服務器:
1.生成給用戶下發的數據
2.使用用戶的公鑰加密數據
3.使用加密數據的有效負載將數據發送的endpoint URL
消息將路由到用戶的設備。喚醒瀏覽器,找到正確的Service Worker並調用推送事件。
1.在推送事件中接收消息數據(若是有)
2.在推送事件中執行自定義邏輯
3.顯示通知
當支持推送消息的瀏覽器收到消息時,它會向Service Worker發送一個push
事件。咱們能夠在Service Worker中建立一個 push
事件監聽器來處理消息:
// service-worker.js
self.addEventListener('push', function(e) {
var options = {
body: 'This notification was generated from a push!',
icon: 'images/example.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: '2'
},
actions: [
{action: 'explore', title: 'Explore this new world',
icon: 'images/checkmark.png'},
{action: 'close', title: 'Close',
icon: 'images/xmark.png'},
]
};
e.waitUntil(
self.registration.showNotification('Hello world!', options)
);
});
複製代碼
與以前不一樣的地方就是,這裏監聽的是push事件,以前是notification事件,而且,這裏用了event.waitUntil方法來延長push事件的生命週期,到showNotification異步操做執行完成。
在發送推送消息以前,咱們必須首先訂閱推送服務。訂閱返回訂閱對象或者是一個subscription
。它是整個過程當中很關鍵一個部分,咱們才能知道push發送到哪裏。
// main.js
//檢查是否訂閱了
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').then(function (reg) {
console.log('Service Worker Registered!', reg);
reg.pushManager.getSubscription().then(function (sub) {
if (sub === null) {
// Update UI to ask user to register for Push
console.log('Not subscribed to push service!');
} else {
// We have a subscription, update the database
console.log('Subscription object: ', sub);
}
});
})
.catch(function (err) {
console.log('Service Worker registration failed: ', err);
});
}
function subscribeUser() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(function (reg) {
reg.pushManager.subscribe({
userVisibleOnly: true
}).then(function (sub) {
console.log('Endpoint URL: ', sub.endpoint);
}).catch(function (e) {
if (Notification.permission === 'denied') {
console.warn('Permission for notifications was denied');
} else {
console.error('Unable to subscribe to push', e);
}
});
})
}
}
複製代碼
Web Push協議是發送發往瀏覽器的推送消息的正式標準。它描述瞭如何建立推送消息,加密推送消息並將其發送到推送消息傳遞平臺的結構和流程。該協議抽象出用戶具備哪一個消息傳遞平臺和瀏覽器的細節。
Web Push協議很複雜,但咱們不須要了解全部細節。瀏覽器自動負責使用推送服務訂閱用戶。做爲開發人員,咱們的工做是獲取訂閱令牌,提取URL並向那裏發送消息。
{"endpoint":"https://fcm.googleapis.com/fcm/send/dpH5lCsTSSM:APA91bHqjZxM0VImWWqDRN7U0a3AycjUf4O-byuxb_wJsKRaKvV_iKw56s16ekq6FUqoCF7k2nICUpd8fHPxVTgqLunFeVeB9lLCQZyohyAztTH8ZQL9WCxKpA6dvTG_TUIhQUFq_n",
"keys": {
"p256dh":"BLQELIDm-6b9Bl07YrEuXJ4BL_YBVQ0dvt9NQGGJxIQidJWHPNa9YrouvcQ9d7_MqzvGS9Alz60SZNCG3qfpk=",
"auth":"4vQK-SvRAN5eo-8ASlrwA=="
}
}
複製代碼
一般用VSPID身份驗證來識別身份。 直接上一個例子
//main.js
var endpoint;
var key;
var authSecret;
// We need to convert the VAPID key to a base64 string when we subscribe
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;
}
function determineAppServerKey() {
var vapidPublicKey = 'BAyb_WgaR0L0pODaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY';
return urlBase64ToUint8Array(vapidPublicKey);
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').then(function (registration) {
return registration.pushManager.getSubscription()
.then(function (subscription) {
if (subscription) {
// We already have a subscription, let's not add them again
return;
}
return registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: determineAppServerKey()
})
.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('http://localhost:3111/register', {
method: 'post',
headers: new Headers({
'content-type': 'application/json'
}),
body: JSON.stringify({
endpoint: subscription.endpoint,
key: key,
authSecret: authSecret,
}),
})
});
});
}).catch(function (err) {
// registration failed :(
console.log('ServiceWorker registration failed: ', err);
});
}
複製代碼
// server.js
const webpush = require('web-push');
const express = require('express');
var bodyParser = require('body-parser');
var path = require('path');
const app = express();
// Express setup
app.use(express.static('public'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ // to support URL-encoded bodies
extended: true
}));
function saveRegistrationDetails(endpoint, key, authSecret) {
// Save the users details in a DB
}
webpush.setVapidDetails(
'mailto:contact@deanhume.com',
'BAyb_WgaR0L0pODaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY',
'p6YVD7t8HkABoez1CvVJ5bl7BnEdKUu5bSyVjyxMBh0'
);
// Send a message
app.post('/sendMessage', function (req, res) {
var endpoint = req.body.endpoint;
var authSecret = req.body.authSecret;
var key = req.body.key;
const pushSubscription = {
endpoint: req.body.endpoint,
keys: {
auth: authSecret,
p256dh: key
}
};
var body = 'Breaking News: Nose picking ban for Manila police';
var iconUrl = 'https://raw.githubusercontent.com/deanhume/progressive-web-apps-book/master/chapter-6/push-notifications/public/images/homescreen.png';
webpush.sendNotification(pushSubscription,
JSON.stringify({
msg: body,
url: 'http://localhost:3111/article?id=1',
icon: iconUrl,
type: 'actionMessage'
}))
.then(result => {
console.log(result);
res.sendStatus(201);
})
.catch(err => {
console.log(err);
});
});
// Register the user
app.post('/register', function (req, res) {
var endpoint = req.body.endpoint;
var authSecret = req.body.authSecret;
var key = req.body.key;
// Store the users registration details
saveRegistrationDetails(endpoint, key, authSecret);
const pushSubscription = {
endpoint: req.body.endpoint,
keys: {
auth: authSecret,
p256dh: key
}
};
var body = 'Thank you for registering';
var iconUrl = '/images/homescreen.png';
webpush.sendNotification(pushSubscription,
JSON.stringify({
msg: body,
url: 'https://localhost:3111',
icon: iconUrl,
type: 'register'
}))
.then(result => {
console.log(result);
res.sendStatus(201);
})
.catch(err => {
console.log(err);
});
});
// The server
app.listen(3111, function () {
console.log('Example app listening on port 3111!')
});
複製代碼
後面再詳細說整個push過程。 也能夠看先Google給出的教程描述developers.google.com/web/ilt/pwa…