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

Service Worker

life cycle & caches

service worker

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

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