前回Express + Service Worker で最小構成 PWA Push 通知を体験するで基本的なPush通知の基本的な仕組みを理解した。
Web標準でPush通知ができることに感心した。
ただ、実際のサービス展開にあたってはPWA対応のフレームワークを使う方が楽だし管理しやすいよね、ということで慣れ親しんだViteのPWAプラグインででPush通知を試してみたのだ🐦
先に成果物
https://github.com/11bluetree/vite-pwa-push-notice-app
こんなのつくった。
ページ上で通知の購読・解除、テスト通知の送信ができる。もちろんインストール可能。
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のワークボックスのビルド戦略を選択する。以下のようにgenerateSW
とinjectManifest
の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 update
とAuto 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
ビルド後のファイル構成 ざっとスクショお見せする。
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/ を開くとアプリが表示できた。
PWAとしてインストールできることも確認できた。
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)を使ったプッシュ通知を試すのだ。
以上!!