讓使用者訂閱推播通知

Matt Gaunt

如要傳送推播訊息,您必須先取得使用者授權,然後將他們的裝置訂閱推播服務。這包括使用 JavaScript API 取得 PushSubscription 物件,然後傳送至伺服器。

JavaScript API 可直接管理這項程序。本指南將說明整個流程,包括偵測功能、要求權限及管理訂閱程序。

特徵偵測

首先,請確認瀏覽器是否支援推播訊息。你可以透過以下兩種檢查方式,確認是否支援推送通知:

  • 檢查 navigator 物件是否有 serviceWorker
  • 檢查 window 物件是否有 PushManager
if (!('serviceWorker' in navigator)) {
  // Service Worker isn't supported on this browser, disable or hide UI.
  return;
}

if (!('PushManager' in window)) {
  // Push isn't supported on this browser, disable or hide UI.
  return;
}

雖然瀏覽器對 Service Worker 和即時訊息的支援度越來越高,但請務必偵測這兩項功能,並逐步強化應用程式。

註冊 Service Worker

偵測功能後,您會知道服務工作人員和即時訊息功能是否受到支援。接著,註冊 Service Worker。

註冊 Service Worker 時,您會告知瀏覽器 Service Worker 檔案的位置。這個檔案是 JavaScript 檔案,但瀏覽器會授予檔案存取 Service Worker API 的權限,包括推送訊息。具體來說,瀏覽器會在 Service Worker 環境中執行檔案。

如要註冊 Service Worker,請呼叫 navigator.serviceWorker.register() 並傳遞檔案路徑。例如:

function registerServiceWorker() {
  return navigator.serviceWorker
    .register('/service-worker.js')
    .then(function (registration) {
      console.log('Service worker successfully registered.');
      return registration;
    })
    .catch(function (err) {
      console.error('Unable to register service worker.', err);
    });
}

這項函式會告知瀏覽器服務工作人員檔案的位置。這裡的服務工作人員檔案位於 /service-worker.js。呼叫 register() 後,瀏覽器會執行下列步驟:

  1. 下載 Service Worker 檔案。

  2. 執行 JavaScript。

  3. 如果檔案正確執行且沒有錯誤,register() 傳回的 Promise 就會解析。如果發生錯誤,Promise 會遭到拒絕。

注意:如果 register() 遭到拒絕,請在 Chrome 開發人員工具中檢查 JavaScript 是否有錯別字或錯誤。

register() 解析時,會傳回 ServiceWorkerRegistration。您可以使用這項註冊資訊存取 PushManager API

PushManager API 瀏覽器相容性

Browser Support

  • Chrome: 42.
  • Edge: 17.
  • Firefox: 44.
  • Safari: 16.

Source

要求權限

註冊服務工作人員並取得權限後,請先徵求使用者同意,再傳送推播訊息。

取得權限的 API 很簡單。不過,API 最近已從接受回呼改為傳回 Promise。由於您無法判斷瀏覽器實作的 API 版本,因此必須實作並處理這兩個版本。

function askPermission() {
  return new Promise(function (resolve, reject) {
    const permissionResult = Notification.requestPermission(function (result) {
      resolve(result);
    });

    if (permissionResult) {
      permissionResult.then(resolve, reject);
    }
  }).then(function (permissionResult) {
    if (permissionResult !== 'granted') {
      throw new Error("We weren't granted permission.");
    }
  });
}

在上述程式碼中,對 Notification.requestPermission() 的呼叫會向使用者顯示提示:

在電腦和行動裝置上的 Chrome 顯示權限提示。

使用者選取「允許」、「封鎖」或關閉權限提示後,您會收到結果字串:'granted''default''denied'

在範例程式碼中,如果獲得授權,askPermission() 傳回的 Promise 會解析;否則會擲回錯誤,且 Promise 會遭到拒絕。

處理使用者點選「封鎖」按鈕的極端情況。如果發生這種情況,您的網路應用程式就無法再次要求使用者授予權限。使用者必須在設定面板中變更應用程式的權限狀態,才能手動解除封鎖。請謹慎考慮要求權限的時機和方式,因為使用者點選「封鎖」後,不容易撤銷這項決定。

如果使用者瞭解應用程式要求權限的原因,通常會授予權限。

本文稍後會說明一些熱門網站要求權限的方式。

使用 PushManager 訂閱使用者

註冊服務工作人員並取得權限後,您就可以呼叫 registration.pushManager.subscribe() 訂閱使用者。

function subscribeUserToPush() {
  return navigator.serviceWorker
    .register('/service-worker.js')
    .then(function (registration) {
      const subscribeOptions = {
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(
          'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U',
        ),
      };

      return registration.pushManager.subscribe(subscribeOptions);
    })
    .then(function (pushSubscription) {
      console.log(
        'Received PushSubscription: ',
        JSON.stringify(pushSubscription),
      );
      return pushSubscription;
    });
}

呼叫 subscribe() 方法時,您會傳遞包含必要和選用參數的 options 物件。

本節將說明可傳遞的選項。

userVisibleOnly 選項

瀏覽器剛開始支援推播訊息時,開發人員不確定是否能在不顯示通知的情況下傳送推播訊息。這類通知通常稱為無聲推播,因為使用者不會知道背景發生了事件。

因為開發人員可能會在使用者不知情的情況下,持續追蹤他們的位置資訊。

為避免這種情況,並讓規格作者考慮如何以最佳方式支援這項功能,我們新增了 userVisibleOnly 選項。傳遞 true 值代表您同意瀏覽器,在每次收到即時訊息時顯示通知 (即非無聲即時訊息)。

必須傳遞 true 的值。如果未加入 userVisibleOnly 金鑰或傳遞 false,您會收到下列錯誤訊息:

Chrome currently only supports the Push API for subscriptions that will result
in user-visible messages. You can indicate this by calling
`pushManager.subscribe({userVisibleOnly: true})` instead. See
[https://goo.gl/yqv4Q4](https://goo.gl/yqv4Q4) for more details.

Chrome 僅支援會產生使用者可見訊息的訂閱項目。如要指出這點,請呼叫 pushManager.subscribe({userVisibleOnly: true})。詳情請參閱 https://goo.gl/yqv4Q4

Chrome 似乎不會實作全面無聲推送功能。規格作者正在探索預算 API,讓網頁應用程式根據用量傳送特定數量的無聲推播訊息。

applicationServerKey 選項

這份文件先前提到應用程式伺服器金鑰。推送服務會使用應用程式伺服器金鑰,識別訂閱使用者的應用程式,並確保該應用程式會傳送訊息給使用者。

應用程式伺服器金鑰是應用程式專用的公開和私密金鑰組。請勿將應用程式的私密金鑰告知他人,但可自由分享公開金鑰。

傳遞至 subscribe() 呼叫的 applicationServerKey 選項是應用程式的公開金鑰。瀏覽器會在訂閱使用者時將這個金鑰傳遞至推送服務,讓推送服務將應用程式的公開金鑰與使用者的 PushSubscription 建立關聯。

下圖說明這些步驟。

  1. 在瀏覽器中載入網頁應用程式,然後呼叫 subscribe(),並傳遞公開應用程式伺服器金鑰。
  2. 瀏覽器接著會向推送服務發出網路要求,該服務會產生端點、將這個端點與應用程式的公開金鑰建立關聯,然後將端點傳回瀏覽器。
  3. 瀏覽器會將這個端點新增至 PushSubscription,而 subscribe() 承諾會傳回這個端點。

圖表:說明 `subscribe()` 方法如何使用公開應用程式伺服器金鑰。

傳送即時訊息時,請建立 Authorization 標頭,其中包含以應用程式伺服器的私密金鑰簽署的資訊。推送服務收到傳送推播訊息的要求時,會查詢與接收要求的端點連結的公開金鑰,藉此驗證這個已簽署的「Authorization」標頭。如果簽章有效,推送服務就會知道要求來自相符私密金鑰的應用程式伺服器。這項安全措施可防止他人傳送訊息給應用程式使用者。

圖表:說明傳送訊息時如何使用私有應用程式伺服器金鑰。

技術上來說,applicationServerKey 為選填。不過,Chrome 上最簡單的實作方式需要這個屬性,其他瀏覽器日後可能也會要求這個屬性。在 Firefox 上,這項設定為選用。

VAPID 規格定義了應用程式伺服器金鑰。如果您看到應用程式伺服器金鑰或 VAPID 金鑰的參照,請記住兩者相同。

建立應用程式伺服器金鑰

您可以前往 web-push-codelab.glitch.me,或使用 web-push 指令列產生應用程式伺服器金鑰,如下所示:

    $ npm install -g web-push
    $ web-push generate-vapid-keys

請只為應用程式建立一次這些金鑰,並確保私密金鑰不會外洩。

權限和 subscribe()

呼叫 subscribe() 會產生一個副作用。如果您在呼叫 subscribe() 時,網頁應用程式沒有顯示通知的權限,瀏覽器會為您要求權限。如果您的 UI 適用於這個流程,這項功能就很有用。但如果您想要更多控制權 (大多數開發人員都是如此),請使用本文稍早討論的 Notification.requestPermission() API。

PushSubscription 總覽

您會呼叫 subscribe()、傳遞選項,並收到會解析為 PushSubscription 的 Promise。例如:

function subscribeUserToPush() {
  return navigator.serviceWorker
    .register('/service-worker.js')
    .then(function (registration) {
      const subscribeOptions = {
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(
          'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U',
        ),
      };

      return registration.pushManager.subscribe(subscribeOptions);
    })
    .then(function (pushSubscription) {
      console.log(
        'Received PushSubscription: ',
        JSON.stringify(pushSubscription),
      );
      return pushSubscription;
    });
}

PushSubscription 物件包含向該使用者傳送推播訊息所需的所有資訊。如果您使用 JSON.stringify() 列印內容,會看到下列項目:

    {
      "endpoint": "https://some.pushservice.com/something-unique",
      "keys": {
        "p256dh":
    "BIPUL12DLfytvTajnryr2PRdAgXS3HGKiLqndGcJGabyhHheJYlNGCeXl1dn18gSJ1WAkAPIxr4gK0_dQds4yiI=",
        "auth":"FPssNDTKnInHVndSTdbKFw=="
      }
    }

endpoint 是推送服務的網址。如要觸發推播訊息,請對這個網址發出 POST 要求。

keys 物件包含用於加密透過即時訊息傳送的訊息資料值。(本文稍後會討論郵件加密)。

將訂閱項目傳送至伺服器

取得推送訂閱項目後,請將其傳送至伺服器。您可以自行決定傳送方式,但建議使用 JSON.stringify() 從訂閱物件中擷取所有必要資料。或者,您也可以手動組裝相同的結果,例如:

const subscriptionObject = {
  endpoint: pushSubscription.endpoint,
  keys: {
    p256dh: pushSubscription.getKeys('p256dh'),
    auth: pushSubscription.getKeys('auth'),
  },
};

// The above is the same output as:

const subscriptionObjectToo = JSON.stringify(pushSubscription);

如要從網頁傳送訂閱項目,請使用下列方法:

function sendSubscriptionToBackEnd(subscription) {
  return fetch('/api/save-subscription/', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(subscription),
  })
    .then(function (response) {
      if (!response.ok) {
        throw new Error('Bad status code from server.');
      }

      return response.json();
    })
    .then(function (responseData) {
      if (!(responseData.data && responseData.data.success)) {
        throw new Error('Bad response from server.');
      }
    });
}

Node.js 伺服器會收到這項要求,並將資料儲存至資料庫,以供日後使用。

app.post('/api/save-subscription/', function (req, res) {
  if (!isValidSaveRequest(req, res)) {
    return;
  }

  return saveSubscriptionToDatabase(req.body)
    .then(function (subscriptionId) {
      res.setHeader('Content-Type', 'application/json');
      res.send(JSON.stringify({data: {success: true}}));
    })
    .catch(function (err) {
      res.status(500);
      res.setHeader('Content-Type', 'application/json');
      res.send(
        JSON.stringify({
          error: {
            id: 'unable-to-save-subscription',
            message:
              'The subscription was received but we were unable to save it to our database.',
          },
        }),
      );
    });
});

有了伺服器上的 PushSubscription 詳細資料,您隨時可以傳送訊息給使用者。

定期重新訂閱,避免過期

訂閱推播通知時,您通常會收到 PushSubscription.expirationTimenull。從理論上來說,這表示訂閱項目永不過期。(相較之下,DOMHighResTimeStamp 則表示確切的到期時間)。不過,瀏覽器通常會讓訂閱項目過期。舉例來說,如果長時間未收到推播通知,或是瀏覽器偵測到使用者未在使用具有推播通知權限的應用程式,就可能發生這種情況。如要避免這種情況,其中一種模式是在每次收到通知時重新訂閱使用者,如下列程式碼片段所示。這需要您經常傳送通知,以免瀏覽器自動讓訂閱過期。您應仔細權衡正當通知需求帶來的優缺點,以及僅為防止訂閱方案到期而向使用者傳送非自願垃圾訊息的行為。最後,您不應試圖規避瀏覽器保護使用者免於長期忘記的通知訂閱項目。

/* In the Service Worker. */

self.addEventListener('push', function(event) {
  console.log('Received a push message', event);

  // Display notification or handle data
  // Example: show a notification
  const title = 'New Notification';
  const body = 'You have new updates!';
  const icon = '/images/icon.png';
  const tag = 'simple-push-demo-notification-tag';

  event.waitUntil(
    self.registration.showNotification(title, {
      body: body,
      icon: icon,
      tag: tag
    })
  );

  // Attempt to resubscribe after receiving a notification
  event.waitUntil(resubscribeToPush());
});

function resubscribeToPush() {
  return self.registration.pushManager.getSubscription()
    .then(function(subscription) {
      if (subscription) {
        return subscription.unsubscribe();
      }
    })
    .then(function() {
      return self.registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array('YOUR_PUBLIC_VAPID_KEY_HERE')
      });
    })
    .then(function(subscription) {
      console.log('Resubscribed to push notifications:', subscription);
      // Optionally, send new subscription details to your server
    })
    .catch(function(error) {
      console.error('Failed to resubscribe:', error);
    });
}

常見問題

以下列舉幾個常見問題:

瀏覽器使用的推送服務可以變更嗎?

否,瀏覽器會選取推送服務。如本文在 subscribe() 呼叫中討論的內容,瀏覽器會向推送服務發出網路要求,以擷取構成 PushSubscription 的詳細資料。

不同的推送服務是否使用不同的 API?

所有推送服務都使用相同的 API。

這個通用 API 稱為「網頁推送通訊協定」,說明應用程式發出的網路要求,可觸發推送訊息。

如果我在電腦上訂閱某位使用者,手機也會訂閱嗎?

不會。使用者必須在每個要接收訊息的瀏覽器上註冊推播訊息服務。使用者也必須在每部裝置上授予權限。

後續步驟

程式碼研究室