はじめまして。FRESH! でフロントエンドの開発をしている池田 (こりら) です。

この記事では、Web Push の実装概要や FRESH! における Web プッシュ通知機能 〜機能設計編〜 をもとに、実際に FRESH! でどのように Web Push 通知機能を実装したのかを紹介します。

アジェンダ

  1. Web Push の実装概要
    • Web Notifications API
    • Web Push API
  2. Firebase Cloud Messaging (FCM)
  3. Service Worker
    • push イベント
    • notificationclick イベント
  4. FRESH! における Web プッシュ通知の実装
    • FCM に関連する処理をユーティリティ化
    • 通知に関する実装
    • チュートリアルの実装
    • チャンネルページ・登録情報の管理ページの実装
    • 各ブラウザの個別対応
      • Chrome (59)
      • Firefox (54)
  5. 実装を終えて

Web Push の実装概要

これまで、ネイティブアプリでしか使えなかったプッシュ通知が、Web アプリケーションからも使えるように仕様策定が進められています。 これによって、メールアドレスや外部サービスのアカウントを登録してもらわなくても、ユーザーに最新情報を伝えることが可能になります。

プッシュ通知 (Chrome)
プッシュ通知 (Chrome)

Web Push 通知は,

  • 画面上に通知を表示する機能
  • サーバーから通知を受信する機能

の 2 つに分解して考えることができます。
そしてそれぞれの機能は,

  • Web Notifications API (画面上に通知を表示する機能)
  • Web Push API (サーバーから通知を受信する機能)

という 2 つの JavaScript の API によって実装することが可能です。

Web Notifications API

Web Notifications API

Web Notifications API でデスクトップ通知を表示するには、まず、ユーザーの許可を得る必要があります。

Notification.requestPermission().then(permission => {
  switch (permission) {
    case 'granted':
      // 許可された場合
      break;
    case 'denied':
      // ブロックされた場合
      break;
    case 'default':
      // 無視された場合
      break;
    default:
      break;
  }
});

また、Notification.permission を参照すると、現在の通知状態に応じて 'granted''denied''default' のいずれかの値を返します (ただし、後ほどのセクションで紹介しますが、ある状態においては正しく通知状態が取得できません)。

通知状態が 'default' の場合のみ, Notification.requestPermission メソッドを実行すると、ブラウザは以下のようなダイアログを表示してユーザーに許可を要求します。

Chrome の通知ダイアログ
Chrome の通知ダイアログ

許可状態はオリジンごとにブラウザに記憶されるので、同一オリジンに存在するサイトであれば、2 回目以降は許可は必要ありません。 1 度ブロックされるとダイアログが表示されないので、あらためて許可を得ることはできません。 また、プログラム側からブロックを取り消すことも不可能です。 ブロックの取り消しは、ユーザー操作によって、ブラウザの記憶を削除、または、変更した場合のみ可能です。

通知ブロックの取り消し
通知ブロックの取り消し

いったん、許可を得ることができれば通知の表示は簡単で、Notification インスタンスを生成するだけで表示できます。

const title = '見出し';
const options = {
  body : '本文',
  icon : 'アイコン画像のパス',
  data : {
    foo : '任意のデータ'
  }
};

const notification = new Notification(title, options);

第 1 引数のタイトルは必須です。第 2 引数はオプションですが、よく指定するオプションを以下に示します。

Notification コンストラクタの第 2 引数
プロパティ 概要
body string 本文の文字列
icon string アイコン画像の URL または, パス
data object 通知にもたせたい任意のデータ

ユーザーがデスクトップ通知をクリックしたときに何らかの処理を実行するには、Notification インスタンスに対して、イベントリスナーを設定します。

notification.addEventListener('click', event => {
  // do something ...
}, false);

以上で、「画面上に通知を表示する機能」は実装できました。

しかしながら、Web Notifications API のみでは、プッシュ通知は実現できません。 なぜなら、そのページを開いていないと受信できないという制約があるからです。 そこで、次に紹介する Service Worker を利用することによってこれを解決することができます。Service Worker は、ページが開いていなくてもブラウザがイベントドリブンで任意のスクリプトを実行できるという非常に強力な機能をもっているからです

Web Push API

Web Push API

Web Push API は、Web アプリケーションがサーバーからメッセージ (プッシュ通知) を受信できるようにするための API で, その機能は Service Worker に依存しています。

Service Worker は Progressive Web App (PWA) (はじめてのプログレッシブ ウェブアプリ) を構成する要素のひとつで、FRESH! でもすでに利用されています (FRESH! Web パフォーマンス改善 〜クライアントサイド編〜)。あらためて、Service Worker の特徴をまとめると

  • https が必須 (localhost 以外)
  • Web Worker の一種なので DOM 操作ができない。 ブラウザ (UI スレッド) とは、postMessage でやりとりする
  • 持続的で再利用可能な情報を Service Worker のライフサイクル間で共有したい場合は、IndexedDB を利用する
  • Background Sync
  • Web Push API

以上のようになり、今回利用するのが Web Push API です。

Web Push API を利用するには、プッシュ通知の送信元が正当なアプリケーションサーバーだと認証するために利用する公開鍵と秘密鍵のペアを生成する必要があります。 しかしながら、鍵を利用した実装は煩雑になるので、今回の実装では、以降のセクションで説明する Firebase Cloud Messaging (以下、FCM) を利用しました (ちなみに、事前の技術検証のために個人で実装した鍵を利用したプッシュ通知の簡単なサンプルはこちら)。

Firebase Cloud Messaging (FCM)

FCM は、クロスプラットフォームのプッシュ通知のためのソリューションです。

以下のコードは、FCM を利用するためのコードです。

import * as firebase from 'firebase';

firebase.initializeApp({
  messagingSenderId : 'YOUR-SENDER-ID'
});

const messaging = firebase.messaging();

messaging オブジェクトは、Web Push 通知で重要となる Notification オブジェクトをラッパーしたような役割をもっています。 実際、Web Push 通知の許可を得るコードは以下のようになります。

messaging.requestPermission().then(() => {
   // 許可された場合
}).catch(() => {
  // ブロック、または、無視された場合
});

さらに、プッシュ通知の送信元が正当なアプリケーションサーバーだと認証するために利用する鍵のやりとりを、Firebase トークンに隠蔽して、シンプルにします。 これによって、クライアントサイドは、getToken メソッドで Firebase トークンを取得してサーバーへ送信する処理を実装するだけです。

if (navigator.serviceWorker) {
  navigator.serviceWorker.register('./firebase-messaging-sw.js').then(() => {
    return navigator.serviceWorker.ready;
  }).then(registration => {
    messaging.useServiceWorker(registration);

    messaging.requestPermission().then(() => {
      // Firebase トークンを取得する
      messaging.getToken().then(token => {
        // 取得した Firebase トークンをサーバーへ送信
      }).catch(error => {
        // Firebase トークンの取得に失敗した場合
      });
    }).catch(error => {
      // 通知がブロック、または、無視された場合
    });
  }).catch(error => {
    // Service Worker スクリプトの登録に失敗した場合
  });
}

サーバーサイドでは、クライアントから送信された Firebase トークンを Cloud Messaging サーバーに対してトピック購読させる処理をします。

Service Worker

Service Worker

このセクションでは、Web Push 通知における Service Worker の基本的なイベント処理を解説します。

push イベント

push イベントは Web Push 通知 を受信した時に発生するイベントです。

このイベントが発生した時にデスクトップ上に通知を表示するわけですが、Service Worker から Notification オブジェクトは参照できないので、代わりに ServiceWorkerRegistration#showNotification メソッドを利用します。 このメソッドの引数は先ほど紹介した Notification コンストラクタの引数と同じです。

self.addEventListener('push', event => {
  // event.data は、以下のような JSON だとします
  //
  // {
  //   "title": "hogehoge",
  //   "body": "foobar"
  //   "data": {
  //     "url": "https://freshlive.tv"
  //   }
  // }

  const data = event.data.json();
  const title = data.notification.title;
  const body = data.notification.body;
  const url = data.data.url;

  self.registration.showNotification(title, {
    icon : '/img/web_push_icon.png',
    body,
    data : { url }
  });
});

notificationclick イベント

notificationclick イベントは、デスクトップ上に表示された通知をクリックした時に発生するイベントです。

showNotification メソッドで送信されたタイトルやオプションのデータは、event.notification からアクセス可能です。

self.addEventListener('notificationclick', event => {
  event.notification.close();

  // Web Push 通知が指定した URL に遷移する
  event.waitUntil(self.clients.openWindow(event.notification.data.url));
});

FRESH! における Web プッシュ通知の実装

このセクションでは、FCM や Service Worker の仕様と FRESH! に置ける Web のプッシュ通知機能 〜機能設計〜を踏まえて具体的にどう実装したのかを紹介します。

FCM に関連する処理をユーティリティ化

チュートリアル、チャンネルページ、登録情報の管理ページで共通して利用する FCM 関連の処理をユーティリティ関数として実装しました。 注意すべき点は、ログアウト時の Firebase トークン削除のために、Firebase トークンを取得するたびにローカルストレージに保存していますが、この保存した Firebase トークンを使い回してしまうと、通知が受信できないので、通知が許可されるたびに、getToken メソッドを実行しています。

// ...

export function prepareNotification(context) {
  // 通知の許可 -> Firebase トークンの取得 の順でないと、Firebase トークンが `null` になってしまう
  firebase.messaging().requestPermission().then(() => {
    prepareFirebaseToken(context).then(() => {
      // Firebase トークン取得の成否に関わらず、ブラウザの通知設定を反映させるため、リロード
      location.reload();
    }).catch(() => {
      // Firebase トークン取得の成否に関わらず、ブラウザの通知設定を反映させるため、リロード
      location.reload();
    });
  }).catch(() => {
    // ブラウザの通知設定を反映させるため、リロード
    location.reload();
  });
}

export function prepareFirebaseToken(context) {
  // ローカルストレージに保存してある Firebase トークンを使うと、Firebase トークンが最新でないので通知が届かなくなる
  // したがって、通知を許可にしたときは、常に Firebase トークンを再取得する

  return firebase.messaging().getToken().then(token => {
    // ログアウト時の Firebase トークン削除のため、ローカルストレージに保存する
    localStorage.setItem('FIREBASE_TOKEN', token):

    // 通知サーバーへ Firebase トークンを送信し保存する
    // createFirebaseToken は、そのための Action
    return context.executeAction(createFirebaseToken, { token });
  });
}

export function clearFirebaseToken(context) {
  const token = localStorage.getItem('FIREBASE_TOKEN'):

  if (!token) {
    return Promise.resolve();
  }

  return firebase.messaging().deleteToken(token).then(() => {
    // ローカルに保存されている Firebase トークンを削除する
    localStorage.removeItem('FIREBASE_TOKEN'):

    // 通知サーバへ Firebase トークンの削除リクエスト
    // deleteFirebaseToke は、そのための Action
    return context.executeAction(deleteFirebaseToken, { token });
  });
}

通知に関する実装

FRESH! では、以下の 3 つの条件がそろっていないと通知は受信できないように実装しています。

  • ログインしている
  • ブラウザの通知を許可している
  • チャンネルごとの通知を受け取るようにしている

FRESH! 独自の通知に関する実装としては、以下のとおりです。

  • チャンネルごとの通知の許可・ブロックがあるので、サーバーへ送信した Firebase トークンは、ユーザーの ID と紐づけて DB に保存
  • ログアウトしたユーザーへは通知しないように、ログアウト時に DB から Firebase トークンを削除
  • 生放送サービスという観点から、古い通知が残り続けないように、通知の有効期限を 2 時間に設定

チュートリアルの実装

チュートリアルは初回にログインしたユーザーにのみ表示して、それ以降は表示させないようにしています。 チュートリアルを閉じると通知許可を求めるダイアログが表示されることによって、よくある、ページを開いたときに通知許可を求めるダイアログが表示されるのを防止しています。

Chrome チュートリアル
Chrome チュートリアル
Firefox チュートリアル
Firefox チュートリアル
// ...

export default class WebPushTutorial extends React.Component {
  state = {
    isShowModal : false
  };

  onClose() {
    // チュートリアルを閉じたら、通知許可を求めるダイアログを表示する
  }

  componentDidMount() {
    // 初回ログイン、または、cookie の有効期限が切れていれば、state を変更する
  }

  render() {
    // state に応じて, チュートリアルを表示する
  }
}

チャンネルページ・登録情報の管理ページの実装

チャンネルページでは、(ブラウザの) 通知状態とチャンネルごとの通知設定があるので、表示のパターンが最も多いページとなっています。

チャンネルごとの通知設定
チャンネルごとの通知設定
// ...

export default class ChannelHeader extends React.Component {
  state = {
    notificationPermissionStatus : null,  // ブラウザの通知状態
    isNotifiable                 : null   // チャンネルごとの通知状態
  };

  onChangeNotificationStatus() {
    // チャンネルごとの通知設定をサーバーへ送信してDB に保存する
  }

  onOpenModalNotificationPermission() {
    // 通知をブロックしている状態から、ユーザーに手動で通知を許可してもらうために、手順を示したモーダルを表示する
  }

  onPrepareNotification() {
    // 通知がデフォルト -> 通知の許可を求めるダイアログの表示 -> 許可なら Firebase トークンを取得する
  }

  componentDidMount() {
    // チャンネルページに遷移した場合、チャンネルごとの通知状態をサーバーから取得して、state を更新する
  }

  renderWebPushSetting() {
    const {
      notificationPermissionStatus,
      isNotifiable
    } = this.state;

    // 通知状態による表示の分岐
    switch (notificationPermissionStatus) {
      case 'granted':
        return (
          <div>
            <label>
              <input
                type="checkbox"
                checked={isNotifiable}
                onChange={this.onChangeNotificationStatus}
              />
              通知を受け取る
            </label>
          </div>
        );
      case 'denied':
        return (
          <div>
            <button type="button" onClick={this.onOpenModalNotificationPermission}>通知を受け取るには?</button>
          </div>
        );
      case 'default':
        return (
          <div>
            <button type="button" onClick={this.onPrepareNotification}>通知をオンにする</button>
          </div>
        );
      default:
        return null;
    }
  }

  renderModalNotificationPermission() {
    // 通知ブロックを解除してもらうための手順を示したモーダルを表示する
  }

  render() {
    // ...
  }
}

登録情報の管理ページでも、チャンネルごとの通知設定がないことを除けば、チャンネルページとほぼ同様の実装となっています。

各ブラウザの個別対応

以下の各ブラウザの対応は、2017年7月時点で最新バージョンを対象としたものになります。 ブラウザのアップデートによって挙動が変わる可能性があります。 また、現状の挙動では、通知ダイアログが表示された場合、Chrome・Firefox ともにリロード以外に無視する手段はないようです。

Chrome (59)

Chrome には、通知の「自動ブロック」という設定があり、通知はブロックされるが、Notification.permission の値が 'default' なります。 すなわち、通知の状態と UI の整合がとれないという問題が発生しました。

Chrome 自動ブロック
Chrome 自動ブロック

さいわい、Notification.requestPermission の返す値が 'denied' となるのでこれとあわせて判定することで、自動ブロックの設定を検出することができました。

// HACK: Chrome の自動ブロックを検出する
Notification.requestPermission().then(permission => {
  if ((permission === 'denied') && (Notification.permission === 'default')) {
    // `permission` が 'denied' で `Notification.permission` が 'default' の場合、
    // Chrome の自動ブロックが設定されているので、解除してもらうようにモーダルを表示する
  } else {
    // 許可・ブロックの場合
  }
});

Firefox (54)

Firefox には、「このサイトでは今後も同様に処理する」というチェックボックスがあり、これを外した状態で通知を許可、または、ブロックを選択すると、Notification.permission の値が 'default' になり、いずれを選択しても通知が受信できず、これもまた通知の状態と UI の整合がとれないという問題が発生しました。

Firefox 「このサイトでは今後も同様に処理する」
Firefox 「このサイトでは今後も同様に処理する」

Firefox でも Chrome と同様に、Notification.requestPermission が返す値と Notification.permission の値をあわせて判定することでこの不整合を解決しました。

// HACK: Firefox の場合、「このサイトでは今後も同様に処理する」のチェックボックスを外した場合の対応をする
Notification.requestPermission().then(permission => {
  if ((permission === 'granted') && (Notification.permission === 'default')) {
    // 「このサイトでは今後も同様に処理する」のチェックボックスを外して、通知を許可した場合
    // UI上は、'default' の場合と同じ
  } else if ((permission === 'default') && (Notification.permission === 'default')) {
    // 「このサイトでは今後も同様に処理する」のチェックボックスを外して、通知を許可しなかった場合
    // 現状の挙動では、通知ダイアログが表示された場合、リロード以外に無視する手段はないので、
    // UI上は、'denied' の場合と同じ
  } else {
    // 「このサイトでは今後も同様に処理する」のチェックボックスを外していない場合で、許可・ブロックの場合
  }
});

実装を終えて

実装で大変だったのは、

  • 表示パターンが多くそれを実装におとしこむこと (ブラウザの通知状態 x チャンネルごとの通知状態 x Chrome・Firefox の 12 パターン)
  • Chrome・Firefox の独自の設定 (「各ブラウザの個別対応」セクションを参照)

特に、Chrome・Firefox の独自の設定に関しては、ブラウザのアップデートによって挙動が変わる可能性があるので、今後もそれを追っての対応が必要になります。

また、Service Worker は、ウェブペイメントアプリ (Payment Handler API) でも利用される前提で標準化が進行しています。Web Worker の世界は、スクリプトをバックグラウンドのスレッドで実行するためのシンプルな手段である WebWorkers・SharedWorker だけでなく、よりネイティブアプリに近い体験をユーザーに与える Service Worker が登場しました。 活躍の場はますます広がり、Web の世界に革命をもたらしています。