第2回

無料公開中公開翌日4時まで

プッシュ通知とアイコンバッジを使う

「ホーム画面に追加」してインストールしたWebアプリで、プッシュ通知とアイコンバッジを使う方法を解説します。ユーザーの許可を得て宛先を取得する購読、サーバーからの送信、Service Workerでの表示という3つの実装と、通知に合わせてバッジを更新する方法を見ていきます。

(700号)

目次
シェア

はじめに

前回は、「ホーム画面に追加」したサイトがブラウザのタブではなく独立したアプリとして起動し、使える機能が変わること、ブラウザがサイトを「インストールされたアプリ」として扱うときの説明書にあたる、Webアプリマニフェストについて見ていきました。

今回は、インストールしたWebアプリだからこそ使える2つの機能、プッシュ通知とアイコンバッジを扱います。

どちらもネイティブアプリの代名詞のような機能ですが、Androidでは2015年のChrome 42から、iOSでも2023年のiOS 16.4から、インストールされたWebアプリで使えるようになっています。

実装の全体像をつかむことを目的に、クライアント、Service Worker、サーバーのそれぞれで何をするのかを順に見ていきます。

プッシュ通知が届くまでの流れ

プッシュ通知は、ユーザーがサイトを開いていないときでも、サーバーからの働きかけで端末に通知を表示する仕組みです。

一昔前は、中身はWebサイトとほとんど変わらないのに、プッシュ通知のためだけにネイティブアプリとして配信されているものをよく目にしましたが、現在ではその必要はなくなっています。

まずはプッシュ通知の仕組みの全体像を見ていきましょう。

プッシュ通知は、次の経路をたどってユーザーの端末に届きます。

  1. ユーザーが通知を許可し、ページが受け取った宛先をWebアプリのサーバーに保存する(購読)
  2. Webアプリのサーバーが、通知の内容をメッセージとして宛先に送り出す
  3. プッシュサービスがメッセージを受け取り、宛先のスマートフォンに配達する
  4. ブラウザがインストールされたWebアプリのService Workerを裏で起動し、Service Workerが通知を表示する
プッシュ通知が届くまでの流れ
図:プッシュ通知が届くまでの流れ。左の「ユーザーの端末」の中に「ブラウザ」があり、その中に「ページ(JavaScript)閉じていてもよい」と「Service Worker(sw.js)」が上下に並ぶ。右には「Webアプリのサーバー」と「プッシュサービス(Apple・Googleが運用)」が並ぶ。1「購読情報を保存」でページからWebアプリのサーバーへ、2「メッセージを送る」でサーバーからプッシュサービスへ、3「端末に配達」でプッシュサービスからブラウザへ矢印が向かい、4「起動して通知を表示」でブラウザからService Workerへ矢印が向かう。凡例で、青い実線の箱は「開発者が実装する部分」(ページ、Webアプリのサーバー、Service Worker)、灰色の破線の箱は「実装しなくてよい部分(ブラウザ・プッシュサービス任せ)」(ブラウザ、プッシュサービス)と示している大きな画像

3.に出てくる「プッシュサービス」とは、ブラウザベンダーが運用している配達用のサーバーです。

iPhoneならApple、Android(Chrome)ならGoogleのサーバーで、スマートフォンは、各サーバーと常時接続を維持しています。

通知を送る際は、端末に直接メッセージを届けることはできないので、プッシュサービスに預けて配達してもらいます。これは、ネイティブアプリの通知と同じ経路になっています。

したがって3.の工程はこちらで実装するわけではなく、主に気にする必要があるのは、次の3点です。

  • ユーザーに許可をもらって宛先を取得する購読(工程1.)
  • Webアプリのサーバーから行うプッシュ通知のリクエスト(工程2.)
  • インストールされたWebアプリのService Worker(工程4.)

3.のプッシュサービスは、送る側から見るとただの宛先URLとして現れるだけで、開発者が何かをする必要はありません。

プッシュ通知を実装する

ここからは、開発者が実装する工程1.・2.・4.のコードを順に紹介します。

1. 購読:ユーザーの許可を得て宛先を取得する

購読の処理は、ページのJavaScriptに書きます。

購読はService Workerを登録し、pushManager.subscribe()というメソッドで行います。

subscribe()は、ボタンのタップなどユーザーの操作をきっかけに呼びます。iOSでは、操作なしに許可ダイアログを出すことはできません。

フロント側:購読して宛先を取得する
// Service Workerの登録は、ページの読み込み時でよい
const registration = await navigator.serviceWorker.register('/sw.js');

// 購読は、ユーザーが操作したときに行う
document.querySelector('#subscribe').addEventListener('click', async () => {
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: VAPID_PUBLIC_KEY,
  });

  // 取得した宛先をWebアプリのサーバーに保存する
});

subscribe()を呼ぶと、ユーザーがまだ通知を許可していなければ、ブラウザが次のような許可ダイアログを表示します。

通知の許可ダイアログ(iOS)
インストールされたWebアプリの上に、「“Pitform”は通知を送信します。よろしいですか?」というiOSのダイアログが表示されている。通知方法としてテキスト、サウンド、アイコンバッジが利用できる旨の説明と、「許可しない」「許可」のボタンがある大きな画像

subscribe()には、次の2つの設定を持つオブジェクトを引数として渡します。

  • userVisibleOnly:「受け取ったプッシュは必ずユーザーに見える通知として表示する」という宣言。現状はtrue以外を指定する場面はない
  • applicationServerKey:送信者を識別するための公開鍵

宛先のURLを知った第三者が勝手に通知を送れないように、Webアプリのサーバーは自分の鍵で署名してから送る決まりになっています。

この仕組みをVAPIDと呼び、ここで渡した公開鍵と対になる秘密鍵を持つサーバーだけが、この宛先へ送れるようになります。

VAPID鍵は、ユーザーごとに作るものではなく、サービスにつき1組だけ用意し、サーバー側が秘密鍵・subscribe()に渡すことで購読側が公開鍵を持つ状態にします。

VAPID鍵はNode.jsのweb-pushパッケージで生成できます。

VAPID鍵の生成
npx web-push generate-vapid-keys

subscribe()の返り値が購読情報(PushSubscription)で、次のような形になります。

購読情報の例
{
  "endpoint": "https://web.push.apple.com/QGd3q2...",
  "expirationTime": null,
  "keys": {
    "p256dh": "BNcRdreALRFX...",
    "auth": "tBHItJI5sv..."
  }
}

endpointが宛先のURLで、keysがそのユーザー宛のメッセージを暗号化するための鍵です。

この購読情報をWebアプリのサーバーに送って保存しておき、次の節でここに向けて通知を送ります。

2. Webアプリのサーバーからプッシュ通知をリクエストする

Webアプリのサーバーでは、保存しておいた購読情報と1.で言及した秘密鍵を使って通知を送ります。

メッセージの暗号化や署名の手順は仕様で決まっていますが、自前で実装することはまずなく、ライブラリに任せます。

Node.jsなら前述したweb-pushパッケージです。

サーバー側:プッシュ通知の送信
import webpush from 'web-push';

// 送信者の連絡先と、鍵のペアを登録する(署名に使われる)
webpush.setVapidDetails(
  'mailto:you@example.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

// 保存しておいた購読情報に向けて送る
await webpush.sendNotification(
  subscription,
  JSON.stringify({
    title: '新しい問い合わせ',
    body: '山田さんからフォームの送信がありました',
    url: '/inbox',
  })
);

sendNotification()に購読情報とメッセージを渡すだけです。メッセージの中身は自由で、ここではタイトル、本文、タップしたときに開くURLを入れています。

購読が無効になっていると、送信のときに410などのエラーが返ります。

4. Service Workerで受け取って表示する

Service Workerの処理は、1.でregister()に渡したファイル(sw.js)に書きます。

Service Workerでは、pushイベントとnotificationclickイベントに対してリスナーを張り、それぞれに対して処理を書きます。

pushイベントは、メッセージが配達されてブラウザがService Workerを起動したときに発生します。ここで通知を表示します。

notificationclickイベントは、表示した通知をユーザーがタップしたときに発生します。ここで開くページを指定します。

Service Worker(sw.js):通知の表示とタップの処理
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {};
  event.waitUntil(
    self.registration.showNotification(data.title ?? 'お知らせ', {
      body: data.body,
      data: { url: data.url },
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(clients.openWindow(event.notification.data.url ?? '/'));
});

event.waitUntil()は、通知の表示が終わるまでService Workerを終了させないためのものです。

これで、Webアプリのサーバーから送ったメッセージが、端末に通知として表示されるようになりました。

Declarative Web Push:Service Workerなしのプッシュ通知

上記の実装のとおり、pushイベントで何をするかは開発者に委ねられています。

通知を表示せずに処理だけを動かせば、ユーザーに気づかれないまま、端末がいつオンラインになっているかや、通信元のIPアドレスからおおよその居場所を調べる、といったこともできてしまいます。

各ブラウザベンダーはこれを問題視しており、pushイベントで通知を表示しなかった購読を解除するなどのペナルティを課すようになっています。

しかし、それでも通知を表示しつつ、裏で別の処理をすることまでは防げません。

そこでWebKitが2025年に、iOS 18.4とmacOS 15.5(Safari 18.5)で導入したのがDeclarative Web Pushです。

2.のsendNotification()で送るメッセージを次の形にすると、Service Workerを介さずにブラウザが通知を表示します。

サーバー側:Declarative Web Pushのメッセージを送る
await webpush.sendNotification(
  subscription,
  JSON.stringify({
    web_push: 8030,
    notification: {
      title: '新しい問い合わせ',
      body: '山田さんからフォームの送信がありました',
      navigate: 'https://example.com/inbox',
    },
    app_badge: 1,
  })
);

web_push: 8030がこの形式であることの宣言で、navigateがタップしたときに開くURL、notificationの外に並べるapp_badgeは後述するアイコンバッジの数です。

メッセージを受け取っても、サイトのJavaScriptは動きません。ブラウザが書かれた内容をそのまま通知にするだけなので、通知の裏で別の処理をする余地がなくなります。

購読するときに使うpushManagerも、iOS 18.4以降とmacOS 15.5(Safari 18.5)以降ではwindow.pushManagerから行えるので、Service Workerの登録自体が不要になります。

対応していないブラウザに届いた場合は、従来どおりService Workerのpushイベントで処理されます。

詳しくはWebKitのブログを参照してください。

アイコンバッジを表示する(Badging API)

アイコンバッジは、ホーム画面のアイコンの右上に表示される数字やドットです。未読件数などを示すのに使われ、「見てほしいものがある」と伝えられます。

navigator.setAppBadge()とnavigator.clearAppBadge()で操作します。

バッジの設定と解除
// 未読が3件あることを示す
await navigator.setAppBadge(3);

// バッジを消す
await navigator.clearAppBadge();

どちらもページのJavaScriptとService Workerの両方から呼べます。アプリを開いている間はページ側から、閉じている間はService Workerから更新する、という使い分けになります。

対応状況はプッシュ通知より限られます。

数字を出せるのはiOS(16.4以降)とデスクトップ(Safari 17以降、Chrome、Edge)で、Android版Chromeは未対応です。

Androidでは、通知が届いたときにOSがアイコンにドットを付けるだけになります。

プッシュ通知とセットで使う

バッジは、プッシュ通知とセットで使われることがほとんどです。

プッシュ通知とともにバッジを更新するには、Service WorkerのpushイベントでsetAppBadge()を呼び出します。

Service Worker(sw.js):通知とバッジを同時に更新する
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {};
  event.waitUntil(
    Promise.all([
      self.registration.showNotification(data.title, { body: data.body }),
      navigator.setAppBadge(1),
    ])
  );
});

ページ側でWebアプリが開かれたときにclearAppBadge()を呼べば、バッジを削除できます。

フロント側:アプリを開いたらバッジを消す
function clearBadgeIfVisible() {
  if (document.visibilityState === 'visible') {
    navigator.clearAppBadge();
  }
}

document.addEventListener('visibilitychange', clearBadgeIfVisible);
clearBadgeIfVisible();

まとめ

今回は、インストールされたWebアプリで、プッシュ通知とアイコンバッジを使う方法を見てきました。

ここまでで、「ホーム画面に追加」したサイトはアプリとして起動し、通知とバッジでユーザーへ働きかけられるようになりました。

では、ネイティブアプリと比べて何が足りないのでしょうか。次回は、この仕組みの弱点と、どんなサービスなら活かせるのかを考えます。

この記事の内容についての意見・感想を送る
CodeGridのDiscordで知識を深めよう。参加する。

人気のシリーズ