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
- 401
 
  