Use JavaScript running web app in the background

Photo by Andrew Neel on Unsplash

Use JavaScript running web app in the background

有時有需求在背景時仍然要同步資料,當 User 切回頁面時,仍然可以看到最及時的資料。或是發送通知給 User、持續上傳檔案等需求,都需要使用到 javscript 在 Web APP 背景執行的能力。

使瀏覽在背景下繼續執行的方式有許多種,不過以支援度來說最常使用的 Service worker ,並透過 Page Visibility API 確認瀏覽器是否是在背景模式。

Service worker 根據不同的裝置(Mobile, Desktop)與瀏覽器(Chrome, Firefox, Safari, Chromium base 瀏覽器)支援度都不盡相同,目前 Chrome 與 Chromium base browser 支援大部分的 API,其餘瀏覽器大多支援 Web push。

瀏覽器的狀態可以大致分爲 Active、Passive、Hidden、Frozen 與 Discard。
PWA 也有類似的狀態,根據不同的裝置與作業系統,狀態下能執行的 API 也不太相同。在手機上,可能會因爲作業系統或是因爲記憶體使用過多而導致 APP Discard。

爲了盡可能的涵蓋所有的裝置,確認目前的狀態是否是在 background 會使用 Page Visibility API(這是所有裝置與瀏覽器都能支援的 API )。

Detect background

透過監聽 visibilitychange event 和使用 visibilityState 確認是否進入 background

document.addEventListener('visibilitychange', (event) => {
    if (document.visibilityState === 'hide') {
        // in the background
    } else {
        // active
    }
})

可以透過此方法查看 User 在 background 的時間有多久、什麼時候回到此頁面等

let backgroundInitTimestamp;
window.addEventListener('visibilitychange', ev => {
  if (document.visibilityState === 'hidden') {
    backgroundInitTimestamp = performance.now();
  } else {
    const diffTime = Math.floor((performance.now() - backgroundInitTimestamp) / 1000);
    console.log(`back from background after ${diffTime}s`)
  }
});

code link

Detect freezing and resume

Freezing 和 Resume 是 chromium base 的瀏覽器特有的事件,可以用來偵測到瀏覽器觸發 Freeze 事件進入暫停(suspended)狀態(之後就不會執行程式),與觸發 Resume 回到原本的狀態正常執行程式。

常用的 use case 是將 state 進行保存,可能後續 OS 釋放 Memory 或是瀏覽器等其他行爲觸發 discard 後,將 state 重新設定。

若是沒有 discard 的話,resume 過後程式的 context 仍然會在(例如 cursor 在哪裡、目前的變數值等),就不需要進行 restore state。

window.addEventListener('freeze', ev => {
    // save state
    saveState();
});

window.addEventListener('resume', ev => {
    // go back from suspended
    // context still there
});

window.addEventListener('DOMContentLoaded', ev => {
    if (document.wasDiscarded) {
        restoreState();
    }
});

大多數情況仍然使用 visibilitychange 較能符合跨平臺的需求。

Service Worker

JavaScript 執行在背景的 thread 有自身的 life cycle,有一些 API 可以在背景進行使用。Service Worker 就像是 user 自行安裝在本機的後端 server。

Service Worker 的 life cycle 會先進行 parse 與 install,安裝完成後可能會先進行在此之前 install 的其他 worker,所以會有 Waiting 的發生。

當 worker 執行後(Activated)會進入到 Idle 的狀態,直到發生 fetch, push, message 等事件發生後才會重新繼續執行,結束後又回到 Idle 的狀態,直到一陣子都沒執行後,瀏覽器會將 worker stop 或是 terminate(無論使用者是否正在使用網頁,這代表了 worker 的自身 life cycle)。

如果又有事件發生,會從新回到執行的狀態,不過此時與上次的 instance 會是不同的,會有新的 context、不同的變數 state 等。

可以前往 chrome://serviceworker-internals 查看目前已經安裝的 Service worker,並查看 worker 的運行狀態。

當觸發事件時,worker 的狀態會變到 running,關閉網頁後仍會繼續執行,直到後來才會 stopped。

使用 navigator.serviceWork 註冊 service worker,透過 serviceworker-internals 可以進入 worker 的 console 進行 debug。

navigator.serviceWorker.register("/sw.js");

在 dev tool 上的 Application 也可以看到 Service Workers 的狀態

Web Push API

Client 端詢問 Notification 權限並該網站擁有 Service worker,當擁有權限後 Service Worker 會向 Push Server 進行註冊(Push Server 由第三方提供,例如 Google、Mozilla 、Apple 等),會返回 Endpoint 訊息,當有訊息需要進行推送時,再透過後端傳送資料給 Push Server 觸發 Service Woker 進行 Notification。

向使用者要求權限。

  if ("PushManager" in window) {
    const notificationPermission = await Notification.requestPermission();
    if (notificationPermission === "granted") {
      const registration = await navigator.serviceWorker.ready;
    }
  }

有權限後透過 Service Worker 向 Push Server 註冊,註冊時需帶上 Server key,可以透過 web-psuh 產生。

npx web-push generate-vapid-keys

會產生一組公鑰和私鑰,傳送給 Push Server 進行註冊。

const notificationSub = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: "<Public Key>",
});
console.log(notificationSub);

會返回 Endpoint 訊息。

{
    "endpoint": "https://fcm.googleapis.com/fcm/send/f3-wjn31wZQ:APA91bFp_8wDuHMrKQZWOiPWO10npk3IFMGDx9PBlITWiauuNt-fT8AI280yKw9Jd9I9gjmFcaBYpprI61wkCeUH-iE5KI_2tquQiq6dhlB6MPSj8ZtEqVvlSDiRLtxMwfbcezWjSUTU",
    "expirationTime": null,
    "keys": {
        "p256dh": "..."(Array buffer),
        "auth": "..."(Array buffer)
    }

每個 User (使用不同的瀏覽器) 所返回的 endpoint 與 Key 都會不相同,或是 User 升級了 OS 等狀況都會進行改變,所以最好的做法是每次都進行 Subscribe Push Server 並將每次獲得的資訊儲存。

每次向 Push Server 發送時,皆可以得知目前的 Subscribe 是否已經過期,或是 User 更改了設定不行允許進行通知。

透過 web-psuh library 發送通知。

const webpush = require("web-push");

webpush.setVapidDetails(
  "<domain name>",
  "<public key>",
  "<private key>"
);
// 註冊 Push server 獲得的 detail 訊息
const pushSubscription = {
  endpoint: "...",
  keys: {
    p256dh: "...",
    auth: "...",
  },
};

webpush
  .sendNotification(pushSubscription, "Push message", {})
  .then((result) => {
    console.log(result);
  })
  .catch((e) => {
    console.log(e);
  });

在 Service Worker 監聽 push 事件。

self.addEventListener("push", (event) => {
  console.log(event);
});

若是使用者已經取消允許權限或是憑證過期的話,在發送 Web Push 時就會發生 401 的錯誤,並告知使用者的權限不允許或是憑證已經過期,必須使用新的 Push Server 訂閱訊息。

{
  statusCode: 410,
  headers: {
    'content-type': 'text/plain; charset=utf-8',
    'x-content-type-options': 'nosniff',
    'x-frame-options': 'SAMEORIGIN',
    'x-xss-protection': '0',
    date: 'Sun, 21 May 2023 02:15:06 GMT',
    'content-length': '47',
    'alt-svc': 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000'
  },
  body: 'push subscription has unsubscribed or expired.\n',
  endpoint: '...'

成功監聽 push event 時,可以在 Service Worker 進行通知。

self.addEventListener("push", (event) => {
  if (event.data) {
    console.log(event.data.text());
    event.waitUntil(
      (function () {
        self.registration.showNotification("title", {
          body: event.data.text(),
        });
      })()
    );
  }
});

code link

Desktop Notification API

Desktop Notification API 和 Web Push 兩者都需要使用者同意 Notification 的權限,Desktop Notification API 是使用 Web Worker 或是 window 進行通知。

先確認是否有權限,若沒有的話進行詢問。

const checkNotificationPermission = async () => {
  if ('Notification' in window) {
    if (Notification.permission === 'granted') return true;
    return (await Notification.requestPermission()) === 'granted';
  }
  return false
}

await Notification.requestPermission() 當 User 點擊 x 未進行拒絕或是同意時,回傳 default,下次 User 再次瀏覽網頁的話會重新詢問一次,若是點擊拒絕的話將不會再次詢問,需要使用者手動開啓。

之後就可以直接使用 new Notification(title, options) 發送通知。

if (checkNotificationPermission()) {
  const n = new Notification('Hello world', { body: 'hello from tp6gw94' });
}

code link

Media Session API

透過 Media Session API 可以傳送 Media 的 MetaData 訊息給與 OS,並透過 OS 繼續進行背景播放。

  <audio controls src="media/420-holiday-special.wav"></audio>  
  <button id="playBtn">Play</button>
document.getElementById('playBtn').addEventListener('click', (evt) => {
  navigator.mediaSession.metadata = new MediaMetadata({
    title: '420 holiday special',
    artist: 'Steven F Allen',
    album: '420',
    artwork: [{ src: "/images/420.jpg", type: 'image/jpg', sizes: '800x800' }]
  });
  document.querySelector('audio').play();
});

code link

Beacon API

Beacon API 可以發送出 Network Request,但並不會獲取 Response 而是 Boolean 值。

使用 Beacon API 發送 Request 時,會儲存在 Queue 中,所以無法得知什麼時候會發送 Request 至 Server,就算立刻關閉瀏覽器,發送 Request 的 Queue 仍然會在下次打開瀏覽器時進行發送。

document.getElementById("send").addEventListener("click", () => {
  const data = JSON.stringify({
    msg: "hello",
  });
  navigator.sendBeacon("/log", data);
});
router.post("/log", function (req, res, next) {
  console.log(req.body);
  res.send();
});

不過會發現 console 出的資料是空的,原因是因爲無法直接透過 sendBeacon 設定 Header 的 type,所以需要透過 Blob 進行設定。

document.getElementById("send").addEventListener("click", () => {
  const data = JSON.stringify({
    msg: "hello",
  });
  const blob = new Blob([data], { type: "application/json" });
  navigator.sendBeacon("/log", blob);
});

Background Sync

當使用者的網路連線不穩定時,可以將資料進行暫存,當網路連線穩定時,繼續進行操作。

例如表單提交,在網路不穩定時可以暫存資料,待網路穩定時在進行傳送 API。

  <form id="form">
    <input name="name" value="">
    <input type="input" name="msg" value="">
    <button type="submit">submit</button>
  </form>

Submit 時將資料儲存至 Cache Storage,並註冊 submit-form 的 sync 事件 tag。

const form = document.querySelector("#form");

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  const formData = new FormData(form);
  const data = {
    name: formData.get("name"),
    msg: formData.get("msg"),
  };

  const formCache = await caches.open("form");
  formCache.put("/data", new Response(JSON.stringify(data)));

  if ("SyncManager" in window) {
    const reg = await navigator.serviceWorker.ready;
    reg.sync.register("submit-form");
  }
});

在 Service Worker 監聽 sync 事件,從 Cache 獲取資料並傳送至後端。

self.addEventListener("sync", async (event) => {
  switch (event.tag) {
    case "submit-form": {
      const formCache = await caches.open("form");
      const formDataRes = await formCache.match("/data");
      const data = await formDataRes.json();

      async function submitForm() {
        await fetch("/log", {
          method: "POST",
          body: JSON.stringify(data),
          headers: {
            "Content-Type": "application/json",
          },
        });
      }

      event.waitUntil(submitForm());
      break;
    }
    case "test-tag-from-devtools": {
      console.log("test");
      break;
    }
    default: {
      console.log("unknow tag", event.tag);
    }
  }
});
app.post("/log", (req, res) => {
  console.log("get request", req.body);
  res.send({ status: 200 });
});

測試在 network offline 下並重新回覆連線後會進行發送請求。

git repo

Can I use background-sync

Periodic Background Sync

Interval 的 Background Sync,可以設定多久進行一次 Background Sync,不過需要瀏覽器擁有權限,瀏覽器會依據 Site Engagement Score 判斷是有有權限進行 Periodic Background Sync。

Site Engagement Score 是依照使用者使用該網站的頻率、停留時間、點擊等計算而成,若沒有權限則無法執行此 API,有權限的話會依照瀏覽器允許的 Interval 時間執行程式。若是 Site Engagement Score 的分數不足,但使用者已下載 PWA 則可以直接執行此 API。

透過 chrome://discards 可以查看 Site Engagement Score 的分數。

使用者裝置的電量與網路狀態也會影響此 API 是否可以執行。

詢問是否擁有執行 Periodic Background Sync 的權限。

  const permissionStatus = await navigator.permissions.query({
    name: "periodic-background-sync",
  });

有權限的話就可以執行此 API。

  const registration = await navigator.serviceWorker.ready;
  registration.periodicSync.register("news", {
    minInterval: 12 * 60 * 60 * 1000,
  });

在 Service worker 監聽此事件。

self.addEventListener("periodicsync", (event) => {
  switch (event.tag) {
    case "test-tag-from-devtools": {
      console.log("test-tag-from-devtools");
      event.waitUntil();
    }
    case "news": {
      console.log("news");
      event.waitUntil();
    }
  }
});

Background Fetch

Background Fetch API 可以在關閉瀏覽器的時候繼續下載檔案或是其他資源等,下載並非指將資源下載至 File System,而是將檔案下載至 Service Worker。

使用 backgroundFetch 下載檔案。

  const registration = await navigator.serviceWorker.ready;
  if ("backgroundFetch" in registration) {
    const fetch = await registration.backgroundFetch.fetch(
      "assets",
      ["/assets/test.js", "/assets/test.md"],
      {
        title: "Assets",
      }
    );
    console.log(fetch)
  }

至 Service worker 中取得。

self.addEventListener("backgroundfetchsuccess", (event) => {
  event.waitUntil(
    (async function () {
      const records = await event.registration.matchAll();
      console.log(records);
    })()
  );
});