🔔

Service Workerを活用したデスクトップ通知実装

2025/03/04に公開

はじめに

本記事では、TypeScriptとNext.jsを使用したプロジェクトに、Service Workerを活用してデスクトップ通知機能を実装する方法を解説します。Next.jsのクライアントサイドコンポーネントとService Workerを組み合わせることで、ブラウザがバックグラウンドにある場合でも通知を送信できる方法を紹介します。

開発環境と技術スタック:

  • フロントエンド: Next.js (App Router)、TypeScript、TailwindCSS
  • 状態管理: Jotai
  • UIコンポーネント: Yamada UI
  • その他: Service Worker API、Notification API

Service Workerとは

Service Workerは、Webアプリケーションとブラウザの間に位置する特殊なJavaScriptファイルで、オフライン動作、プッシュ通知、バックグラウンド同期などの機能を提供します。ブラウザのメインスレッドとは別に動作するため、ページが表示されていない場合でも処理を実行できます。

主な特徴:

  • ブラウザのバックグラウンドで動作
  • ネットワークリクエストの傍受と制御
  • プッシュ通知の処理
  • オフラインキャッシュの管理

他に通知機能の実装としてはWebSocketによるリアルタイム通知、アプリ内通知(トーストやアラート)、純粋なWeb Notification APIと方法はありましたが、ブラウザがバックグラウンドや閉じられている状態でも通知を表示するにはService Workerが最適な方法だと考えたため採用してみました。

Service Workerはプロダクション環境に適しているか

世の中に公開するWebサービスでService Workerを使用した通知機能の実装は、基本的に良い選択です。ただし、いくつかの重要な考慮点があります:

プロダクション利用のメリット

  • ブラウザがバックグラウンド時も通知可能: Webサービスの最大の強みになります
  • 標準技術: 主要ブラウザでサポートされている標準的な実装方法です
  • 拡張性: 通知だけでなくオフラインサポートなど他の機能も追加できます
  • バッテリー効率: 適切に実装すれば、ポーリングよりバッテリー消費が少ない傾向があります

実装時の注意点

  1. HTTPS必須: Service Workerは必ずHTTPS環境でのみ動作します

    // 本番環境では常にHTTPSが必要
    if (location.protocol !== 'https:' && !location.hostname.includes('localhost')) {
      console.warn('Service Workerは本番環境ではHTTPSが必要です');
      return;
    }
    
  2. ブラウザ互換性への配慮

    // 互換性チェックと適切なフォールバック
    if (!('serviceWorker' in navigator)) {
      // フォールバック機能を提供
      console.log('Service Workerがサポートされていないため、アプリ内通知を使用します');
      return;
    }
    
  3. アップデート戦略:

    // Service Worker更新の適切な管理
    navigator.serviceWorker.addEventListener('controllerchange', () => {
      console.log('Service Workerが更新されました');
      // 必要に応じてユーザーに通知やリロードを促す
    });
    
  4. デバッグの複雑さ: 開発環境と本番環境で挙動が異なる場合があります

    // 開発中のデバッグに役立つログ
    self.addEventListener('install', event => {
      console.log('[Service Worker] インストール');
      // 開発環境でのみ詳細ログを出力
      if (process.env.NODE_ENV === 'development') {
        console.debug('[Service Worker] 詳細情報:', event);
      }
    });
    

実用的な代替案との比較

機能 Service Worker WebSocket アプリ内通知
バックグラウンド通知
オフライン対応
実装の複雑さ 中〜高
サーバー要件
ブラウザサポート 良好 優良 最良

セキュリティとプライバシー

プロダクション環境では以下の点に特に注意が必要です:

  1. 権限要求のタイミング: ユーザーが通知の価値を理解してから許可を求める

    // ユーザーアクションの後に許可を要求
    signupButton.addEventListener('click', () => {
      // ユーザーが価値を理解した時点で
      setTimeout(() => {
        if (Notification.permission === 'default') {
          showNotificationPermissionDialog();
        }
      }, 2000);
    });
    
  2. 個人情報の扱い: 通知内容には機密情報を含めない

    // 良い実装例
    self.registration.showNotification('新着メッセージ', {
      body: '新しいメッセージが届いています', // 詳細は表示しない
      data: { messageId: 123 } // クリック時に安全に詳細取得
    });
    

大規模サービスでの運用例

実際の大規模Webサービスでは、Service Workerは以下のように利用されています:

  • X: PWA実装で通知とオフラインモードを提供
  • Slack: デスクトップ通知とオフライン対応
  • Gmail: オフラインメール閲覧と新着メール通知

通知APIの基礎知識

Webブラウザの通知API(Notification API)は、Webアプリケーションからデスクトップ通知を表示するための標準インターフェースです。この強力なAPIにより、ユーザーがブラウザを最小化していたり、別のタブで作業していたりしても、重要な情報を伝えることができます。

通知APIの主要コンポーネント

1. 通知の許可管理

通知を表示するには、まずユーザーから許可を取得する必要があります:

// 通知の許可を要求
Notification.requestPermission().then(permission => {
  if (permission === "granted") {
    // 許可された場合の処理
    console.log("通知が許可されました");
  } else if (permission === "denied") {
    // 拒否された場合の処理
    console.log("通知が拒否されました");
  }
});

許可状態の種類:

  • default - ユーザーがまだ選択していない(このケースでは通知は表示されません)
  • granted - ユーザーが通知を許可した
  • denied - ユーザーが通知を拒否した

現在の許可状態は Notification.permission で確認できます。

2. 通知の作成と表示

許可が得られたら、通知を作成して表示できます:

// 基本的な通知
new Notification("タイトル", {
  body: "通知の本文です。詳細な情報を表示できます。",
  icon: "/path/to/icon.png", // 通知に表示するアイコン
  badge: "/path/to/badge.png", // モバイルデバイス向けのバッジ
  tag: "message-1", // 通知のグループ化に使用
  data: { key: "value" }, // 通知に関連付けるカスタムデータ
  requireInteraction: true, // ユーザーの操作があるまで通知を表示し続ける
  vibrate: [200, 100, 200], // バイブレーションパターン(モバイル端末)
});

3. 通知イベントの処理

通知に関するユーザーのインタラクションを処理できます:

const notification = new Notification("メッセージが届きました", {
  body: "新しいメッセージがあります",
});

// クリックイベント
notification.onclick = (event) => {
  console.log("通知がクリックされました", event);
  window.focus(); // アプリケーションウィンドウをフォーカス
  notification.close(); // 通知を閉じる
};

// 通知が閉じられたイベント
notification.onclose = () => {
  console.log("通知が閉じられました");
};

// エラーイベント
notification.onerror = (error) => {
  console.error("通知エラー:", error);
};

4. 通知のオプションパラメータ

通知にはさまざまなパラメータを設定できます:

オプション 説明
body 通知の詳細テキスト
icon 通知に表示されるアイコン画像のURL
badge モバイルデバイスのステータスバーに表示される小さなアイコン
image 通知に表示される大きな画像(プラットフォームによりサポート状況が異なる)
tag 通知をグループ化するID(同じタグの新しい通知は古い通知を置き換える)
data 通知に関連付けるカスタムデータ
vibrate バイブレーションパターン(モバイルデバイス)
silent 通知音を消すかどうか
requireInteraction ユーザーの操作があるまで通知を表示し続けるかどうか
actions ユーザーが選択できるアクションボタン(プラットフォームによりサポート状況が異なる)
dir テキストの方向(autoltrrtl
lang 通知の言語(BCP 47言語タグ)

5. ブラウザ互換性と制限事項

  • 通知APIは現代的なブラウザの多くでサポートされていますが、Internet Explorerではサポートされていません
  • モバイルブラウザでは、通知の表示方法や機能が制限される場合があります
  • セキュリティ上の理由から、通知を表示するにはHTTPSが必要です(localhostは例外)
  • 一部のプラットフォームやブラウザでは、通知の表示形式やカスタマイズオプションが異なる場合があります

なぜNext.jsプロジェクトにService Workerを使うのか

Next.jsは強力なReactフレームワークですが、そのサーバーサイドレンダリング(SSR)とクライアントサイドのハイドレーションモデルでは、特定の制約があります。以下に、Next.jsアプリケーションでService Workerを活用する理由を示します:

1. ブラウザのバックグラウンドでの通知

Next.jsアプリケーションがフォーカスされていない場合でも、Service Workerはバックグラウンドで実行され、重要な通知をユーザーに届けることができます。これは特にタイマーアプリ、メッセージングアプリ、リマインダーアプリなどで重要です。

2. SSRとの互換性

Next.jsのSSR機能では、クライアントサイドのAPIにアクセスできないため、通知機能をクライアントサイドでのみ動作するように分離する必要があります。Service Workerはクライアントサイドのコードとサーバーサイドのコードを明確に分離するのに役立ちます。

3. パフォーマンスと効率性

Service Workerを使用することで、メインスレッドをブロックすることなく通知処理を行えるため、アプリケーションのパフォーマンスを維持できます。これは特にアニメーションや複雑なUIを持つ
Next.jsアプリケーションで重要です。

4. オフライン機能の拡張

将来的な拡張性を考慮すると、Service Workerはオフラインキャッシングやバックグラウンド同期などの機能も提供できるため、プログレッシブウェブアプリ(PWA)へと進化させる基盤となります。

実装手順

Service Workerファイルの作成

まず、publicディレクトリにsw.jsという名前でService Workerファイルを作成します。このファイルはNext.jsのビルドプロセスの一部ではなく、静的アセットとして提供されるため、publicディレクトリに配置します。

// Service Worker for handling notifications

self.addEventListener("install", (event) => {
  console.log("Service Worker installed");
  self.skipWaiting();
});

self.addEventListener("activate", (event) => {
  console.log("Service Worker activated");
  return self.clients.claim();
});

// 通知クリック時の処理
self.addEventListener("notificationclick", (event) => {
  console.log("Notification clicked", event);
  const notification = event.notification;
  notification.close();

  // メインウィンドウにフォーカスする
  event.waitUntil(
    clients.matchAll({ type: "window" }).then((clientList) => {
      for (const client of clientList) {
        if (client.url && "focus" in client) {
          return client.focus();
        }
      }
      if (clients.openWindow) {
        return clients.openWindow("/");
      }
    })
  );
});

// 基本的なメッセージ処理
self.addEventListener("message", (event) => {
  console.log("SW received message:", event.data);

  if (event.data && event.data.type === "SHOW_NOTIFICATION") {
    const { title, options } = event.data;
    self.registration.showNotification(title, options);
  }
});

このService Workerは以下の主要なイベントリスナーを実装しています:

  • install - Service Workerがインストールされたときに実行されます
  • activate - Service Workerがアクティブになったときに実行されます
  • notificationclick - 通知がクリックされたときのイベント処理
  • message - メインスクリプトからのメッセージを受信して通知を表示

Service Workerの登録

次に、Next.jsアプリケーションからService Workerを登録するためのカスタムフックを作成します。これはTypeScriptを使用して型安全に実装します。

1. 基本構造と型定義

まず、useNotification.tsファイルの基本部分を作成します:

"use client"; // Next.jsのクライアントコンポーネントであることを示す

import { useState, useEffect, useCallback } from "react";

// 通知オプションの型定義
interface NotificationOptions {
  title: string;
  body: string;
  data?: string;
}

export const useNotification = () => {
  // 通知許可の状態("default", "granted", "denied", "unsupported")
  const [permission, setPermission] = useState<
    NotificationPermission | "unsupported"
  >("default");
  
  // Service Worker登録オブジェクト
  const [swRegistration, setSwRegistration] = 
    useState<ServiceWorkerRegistration | null>(null);
  
  // 通知機能の有効/無効状態
  const [isEnabled, setIsEnabled] = useState<boolean>(true);

  // 以下実装が続きます...
};

2. Service Workerの登録とサポートチェック

useEffect内でService Workerの登録とNotification APIのサポートチェックを行います:

// Service Workerの登録とNotification APIのサポートチェック
useEffect(() => {
  // サーバーサイドレンダリング時は何もしない(Next.js特有の対応)
  if (typeof window === "undefined") return;

  // Notification APIとService Workerのサポートチェック
  if (!("Notification" in window) || !("serviceWorker" in navigator)) {
    setPermission("unsupported");
    return;
  }

  // 現在の通知許可状態を設定
  setPermission(Notification.permission);

  // ローカルストレージから通知の有効/無効状態を取得
  try {
    const storedIsEnabled = localStorage.getItem("notificationsEnabled");
    if (storedIsEnabled !== null) {
      setIsEnabled(storedIsEnabled === "true");
    }
  } catch (error) {
    console.error("Failed to access localStorage:", error);
  }

  // Service Workerの登録
  const registerSW = async () => {
    try {
      const registration = await navigator.serviceWorker.register("/sw.js");
      setSwRegistration(registration);
    } catch (error) {
      console.error("Service Worker registration failed:", error);
    }
  };

  registerSW();
}, []);

3. 通知許可要求機能

通知の許可を要求するための関数を実装します:

// 通知の許可を要求する関数
const requestPermission = async (): Promise<NotificationPermission> => {
  // サーバーサイドレンダリング時やサポートされていない場合は何もしない
  if (typeof window === "undefined" || permission === "unsupported") {
    return "denied";
  }

  try {
    const result = await Notification.requestPermission();
    setPermission(result);
    return result;
  } catch (e) {
    console.error("Failed to request permission:", e);
    return "denied";
  }
};

4. 通知の有効/無効切り替え機能

ユーザーが通知を一時的に無効にできるようにする機能を実装します:

// 通知の有効/無効を切り替える関数
const toggleNotifications = useCallback(() => {
  // 新しい状態を計算
  const newState = !isEnabled;

  // ローカルストレージに保存
  if (typeof window !== "undefined") {
    try {
      localStorage.setItem("notificationsEnabled", newState.toString());
    } catch (error) {
      console.error("Failed to save to localStorage:", error);
    }
  }

  // 状態を更新
  setIsEnabled(newState);
  return newState;
}, [isEnabled]);

5. 通知表示機能

実際に通知を表示するコア機能を実装します:

// 通知を表示する関数
const showNotification = async (
  options: NotificationOptions
): Promise<boolean> => {
  // サーバーサイドレンダリング時やサポートされていない場合は何もしない
  if (typeof window === "undefined" || permission === "unsupported") {
    return false;
  }

  // 通知が無効化されている場合は何もしない
  if (!isEnabled) {
    return false;
  }

  // 許可がない場合は許可を要求
  if (permission !== "granted") {
    const newPermission = await requestPermission();
    if (newPermission !== "granted") {
      return false;
    }
  }

  try {
    // Service Workerを使用して通知を表示
    if (swRegistration) {
      await swRegistration.showNotification(options.title, {
        body: options.body,
        data: options.data,
      });
      return true;
    } else {
      // フォールバック: 通常の通知API
      new Notification(options.title, {
        body: options.body,
      });
      return true;
    }
  } catch (e) {
    console.error("Failed to show notification:", e);
    return false;
  }
};

6. フックの戻り値

最後に、外部から使用できる関数と状態を返します:

return {
  permission,    // 現在の許可状態
  isEnabled,     // 通知が有効か無効か
  requestPermission,  // 許可要求関数
  showNotification,   // 通知表示関数
  toggleNotifications, // 有効/無効切り替え関数
};

通知機能の実装

次に、特定のイベントで通知を表示する実装例として、タイマーアプリケーションの例を見てみましょう。ここではタイマーが終了したときに通知を表示します:

// タイマー終了時に通知を表示する例
if (prev.remainingTime <= 0) {
  // タイマー終了時の処理
  clearInterval(id);
  if (prev.isBreak) {
    // 休憩終了時に通知を表示
    if (typeof window !== "undefined") {
      showNotification({
        title: "休憩終了",
        body: "休憩時間が終了しました。新しい作業セッションを開始しましょう!",
      });
    }
    
    // 状態更新処理...
  } else {
    // 作業終了時に通知を表示
    if (typeof window !== "undefined") {
      showNotification({
        title: "作業終了",
        body: "お疲れ様でした!休憩時間です。",
      });
    }
    
    // 状態更新処理...
  }
}

ユーザー設定の保存

ユーザーの通知設定を永続化するために、ローカルストレージを使用します:

// 設定の保存
try {
  localStorage.setItem("notificationsEnabled", newState.toString());
} catch (error) {
  console.error("Failed to save to localStorage:", error);
}

// 設定の読み込み
try {
  const storedIsEnabled = localStorage.getItem("notificationsEnabled");
  if (storedIsEnabled !== null) {
    setIsEnabled(storedIsEnabled === "true");
  }
} catch (error) {
  console.error("Failed to access localStorage:", error);
}

これにより、ページのリロードやブラウザの再起動後も、ユーザーの通知設定が保持されます。

ユーザーインターフェースの実装

通知許可の要求と有効/無効の切り替えのための、Next.jsとTypeScriptを使用したUIコンポーネントを実装します:

"use client";

import { useEffect, useState } from "react";
import { Button, Alert, AlertIcon, AlertTitle, AlertDescription, Box, Switch, HStack, Text } from "@yamada-ui/react";
import { BellIcon } from "lucide-react";
import { useNotification } from "@/hooks/useNotification";

export const NotificationPermission = () => {
  // 通知カスタムフックを使用
  const { permission, requestPermission, isEnabled, toggleNotifications } = useNotification();
  const [showAlert, setShowAlert] = useState(false);
  const [localEnabled, setLocalEnabled] = useState(isEnabled);
  
  // isEnabledの変更を追跡して、ローカルの状態を更新
  useEffect(() => {
    setLocalEnabled(isEnabled);
  }, [isEnabled]);
  
  // 初回レンダリング時に通知許可状態を確認
  useEffect(() => {
    if (typeof window === "undefined") return;
    if (permission === "unsupported") return;
    
    // 通知許可がまだ要求されていない場合、アラートを表示
    if (permission === "default") {
      setShowAlert(true);
    }
  }, [permission]);
  
  // 通知許可を要求する処理
  const handleRequestPermission = async () => {
    const result = await requestPermission();
    if (result === "granted") {
      setShowAlert(false);
      // ここで成功メッセージを表示する処理
    } else {
      // ここで警告メッセージを表示する処理
    }
  };
  
  // 通知の有効/無効を切り替える処理
  const handleToggleNotifications = () => {
    const newState = toggleNotifications();
    setLocalEnabled(newState);
    // ここで状態変更のフィードバックを表示する処理
  };
  
  // 条件に応じて異なるUIを表示(通知がサポートされていない場合)
  if (permission === "unsupported") {
    return (
      <Box mb={4}>
        <Alert status="warning" variant="subtle">
          <AlertIcon />
          <Box>
            <AlertTitle>通知機能がサポートされていません</AlertTitle>
            <AlertDescription>
              お使いのブラウザは通知機能をサポートしていないか、Service Workerが利用できません。
            </AlertDescription>
          </Box>
        </Alert>
      </Box>
    );
  }
  
  // 通知許可ボタンの表示
  if (!showAlert && permission !== "granted") {
    return (
      <Box mb={4}>
        <Button
          leftIcon={<BellIcon size={16} />}
          colorScheme="blue"
          size="sm"
          onClick={handleRequestPermission}
        >
          通知を許可する
        </Button>
      </Box>
    );
  }
  
  // 通知許可済みの場合の通知設定UI
  if (permission === "granted") {
    return (
      <Box mb={4}>
        {localEnabled && (
          <Alert status="success" variant="subtle" mb={4}>
            <AlertIcon />
            <Box>
              <AlertTitle>通知は許可されています</AlertTitle>
              <AlertDescription>
                タイマー終了時に通知が表示されます。
              </AlertDescription>
            </Box>
          </Alert>
        )}

        <HStack gap={4} alignItems="center">
          <Text fontWeight="bold">通知</Text>
          <Switch
            size="md"
            colorScheme="primary"
            isChecked={localEnabled}
            onChange={handleToggleNotifications}
          />
          <Text>{localEnabled ? "有効" : "無効"}</Text>
        </HStack>
      </Box>
    );
  }
  
  // 通知許可リクエスト用のアラート表示
  return (
    <Box mb={4}>
      <Alert status="info" variant="subtle">
        <AlertIcon />
        <Box>
          <AlertTitle>通知の許可</AlertTitle>
          <AlertDescription>
            タイマー終了時に通知を受け取るには、通知を許可してください。
          </AlertDescription>
          <Box mt={2}>
            <Button
              leftIcon={<BellIcon size={16} />}
              colorScheme="blue"
              size="sm"
              onClick={handleRequestPermission}
            >
              通知を許可する
            </Button>
          </Box>
        </Box>
      </Alert>
    </Box>
  );
};

このコンポーネントは次の役割を果たします:

  • 通知機能のサポート状況を確認
  • 通知許可の状態に基づいて適切なUIを表示
  • 通知の有効/無効を切り替える機能を提供
  • ユーザーに適切なフィードバックを表示

注意点と考慮事項

1. Next.jsとService Workerの統合

Next.jsではService Workerの統合に特有の考慮点があります:

  • next.config.js で適切な設定を行う必要がある場合があります
  • App RouterとPages Routerで実装方法が異なることがあります
  • SSRと静的生成の違いに注意が必要です

例えば、Next.jsの設定ファイルで以下のように設定できます:

/** @type {import('next').NextConfig} */
const nextConfig = {
  // Service Workerの処理に関連する設定
  headers: async () => {
    return [
      {
        source: '/sw.js',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=0, must-revalidate',
          },
        ],
      },
    ];
  },
};

module.exports = nextConfig;

2. ブラウザの互換性

Service WorkerとNotification APIは全てのブラウザでサポートされているわけではありません。機能の存在をチェックし、サポートされていない場合のフォールバックを提供することが重要です:

if (!("Notification" in window) || !("serviceWorker" in navigator)) {
  // 機能がサポートされていない場合の処理
  console.log("この機能はお使いのブラウザでサポートされていません。");
  return;
}

3. サーバーサイドレンダリングへの対応

Next.jsなどのフレームワークを使う場合、サーバーサイドレンダリング(SSR)時にwindownavigatorオブジェクトが存在しないことに注意する必要があります:

// サーバーサイドレンダリング時は何もしない
if (typeof window === "undefined") return;

特にクライアントコンポーネントには必ず "use client"; ディレクティブを追加し、サーバーサイドで実行されないようにすることが重要です。

4. セキュリティ上の制約

Service Workerにはいくつかのセキュリティ上の制約があります:

  • HTTPSまたはlocalhostでのみ動作する
  • 同一オリジンのページでのみ動作する
  • Next.jsの開発サーバーでは正常に動作しない場合がある(特にHot Module Replacement使用時)

まとめ

Next.jsとTypeScriptを使用したプロジェクトでService Workerを活用したデスクトップ通知機能の実装は、以下のステップで行います:

  1. public/sw.js にService Workerファイルを作成
  2. useNotification カスタムフックでService Workerの登録と通知管理機能を実装
  3. ユーザーインターフェースコンポーネントを作成して通知許可と設定を管理
  4. アプリケーションの適切な場所(例:タイマー終了時)で通知を表示
  5. ユーザー設定をローカルストレージに保存して永続化

参考リソース

Discussion