0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Vite PWAでPush通知を試すのだ

Posted at

前回Express + Service Worker で最小構成 PWA Push 通知を体験するで基本的なPush通知の基本的な仕組みを理解した。
Web標準でPush通知ができることに感心した。

ただ、実際のサービス展開にあたってはPWA対応のフレームワークを使う方が楽だし管理しやすいよね、ということで慣れ親しんだViteのPWAプラグインででPush通知を試してみたのだ🐦

先に成果物

https://github.com/11bluetree/vite-pwa-push-notice-app

こんなのつくった。

ページ上で通知の購読・解除、テスト通知の送信ができる。もちろんインストール可能。

image.png

image.png

image.png

image.png

Vite PWAとは

ref: Vite PWA Plugin

Vite PWA は Vite プロジェクトに PWA 機能を手軽に追加できる公式プラグイン。
アイコンやマニフェストの生成、Service Worker の登録・更新、キャッシュ管理など、PWAに必要な作業をまとめて面倒見てくれるので、「自分でSWを書くのは大変だけどPWA試したい」人にうってつけということ。

試してみた

開発環境

  • Windows VS Code Dev Container(Node.js)

プロジェクトの作成

とりあえずGetting Startedをすすめる。
react-typescriptのテンプレートを使う。そして動作確認なのでデフォルトで進める。

$ npm create @vite-pwa/pwa@latest my-react-app -- --template react-ts

> npx
> create-pwa my-react-app --template react-ts

✔ PWA Name: … my-react-app
✔ PWA Short Name: … my-react-app
✔ PWA Description: … 
✔ Theme color: … #ffffff
✔ Select a strategy: › generateSW
✔ Select a behavior: › Prompt for update
✔ Enable periodic SW updates? … no
✔ Show offline ready prompt? … no
✔ Generate PWA Assets Icons on the fly? … yes

Scaffolding project in /workspaces/typescript-node-temp/my-react-app...

Done. Now run:

  cd my-react-app
  npm install
  npm run dev

作成完了!

念のためオプションの説明をば。

Select a strategy

Service Workerのワークボックスのビルド戦略を選択する。以下のようにgenerateSWinjectManifestの2つを選べる。

? Select a strategy: › - Use arrow-keys. Return to submit.
❯   generateSW
    injectManifest

ワークボックスとは、Googleが提供するライブラリで、PWAのService Workerを簡単に作成・管理できるツール。

  • generateSW: ワークボックスが自動的にService Workerを生成する。基本的なキャッシュ戦略が組み込まれており、すぐに使える状態になる。
  • injectManifest: 既存のService Workerにワークボックスの機能を追加する。カスタマイズが可能で、特定のリソースをキャッシュするための柔軟性がある。

ワークボックスのビルド戦略についてはこちらのページで詳しく解説されている。
https://developer.chrome.com/docs/workbox/modules/workbox-build?hl=ja

Select a behavior

Service Workerの更新時の挙動を選択する。以下のようにPrompt for updateAuto updateの2つを選べる。

? Select a behavior: › - Use arrow-keys. Return to submit.
❯   Prompt for update
    Auto update

これはブラウザーがアプリケーションの新しいバージョンを検出したときの動作を指定します。

  • Prompt for update: 新しいバージョンが利用可能な場合、ユーザーに更新を確認するプロンプトを表示。
  • Auto update: 新しいバージョンが利用可能な場合、自動的に更新を適用する。特にリリース前に動作確認をしっかりしておくことをお勧めする。

Enable periodic SW updates

サービスワーカーの定期的な更新を有効にするかどうかを選択する。
間隔はミリ秒単位で設定する。

ユーザーがページを開きっぱなしにしている間は新しいバージョンを取得できないので、定期的に更新を確認する場合に有効にすることで更新アクションを促すことができる。

Show offline ready prompt

オフラインでの利用が可能になったときにユーザーに通知するかどうかを選択する。
オフラインでの利用が可能になったとき」というのは、アプリケーションが初めてインストールされたときや、必要なリソースがキャッシュされたときなどを指す。

Vite PWAではofflineReadyイベントを使用して、オフラインでの利用が可能になったときにユーザーに通知することができる。
yesを選択すると、オフラインでの利用が可能になったときに通知するバッジコンポーネントが生成される。

<div className="PWABadge" role="alert" aria-labelledby="toast-message">
  { (offlineReady || needRefresh)
  && (
    <div className="PWABadge-toast">
      <div className="PWABadge-message">
        { offlineReady
          ? <span id="toast-message">App ready to work offline</span>
          : <span id="toast-message">New content available, click on reload button to update.</span>}
      </div>
      <div className="PWABadge-buttons">
        { needRefresh && <button className="PWABadge-toast-button" onClick={() => updateServiceWorker(true)}>Reload</button> }
        <button className="PWABadge-toast-button" onClick={() => close()}>Close</button>
      </div>
    </div>
  )}
</div>

Generate PWA Assets Icons on the fly

Vite PWAでは、PWAアセットアイコンを動的に生成するオプションが用意されている。yesにすることで、PWAプロジェクトに必要なアイコンを手動で用意する手間が省ける。

裏側では@vite-pwa/assets-generatorを使用している。

vite.config.tsでpwaAssetsプロパティを追加して有効にし、pwa-assets.config.tsをプロジェクトルートに作成する。

yesを選択した場合、以下ののような設定がされる。

vite.config.ts:

import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
  plugins: [
    VitePWA({
      // other pwa options

      // pwa assets
      pwaAssets: {
        disabled: false,
        config: true,
      }
    })
  ]
})

pwa-assets.config.ts:

import {
    defineConfig,
    minimal2023Preset as preset,
} from '@vite-pwa/assets-generator/config'

export default defineConfig({
    headLinkOptions: {
        preset: '2023',
    },
    preset,
    images: ['public/favicon.svg'],
})

たったこれだけの設定で、PWAに必要なアイコンが自動生成されるのは非常に便利っす。

なお、v0.21.1の時点ではExperimentalらしい。

実行してみる

vite.config.tsとpwa-assets.config.tsはこの状態。
ビルドしたらdistフォルダにPWAアセットが生成されるのか確認してみる。

vite.config.ts:

import { VitePWA } from 'vite-plugin-pwa';
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), VitePWA({
    registerType: 'prompt',
    injectRegister: false,

    pwaAssets: {
      disabled: false,
      config: true,
    },

    manifest: {
      name: 'my-react-app',
      short_name: 'my-react-app',
      description: 'my-react-app',
      theme_color: '#ffffff',
    },

    workbox: {
      globPatterns: ['**/*.{js,css,html,svg,png,ico}'],
      cleanupOutdatedCaches: true,
      clientsClaim: true,
    },

    devOptions: {
      enabled: false,
      navigateFallback: 'index.html',
      suppressWarnings: true,
      type: 'module',
    },
  })],
})

pwa-assets.config.ts:

import {
    defineConfig,
    minimal2023Preset as preset,
} from '@vite-pwa/assets-generator/config'

export default defineConfig({
    headLinkOptions: {
        preset: '2023',
    },
    preset,
    images: ['public/favicon.svg'],
})
npm i
npm run build

ビルド後のファイル構成 ざっとスクショお見せする。

image.png

manifest.webmanifestができとる。そして必要なアイコンも生成されている。

{
  "name": "my-react-app",
  "short_name": "my-react-app",
  "description": "my-react-app",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#ffffff",
  "lang": "en",
  "scope": "/",
  "icons": [
    {
      "src": "pwa-64x64.png",
      "sizes": "64x64",
      "type": "image/png"
    },
    {
      "src": "pwa-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "pwa-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    },
    {
      "src": "maskable-icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ]
}

index.htmlにmanifestのリンクが追加されている。

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>my-react-app + TS</title>
    <script type="module" crossorigin src="/assets/index-CvA6gJjY.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-C3L-7O-H.css">
  <link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#ffffff">
<link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png"></head>
  <body>
    <div id="root"></div>
  </body>
</html>

sw.jsやらworkbox関連のファイルも生成されている。

ということでローカルで確認してみる。

# Dev ContainerからホストPCのブラウザで見たいので --host を付ける(ネットワーク公開)
$ npm run preview -- --host

http://localhost:4173/ を開くとアプリが表示できた。

image.png

PWAとしてインストールできることも確認できた。

image.png

Push通知を実装するのだ

さっきプロジェクト作成するときにService Worker StrategyをgenerateSWにしたけど、Push通知を実装するにはinjectManifestにしないといけない。

generateSWはWorkbox が SW を“丸ごと自動生成”するモードで、こちらが用意した SW は読み込まれず、push/notificationclick などの独自ロジックを差し込めない。
なので、Service Workerのコードを自分で書けるinjectManifestに変更する。

大丈夫、injectManifestに変更しても、Vite PWAが提供するPWAの基本機能(キャッシュ管理、オフライン対応、マニフェスト生成など)は引き続き利用できる。そしてgenerateSWでやってたことも引き続きできる。

vite.configの修正

以下追記

export default defineConfig({
  plugins: [react(), VitePWA({
    ・・・
    strategies: 'injectManifest',
    srcDir: 'src',
    filename: 'sw-push.js',
    ・・・
  • strategies: 'injectManifest' 書かないとデフォルトでgenerateSWになる
  • srcDir: 'src' どこのディレクトリにSWのソースコードがあるか指定 デフォルトはpublic
  • filename: 'sw-push.ts' どのファイル名でSWを出力するか指定 デフォルトはsw.js

sw-push.tsの作成

まずはworkbox-precachingをインストール。
workbox-precachingはService Worker の プリキャッシュ(インストール・イベント中にあらかじめアセットをキャッシュ)を簡単にしてくれるモジュール。

ref: https://developer.chrome.com/docs/workbox/modules/workbox-precaching?hl=ja

npm i -D  workbox-precaching

あとはsw-push.tsをsrcディレクトリに作成する。

import { precacheAndRoute } from 'workbox-precaching'

interface ExtendedServiceWorkerGlobalScope extends ServiceWorkerGlobalScope {
  addEventListener: (type: string, listener: (event: Event) => void) => void
  registration: ServiceWorkerRegistration
  clients: {
    openWindow: (url: string) => Promise<unknown>
  }
}

declare let self: ExtendedServiceWorkerGlobalScope

precacheAndRoute(self.__WB_MANIFEST)

// プッシュ通知イベントのリスナー
self.addEventListener('push', (event: Event) => {
  const pushEvent = event as unknown as {
    data?: {
      text: () => string
    }
    waitUntil: (promise: Promise<void>) => void
  }

  const options = {
    body: pushEvent.data?.text() || 'プッシュ通知のデフォルトメッセージ',
    icon: '/favicon.svg',
    badge: '/favicon.svg',
    vibrate: [100, 50, 100],
    data: {
      dateOfArrival: Date.now(),
      primaryKey: 1
    },
    actions: [
      {
        action: 'explore',
        title: '詳細を見る',
        icon: '/favicon.svg'
      },
      {
        action: 'close',
        title: '閉じる',
        icon: '/favicon.svg'
      }
    ]
  }

  pushEvent.waitUntil(
    self.registration.showNotification('my-react-app', options)
  )
})

// 通知クリックイベントのリスナー
self.addEventListener('notificationclick', (event: Event) => {
  const notificationEvent = event as unknown as {
    notification: {
      close: () => void
    }
    action?: string
    waitUntil: (promise: Promise<void>) => void
  }

  notificationEvent.notification.close()

  if (notificationEvent.action === 'explore') {
    // アプリを開く
    notificationEvent.waitUntil(
      self.clients.openWindow('/').then(() => undefined)
    )
  } else if (notificationEvent.action === 'close') {
    // 何もしない(通知を閉じるだけ)
  } else {
    // デフォルトのクリック動作
    notificationEvent.waitUntil(
      self.clients.openWindow('/').then(() => undefined)
    )
  }
})

続いてプッシュ通知コンポーネント作成。
要点だけ記載しておく。

PushNotification.tsx:

import { useState, useEffect } from 'react'

const VAPID_PUBLIC_KEY = 'vapid_public_key'

const PushNotification: React.FC = () => {
  const [subscription, setSubscription] = useState<PushSubscription | null>(null)
  const [isLoading, setIsLoading] = useState(false)

  // VAPID鍵をUint8Arrayに変換
  const urlBase64ToUint8Array = (base64String: string): Uint8Array => {
    const padding = '='.repeat((4 - base64String.length % 4) % 4)
    const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
    const rawData = window.atob(base64)
    const outputArray = new Uint8Array(rawData.length)
    for (let i = 0; i < rawData.length; ++i) {
      outputArray[i] = rawData.charCodeAt(i)
    }
    return outputArray
  }

  // プッシュ通知を購読
  const subscribeUser = async () => {
    setIsLoading(true)
    try {
      const permission = await Notification.requestPermission()
      if (permission !== 'granted') throw new Error('通知権限が拒否されました')

      const registration = await navigator.serviceWorker.ready
      const newSubscription = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
      })

      setSubscription(newSubscription)
    } catch (err) {
      console.error('購読に失敗:', err)
    } finally {
      setIsLoading(false)
    }
  }

  // テスト通知を送信
  const sendTestNotification = () => {
    navigator.serviceWorker.ready.then((registration) => {
      registration.showNotification('テスト通知', {
        body: 'プッシュ通知のテストです!',
        icon: '/favicon.svg',
      })
    })
  }

  return (
    <div>
      <h3>プッシュ通知</h3>
      <p>状態: {subscription ? '購読中' : '未購読'}</p>
      
      {!subscription ? (
        <button onClick={subscribeUser} disabled={isLoading}>
          {isLoading ? '購読中...' : 'プッシュ通知を購読'}
        </button>
      ) : (
        <button onClick={sendTestNotification}>
          テスト通知を送信
        </button>
      )}
    </div>
  )
}

export default PushNotification

あとはApp.tsxにPushNotificationコンポーネントを追加する。

import { useState } from 'react'
import reactLogo from './assets/react.svg'
import appLogo from '/favicon.svg'
import PWABadge from './PWABadge.tsx'
import PushNotification from './PushNotification.tsx'
import './App.css'

function App() {
  const [count, setCount] = useState(0)

  return (
    <>
      <div>
        <a href="https://vite.dev" target="_blank">
          <img src={appLogo} className="logo" alt="my-react-app logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      <h1>my-react-app</h1>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>
      <p className="read-the-docs">
        Click on the Vite and React logos to learn more
      </p>
      <PushNotification />
      <PWABadge />
    </>
  )
}

export default App

ここまで出来たら再ビルドすればOK

プレビュー見れば成果物で見せたような通知テストができる🦆

まとめ

  • Vite PWAプラグインを使うことで、PWAの基本機能(マニフェスト、Service Worker、アイコン生成)を自動化できる
  • Push通知を実装するにはgenerateSWからinjectManifestに変更して、カスタムService Workerを作成する必要がある
  • Workbox-precachingを使うことで、キャッシュ管理も簡単に統合できる

次はFCM(firebase Cloud Messaging)を使ったプッシュ通知を試すのだ。

以上!!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?