PWA
Web app
- 可以透過搜引擎找到
- 可直接透過瀏覽器開啟、透過網址分享
native app
- 和作業系統相容性好,使用、操作體驗佳
- 可安裝,裝在桌面直接打開,比到瀏覽器瀏覽方便。
Progressive Web App
開發者使用特定 技術 和 標準模式,讓開發的 Web application 兼具網頁以及原生應用的優勢。
Advantages
- Discoverability
- Installable
- Linkable
- Netword independent
- Progressive
- Re-engageable
- Responsive
- Safe
Benifits
- 在首次安裝之後,減少之後的載入時間
- 和原生平台整合較好,例如在桌面有 app icon,可以使用全屏模式等等
- 永遠都是最新的,不像 app 需要更新
- 使用系統的通知和推送訊息功能,可能有更多的用戶參與度和更高的轉化率
web app manifest
Add manifest
<link rel="manifest" href="js13kpwa.webmanifest">
.webmanifest or manifest.json
web app manifest
{
"name": "PWA Demo",
"short_name": "PWA Demo",
"description": "Progressive Web App Demo",
"icons": [
{
"src": "icons/icon_x48.png",
"sizes": "48x48",
"type": "image/png"
},
{
"src": "icons/icon_x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "icons/icon_x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "icons/icon_x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/icon_x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "screenshots/screenshot_1.webp",
"sizes": "414x739",
"type": "image/webp"
},
{
"src": "screenshots/screenshot_2.webp",
"sizes": "413x741",
"type": "image/webp"
}
],
"shortcuts": [
{
"name": "Project lists",
"url": "/",
"description": "List of latest project.",
"icons": [
{
"src": "icons/home-icon_x96.png",
"sizes": "96x96",
"type": "image/png"
}
]
},
{
"name": "About",
"url": "/about",
"icons": [
{
"src": "icons/about-icon_x96.png",
"sizes": "96x96",
"type": "image/png"
}
]
},
{
"name": "Admin",
"url": "/admin",
"icons": [
{
"src": "icons/setting-icon_x96.png",
"sizes": "96x96",
"type": "image/png"
}
]
}
],
"categories": ["business", "wharever"],
"start_url": "/",
"display": "fullscreen",
"theme_color": "#ffffff",
"background_color": "#ffffff",
"orientation": "portrait",
"prefer_related_applications": true,
"related_applications": [
{
"platform": "play",
"url": "https://play.google.com/store/apps/details?id=com.google.android.apps.maps",
"id": "com.google.android.apps.maps"
}
]
}
Fields
- name
- short_name
- description
- start_url
- display
- orientation
- theme_color
- background_color
icons
"icons": [
{
"src": "icon/lowres.webp",
"sizes": "48x48",
"type": "image/webp"
},
{
"src": "icon/lowres",
"sizes": "48x48"
},
{
"src": "icon/hd_hi.ico",
"sizes": "72x72 96x96 128x128 256x256"
},
{
"src": "icon/hd_hi.svg",
"sizes": "72x72"
}
]
Maskable Icon


一般 icon
maskable icon
{
…
"icons": [
…
{
"src": "path/to/regular_icon.png",
"sizes": "196x196",
"type": "image/png",
"purpose": "any"
},
{
"src": "path/to/maskable_icon.png",
"sizes": "196x196",
"type": "image/png",
"purpose": "maskable" // <-- New property value `"maskable"`
},
…
],
…
}

Borwser Install
Demo
- PWA Demo (pwa-demo-pi.vercel.app)
- Dev tool manifest
- Install PWA
- icon, shortcuts
- pwa-builder
Service Worker
life cycle & caches
service worker
- intercept and handle network requests
- push notifications
- background sync
- ...
life cycle

Register
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/sw.js", {
scope: "/" // optional 指定想讓 sw 控制的內容目錄
})
.then((reg) => {
console.log(`Register sw. Scope is ${reg.scope}`);
})
.catch((err) => {
console.log(`Fail to register sw. ${err}`);
});
}
sw.js
self.addEventListener("install", function (event) {
//...
});
Install event
var CACHE_NAME = 'my-site-cache-v1';
var urlsToCache = [
'/',
'/styles/main.css',
'/script/main.js'
];
self.addEventListener('install', function(event) {
// Perform install steps
event.waitUntil(
caches.open(CACHE_NAME)
.then(function(cache) {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});
event.waitUntil(Promise)

-
CacheStorage
- delete()
- has()
- keys()
- match()
- open()
-
Cache Object
- add()
- addAll()
- delete()
- keys()
- match()
- matchAll()
- put()
-
Open Cache Storage (with a cache name)
-
將預先定義的資源列表:網站載入需要的資源,存進 cache 裡面
-
如同前面提到的:成功便完成 (installed),任何一個檔案讀取失敗的則 sw 不會 activate,且會在下次瀏覽頁面時嘗試重新 install。
cache and return requests
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request)
.then(function(response) {
// Cache hit - return response
if (response) {
return response;
}
return fetch(event.request);
}
)
);
});
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request)
.then(function(response) {
// Cache hit - return response
if (response) {
return response;
}
return fetch(event.request).then(
function(response) {
// Check if we received a valid response
if(!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// IMPORTANT: Clone the response. A response is a stream
// and because we want the browser to consume the response
// as well as the cache consuming the response, we need
// to clone it so we have two streams.
var responseToCache = response.clone();
caches.open(CACHE_NAME)
.then(function(cache) {
cache.put(event.request, responseToCache);
});
return response;
}
);
})
);
});
Clear old caches
self.addEventListener('activate', function(event) {
var cacheAllowlist = ['pages-cache-v1', 'blog-posts-cache-v1'];
event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(
cacheNames.map(function(cacheName) {
if (cacheAllowlist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
})
);
});
Update Service Worker
- 更新 SW JS file (byte-wise compare)
- 新的 sw 會開始 install event
- 既有的 sw 仍掌控頁面,新的 sw 進入 waiting 狀態
- 當沒有任何 client 使用舊的 sw,舊的 sw 會被清除,改用新的 sw,觸發 activate event
- Cache Strategies
- Workbox | Google Developers
Demo
- life cycle
- caches
- offline
- update
Push Notification
Notification API
Notification.requestPermission().then(function (permission) {
// If the user accepts, let's create a notification
if (permission === "granted") {
// eslint-disable-next-line no-new
new Notification("See what's new!", {
body: "Explore thousands of latest projects",
icon: "/icons/icon_x96.png",
// other options
});
}
});
Don't use it
- Andoird: Uncaught TypeError: Failed to construct ‘Notification’: Illegal constructor. Use ServiceWorkerRegistration.showNotification() instead
- IOS safari: not support
Push API
- client-side: 訂閱要推送的用戶
- server-side: 打 web push API 推送訊息到用戶的裝置上
- service worker 接收到 push event,顯示 notification
subscribe a user to push messaging
Push service
- validate request
- send push message to device browser
- queue messages
- network recovered
- message expired
- queue rule
- time-to-live
- urgency
- topic
PushSubscription
{
"endpoint": "https://random-push-service.com/some-kind-of-unique-id-1234/v2/",
"keys": {
"p256dh" :
"BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM=",
"auth" : "tBHItJI5svbpez7KI4CCXg=="
}
}
Web Push API
Push 實作
Subscribe a user
ServiceWorkerRegistration.pushManager
navigator.serviceWorker.register('/sw.js')
.then(function(registration) {
if (!registration.active) return registration; // sw might not active yet
const subscribeOptions = {
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
"Your VAPID Public key"
)
};
return registration.pushManager.subscribe(subscribeOptions);
})
navigator.serviceWorker.ready.then((registration) => {
// subscribe user
const subscribeOptions = {
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
"Your VAPID Public key"
),
};
return registration.pushManager.subscribe(subscribeOptions);
});
subscribe options
- userVisibleOnly
- silent push
- applicationServerKey
- VAPID
流程
- 加載頁面,呼叫 subscribe() 並傳入 public application server key
- browser 向 push service 發送請求,push service 產生 endpoint 並與 public key 做關聯後回傳
- browser 將 endpoint 加入 PushSubscription 中,回傳給我們
Web Push API
Save push subscription
registration.pushManager
.subscribe(subscribeOptions)
.then((pushSubscription) => {
return fetch("/api/subscription/", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(pushSubscription),
});
});
Push message from server
import webpush from "web-push";
// setup
webpush.setVapidDetails(
'mailto:web-push-book@gauntface.com',
vapidKeys.publicKey,
vapidKeys.privateKey
);
// send message to every subscription
findAllSubsInDB().then((subscriptions) =>
subscriptions.map((subscription) =>
webpush.sendNotification(subscription, JSON.stringify({
title: "Hello"
}))
)
);
Service worker
handle push event
self.addEventListener('push', function(event) {
// Returns string
event.data.text()
// Parses data as JSON string and returns an Object
event.data.json()
// Returns blob of data
event.data.blob()
// Returns an arrayBuffer
event.data.arrayBuffer()
});
self.addEventListener("push", function (event) {
const options = {};
const promiseChain = self.registration.showNotification(
"Hello, World.",
options
);
event.waitUntil(promiseChain);
});
Notification Options
{
"//": "Visual Options",
"body": "<String>",
"icon": "<URL String>",
"image": "<URL String>",
"badge": "<URL String>",
"vibrate": "<Array of Integers>",
"sound": "<URL String>",
"dir": "<String of 'auto' | 'ltr' | 'rtl'>",
"//": "Behavioral Options",
"tag": "<String>",
"data": "<Anything>",
"requireInteraction": "<boolean>",
"renotify": "<Boolean>",
"silent": "<Boolean>",
"//": "Both visual & behavioral options",
"actions": "<Array of Strings>",
"//": "Information Option. No visual affect.",
"timestamp": "<Long>"
}
通知行為
self.addEventListener('notificationclick', function(event) {
const clickedNotification = event.notification;
clickedNotification.close();
// Do something as the result of the notification click
const promiseChain = doSomething();
event.waitUntil(promiseChain);
});
- open window
- focus window
- merging notification
self.addEventListener('notificationclose', function(event) {
const dismissedNotification = event.notification;
const promiseChain = notificationCloseAnalytics();
event.waitUntil(promiseChain);
});
分析用戶對通知的參與
Demo
- Push Notification
- Better UX
Background Sync
example
register a sync
navigator.serviceWorker.ready.then(function(swRegistration) {
return swRegistration.sync.register('myFirstSync');
});
listen to sync
self.addEventListener('sync', function(event) {
if (event.tag == 'myFirstSync') {
event.waitUntil(doSomeStuff());
}
});
Periodic Background Sync
limitation
- installed
- site-engagement
- about://site-engagement/
- previous network
const status = await navigator.permissions.query({
name: 'periodic-background-sync',
});
if (status.state === 'granted') {
// Periodic background sync can be used.
} else {
// Periodic background sync cannot be used.
}
const registration = await navigator.serviceWorker.ready;
if ('periodicSync' in registration) {
try {
await registration.periodicSync.register('content-sync', {
// An interval of one day.
minInterval: 24 * 60 * 60 * 1000,
});
} catch (error) {
// Periodic background sync cannot be used.
}
}
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'content-sync') {
// See the "Think before you sync" section for
// checks you could perform before syncing.
event.waitUntil(syncContent());
}
// Other logic for different tags as needed.
});
summarize
- add web manifest
-
add service worker
- caches
- push notification
- background sync
- periodic background sync
Capacity, bugs...
Browsers implementation


- fall back
- nice to have features
- User don't know PWA
PWA
By Timothy Lee
PWA
Progressive web app
- 377