T-CREATOR

Vue.js で PWA を作る:オフライン対応の全手順

Vue.js で PWA を作る:オフライン対応の全手順

近年、モバイルファーストの時代において、ユーザーエクスペリエンスを向上させるための Web アプリケーション開発が重要視されています。その中でも、Progressive Web App(PWA)は、Web アプリケーションでありながらネイティブアプリのような体験を提供できる革新的な技術として注目を集めています。

Vue.js という人気のフロントエンドフレームワークを使って PWA を構築することで、開発効率を大幅に向上させながら、オフライン対応やインストール機能を備えた高品質なアプリケーションを作成できます。本記事では、Vue.js を用いた PWA 開発の全手順を、実際のコード例と共に詳しく解説いたします。

PWA とは何か

Progressive Web App の基本概念

Progressive Web App(PWA)は、Web アプリケーションでありながらネイティブアプリのような体験を提供する技術です。PWA は以下の特徴を持っています。

#特徴説明
1Progressiveすべてのユーザーに対して段階的な機能提供
2Responsiveあらゆるデバイスで最適な表示
3Offlineオフライン環境でも動作
4App-likeネイティブアプリのようなインターフェース
5SecureHTTPS 必須でセキュリティを確保
6Installableホーム画面にインストール可能

PWA を構成する主要な技術要素は以下の通りです:

  • Service Worker: バックグラウンドで動作するスクリプト
  • Web App Manifest: アプリケーションのメタデータ
  • HTTPS: セキュアな通信の確保

ネイティブアプリとの違い

PWA とネイティブアプリの主な違いをわかりやすく整理してみましょう。

#項目PWAネイティブアプリ
1配布方法Web ブラウザでアクセスまたは App StoreApp Store 経由
2インストールブラウザから直接インストールApp Store からダウンロード
3更新方法自動更新手動更新が必要
4開発コスト低い(Web 技術を活用)高い(プラットフォーム別開発)
5デバイス機能制限ありフルアクセス可能

PWA の大きな利点は、一度開発すれば複数のプラットフォームで動作することです。これにより、開発コストを大幅に削減しながら、幅広いユーザーにリーチできます。

オフライン機能の重要性

モバイルデバイスでは、通信環境が不安定な場合が多く、オフライン機能は必須の要素となっています。PWA のオフライン機能により、以下のメリットが得られます:

  • ユーザーエクスペリエンスの向上: 通信エラーによる操作中断を防ぐ
  • データの保持: 重要な情報を端末に保存
  • パフォーマンス向上: キャッシュによる高速読み込み

オフライン機能の実装には、Service Worker を活用したキャッシュ戦略が重要になります。

Vue.js で PWA を作る理由

Vue.js の優位性

Vue.js は、PWA 開発において以下の優位性を持っています:

学習コストの低さ Vue.js は、HTML テンプレートベースのシンプルな構文により、初心者でも習得しやすいフレームワークです。

javascript// Vue.jsのシンプルな記法例
<template>
  <div>
    <h1>{{ title }}</h1>
    <button @click="increment">Count: {{ count }}</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      title: 'My PWA App',
      count: 0
    }
  },
  methods: {
    increment() {
      this.count++
    }
  }
}
</script>

コンポーネント指向の設計 Vue.js のコンポーネント指向により、再利用可能なパーツを作成できます。これにより、PWA の複雑な機能を整理して実装できます。

高いパフォーマンス Vue.js は、仮想 DOM を使用して DOM の操作を最適化します。これにより、PWA でも高いパフォーマンスを維持できます。

既存のエコシステムとの親和性

Vue.js は、豊富なエコシステムを持っており、PWA 開発に必要な機能が揃っています:

#ツール/ライブラリ用途
1Vue CLIプロジェクトの作成・管理
2Vue Routerルーティング機能
3Vuex/Pinia状態管理
4Vue PWA PluginPWA 機能の自動設定
5Vuetify/QuasarUI コンポーネント

これらのツールを活用することで、PWA の開発効率を大幅に向上させることができます。

開発効率の向上

Vue.js を使用することで、以下の面で開発効率が向上します:

Hot Reload 機能 開発中のコード変更が即座に反映されるため、開発サイクルが短縮されます。

優れたデバッグ機能 Vue DevTools を使用することで、コンポーネントの状態やイベントを詳細に確認できます。

TypeScript 対応 Vue.js は TypeScript に対応しており、型安全な開発が可能です。

開発環境の準備

必要なツールとパッケージ

PWA 開発を始める前に、以下のツールとパッケージを準備しましょう:

#ツールバージョン用途
1Node.js18.x 以上JavaScript 実行環境
2Yarn1.22.x 以上パッケージマネージャー
3Vue CLI5.x 以上Vue.js プロジェクト管理
4現代的なブラウザ最新版開発・テスト用

まず、Node.js と Yarn のインストールを確認しましょう:

bash# Node.jsのバージョン確認
node --version

# Yarnのバージョン確認
yarn --version

Vue CLI と PWA プラグインの導入

Vue CLI をグローバルにインストールします:

bash# Vue CLIのインストール
yarn global add @vue/cli

# インストールの確認
vue --version

インストール後、Vue CLI が正常に動作することを確認してください。もしコマンドが見つからない場合は、以下のエラーが表示されます:

bashzsh: command not found: vue

このエラーが発生した場合は、PATH の設定を確認し、Yarn のグローバルパスを追加してください。

プロジェクトの初期設定

PWA 対応の Vue.js プロジェクトを作成します:

bash# PWAプロジェクトの作成
vue create my-pwa-app

# プロジェクトディレクトリに移動
cd my-pwa-app

プロジェクト作成時に以下の設定を選択します:

  1. Manually select featuresを選択
  2. Progressive Web App (PWA) Supportにチェック
  3. RouterVuexも含めることを推奨

作成されたプロジェクト構造は以下のようになります:

cssmy-pwa-app/
├── public/
│   ├── index.html
│   ├── manifest.json
│   └── img/
│       └── icons/
├── src/
│   ├── components/
│   ├── views/
│   ├── registerServiceWorker.js
│   └── main.js
├── package.json
└── yarn.lock

開発サーバーを起動して、PWA が正常に動作することを確認しましょう:

bash# 開発サーバーの起動
yarn serve

Service Worker の実装

Service Worker の基本概念

Service Worker は、Web アプリケーションとネットワークの間に位置するプロキシとして動作します。主な役割は以下の通りです:

  • リクエストの制御: ネットワークリクエストをインターセプト
  • キャッシュ管理: リソースの効率的な保存と取得
  • バックグラウンド処理: プッシュ通知やバックグラウンド同期

Vue CLI で PWA プロジェクトを作成すると、registerServiceWorker.jsが自動生成されます:

javascript// src/registerServiceWorker.js
import { register } from 'register-service-worker';

if (process.env.NODE_ENV === 'production') {
  register(`${process.env.BASE_URL}service-worker.js`, {
    ready() {
      console.log('Service worker is active.');
    },
    registered() {
      console.log('Service worker has been registered.');
    },
    cached() {
      console.log(
        'Content has been cached for offline use.'
      );
    },
    updatefound() {
      console.log('New content is downloading.');
    },
    updated() {
      console.log(
        'New content is available; please refresh.'
      );
    },
    offline() {
      console.log('No internet connection found.');
    },
    error(error) {
      console.error(
        'Error during service worker registration:',
        error
      );
    },
  });
}

キャッシュ戦略の選択

Service Worker では、以下のキャッシュ戦略を選択できます:

#戦略説明適用場面
1Cache Firstキャッシュを優先、なければネットワーク静的リソース
2Network Firstネットワークを優先、失敗時はキャッシュ動的コンテンツ
3Stale While Revalidateキャッシュを返しつつ、バックグラウンドで更新頻繁に更新される API

コードの具体的な実装

カスタム Service Worker を実装してみましょう。まず、public​/​sw.jsを作成します:

javascript// public/sw.js
const CACHE_NAME = 'my-pwa-v1';
const urlsToCache = [
  '/',
  '/static/js/bundle.js',
  '/static/css/main.css',
  '/offline.html',
];

// インストール時の処理
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      console.log('Opened cache');
      return cache.addAll(urlsToCache);
    })
  );
});

続いて、fetch イベントの処理を実装します:

javascript// public/sw.js (続き)
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      // キャッシュが存在する場合はそれを返す
      if (response) {
        return response;
      }

      // ネットワークリクエストのクローンを作成
      const fetchRequest = event.request.clone();

      return fetch(fetchRequest)
        .then((response) => {
          // レスポンスが無効な場合はそのまま返す
          if (!response || response.status !== 200) {
            return response;
          }

          // レスポンスをキャッシュに保存
          const responseToCache = response.clone();
          caches.open(CACHE_NAME).then((cache) => {
            cache.put(event.request, responseToCache);
          });

          return response;
        })
        .catch(() => {
          // ネットワークエラーの場合はオフラインページを表示
          return caches.match('/offline.html');
        });
    })
  );
});

Service Worker の更新処理も実装します:

javascript// public/sw.js (続き)
self.addEventListener('activate', (event) => {
  const cacheWhitelist = [CACHE_NAME];

  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

Web App Manifest の設定

manifest ファイルの作成

Web App Manifest は、PWA の外観と動作を定義する JSON ファイルです。Vue CLI ではpublic​/​manifest.jsonが自動生成されます:

json{
  "name": "My PWA App",
  "short_name": "PWA App",
  "theme_color": "#4DBA87",
  "background_color": "#000000",
  "display": "standalone",
  "start_url": "/",
  "icons": [
    {
      "src": "img/icons/android-chrome-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "img/icons/android-chrome-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

アイコンとメタデータの設定

PWA で使用するアイコンのサイズは以下の通りです:

#サイズ用途
1192x192Android ホーム画面
2512x512Android スプラッシュ画面
3180x180iOS ホーム画面
4152x152iPad ホーム画面

アイコンの設定を詳しく見てみましょう:

json{
  "icons": [
    {
      "src": "img/icons/android-chrome-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "img/icons/android-chrome-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ]
}

インストール可能な設定

PWA をインストール可能にするために、以下の設定を追加します:

json{
  "name": "My PWA App",
  "short_name": "PWA App",
  "description": "Vue.jsで作成したPWAアプリケーション",
  "theme_color": "#4DBA87",
  "background_color": "#ffffff",
  "display": "standalone",
  "orientation": "portrait",
  "start_url": "/",
  "scope": "/",
  "lang": "ja",
  "categories": ["productivity", "utilities"]
}

HTML ファイルでも manifest を参照する設定を追加します:

html<!-- public/index.html -->
<head>
  <meta charset="utf-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta
    name="viewport"
    content="width=device-width,initial-scale=1.0"
  />
  <link rel="manifest" href="manifest.json" />
  <meta name="theme-color" content="#4DBA87" />
  <title>My PWA App</title>
</head>

オフライン機能の実装

キャッシュ機能の詳細実装

より高度なキャッシュ機能を実装するために、異なるリソースタイプに応じたキャッシュ戦略を実装します:

javascript// src/utils/cacheStrategies.js
class CacheStrategies {
  static async cacheFirst(request) {
    const cachedResponse = await caches.match(request);
    if (cachedResponse) {
      return cachedResponse;
    }

    try {
      const networkResponse = await fetch(request);
      const cache = await caches.open('dynamic-cache-v1');
      await cache.put(request, networkResponse.clone());
      return networkResponse;
    } catch (error) {
      console.error('Cache first strategy failed:', error);
      throw error;
    }
  }

  static async networkFirst(request) {
    try {
      const networkResponse = await fetch(request);
      const cache = await caches.open('dynamic-cache-v1');
      await cache.put(request, networkResponse.clone());
      return networkResponse;
    } catch (error) {
      console.log('Network failed, trying cache:', error);
      return await caches.match(request);
    }
  }
}

データの同期処理

オフライン時のデータ同期を実装します。IndexedDB を使用してデータを保存し、オンライン復帰時に同期する仕組みを作成します:

javascript// src/utils/syncManager.js
class SyncManager {
  constructor() {
    this.dbName = 'PWAOfflineDB';
    this.dbVersion = 1;
    this.db = null;
  }

  async initDB() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(
        this.dbName,
        this.dbVersion
      );

      request.onerror = () => reject(request.error);
      request.onsuccess = () => {
        this.db = request.result;
        resolve(this.db);
      };

      request.onupgradeneeded = (event) => {
        const db = event.target.result;
        const objectStore = db.createObjectStore(
          'pendingSync',
          {
            keyPath: 'id',
            autoIncrement: true,
          }
        );
        objectStore.createIndex('timestamp', 'timestamp', {
          unique: false,
        });
      };
    });
  }

  async addPendingSync(data) {
    const transaction = this.db.transaction(
      ['pendingSync'],
      'readwrite'
    );
    const store = transaction.objectStore('pendingSync');
    await store.add({
      ...data,
      timestamp: Date.now(),
    });
  }
}

続いて、同期処理の実装を行います:

javascript// src/utils/syncManager.js (続き)
class SyncManager {
  async syncPendingData() {
    if (!navigator.onLine) {
      console.log(
        'オフライン状態のため同期をスキップします'
      );
      return;
    }

    const transaction = this.db.transaction(
      ['pendingSync'],
      'readwrite'
    );
    const store = transaction.objectStore('pendingSync');
    const request = store.getAll();

    request.onsuccess = async () => {
      const pendingItems = request.result;

      for (const item of pendingItems) {
        try {
          await this.syncItem(item);
          await store.delete(item.id);
          console.log(`アイテム ${item.id} を同期しました`);
        } catch (error) {
          console.error(
            `アイテム ${item.id} の同期に失敗:`,
            error
          );
        }
      }
    };
  }

  async syncItem(item) {
    const response = await fetch('/api/sync', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(item.data),
    });

    if (!response.ok) {
      throw new Error(
        `HTTP error! status: ${response.status}`
      );
    }
  }
}

エラーハンドリング

PWA でよく発生するエラーとその対処法を実装します:

javascript// src/utils/errorHandler.js
class PWAErrorHandler {
  static handleServiceWorkerError(error) {
    if (error.code === 'NETWORK_ERROR') {
      console.log(
        'ネットワークエラー: オフラインモードに切り替えます'
      );
      this.showOfflineMessage();
    } else if (error.code === 'CACHE_ERROR') {
      console.error('キャッシュエラー:', error.message);
      this.clearCacheAndReload();
    }
  }

  static showOfflineMessage() {
    const offlineMessage = document.createElement('div');
    offlineMessage.className = 'offline-message';
    offlineMessage.textContent =
      'オフライン状態です。一部の機能が制限されます。';
    document.body.appendChild(offlineMessage);
  }

  static async clearCacheAndReload() {
    try {
      const cacheNames = await caches.keys();
      await Promise.all(
        cacheNames.map((cacheName) =>
          caches.delete(cacheName)
        )
      );
      window.location.reload();
    } catch (error) {
      console.error('キャッシュクリアに失敗:', error);
    }
  }
}

実践的な応用例

ToDo 管理アプリの作成

実際に PWA の機能を活用した ToDo 管理アプリを作成してみましょう。まず、コンポーネントの基本構造を作成します:

vue<!-- src/components/TodoApp.vue -->
<template>
  <div class="todo-app">
    <header class="todo-header">
      <h1>PWA Todo Manager</h1>
      <div
        class="connection-status"
        :class="{ offline: !isOnline }"
      >
        {{ isOnline ? 'オンライン' : 'オフライン' }}
      </div>
    </header>

    <form @submit.prevent="addTodo" class="todo-form">
      <input
        v-model="newTodo"
        type="text"
        placeholder="新しいタスクを入力"
        required
      />
      <button type="submit">追加</button>
    </form>

    <ul class="todo-list">
      <li
        v-for="todo in todos"
        :key="todo.id"
        class="todo-item"
      >
        <input
          type="checkbox"
          v-model="todo.completed"
          @change="updateTodo(todo)"
        />
        <span :class="{ completed: todo.completed }">
          {{ todo.text }}
        </span>
        <button
          @click="deleteTodo(todo.id)"
          class="delete-btn"
        >
          削除
        </button>
      </li>
    </ul>
  </div>
</template>

続いて、JavaScript の実装を行います:

javascript// src/components/TodoApp.vue (script部分)
<script>
import { SyncManager } from '@/utils/syncManager'

export default {
  name: 'TodoApp',
  data() {
    return {
      newTodo: '',
      todos: [],
      isOnline: navigator.onLine,
      syncManager: null
    }
  },

  async mounted() {
    this.syncManager = new SyncManager()
    await this.syncManager.initDB()

    // オンライン状態の監視
    window.addEventListener('online', this.handleOnline)
    window.addEventListener('offline', this.handleOffline)

    await this.loadTodos()
  },

  methods: {
    async addTodo() {
      if (!this.newTodo.trim()) return

      const todo = {
        id: Date.now(),
        text: this.newTodo,
        completed: false,
        createdAt: new Date().toISOString()
      }

      this.todos.push(todo)
      this.newTodo = ''

      await this.saveTodos()

      if (this.isOnline) {
        await this.syncTodoToServer(todo)
      } else {
        await this.syncManager.addPendingSync({
          type: 'CREATE_TODO',
          data: todo
        })
      }
    }
  }
}
</script>

画像キャッシュの実装

画像リソースの効率的なキャッシュ機能を実装します:

javascript// src/utils/imageCache.js
class ImageCache {
  constructor() {
    this.cacheName = 'image-cache-v1';
  }

  async cacheImage(imageUrl) {
    try {
      const cache = await caches.open(this.cacheName);
      const response = await fetch(imageUrl);

      if (response.ok) {
        await cache.put(imageUrl, response.clone());
        console.log(
          `画像をキャッシュしました: ${imageUrl}`
        );
      }

      return response;
    } catch (error) {
      console.error('画像キャッシュエラー:', error);
      throw error;
    }
  }

  async getCachedImage(imageUrl) {
    const cache = await caches.open(this.cacheName);
    const cachedResponse = await cache.match(imageUrl);

    if (cachedResponse) {
      console.log(`キャッシュから画像を取得: ${imageUrl}`);
      return cachedResponse;
    }

    return null;
  }
}

通知機能の追加

プッシュ通知機能を実装します:

javascript// src/utils/notificationManager.js
class NotificationManager {
  async requestPermission() {
    if (!('Notification' in window)) {
      console.log(
        'このブラウザは通知をサポートしていません'
      );
      return false;
    }

    const permission =
      await Notification.requestPermission();
    return permission === 'granted';
  }

  async showNotification(title, options = {}) {
    const hasPermission = await this.requestPermission();

    if (!hasPermission) {
      console.log('通知の許可が得られませんでした');
      return;
    }

    const defaultOptions = {
      body: 'PWAアプリからの通知です',
      icon: '/img/icons/android-chrome-192x192.png',
      badge: '/img/icons/android-chrome-192x192.png',
      vibrate: [200, 100, 200],
      data: {
        dateOfArrival: Date.now(),
        primaryKey: 1,
      },
    };

    const notification = new Notification(title, {
      ...defaultOptions,
      ...options,
    });

    notification.addEventListener('click', () => {
      window.focus();
      notification.close();
    });

    return notification;
  }
}

Service Worker でのプッシュ通知処理も実装します:

javascript// public/sw.js (通知処理追加)
self.addEventListener('push', (event) => {
  const options = {
    body: event.data ? event.data.text() : 'PWAからの通知',
    icon: '/img/icons/android-chrome-192x192.png',
    badge: '/img/icons/android-chrome-192x192.png',
    vibrate: [200, 100, 200],
    data: {
      dateOfArrival: Date.now(),
      primaryKey: 1,
    },
    actions: [
      {
        action: 'explore',
        title: '詳細を見る',
        icon: '/img/icons/checkmark.png',
      },
      {
        action: 'close',
        title: '閉じる',
        icon: '/img/icons/xmark.png',
      },
    ],
  };

  event.waitUntil(
    self.registration.showNotification('PWA通知', options)
  );
});

まとめ

本記事では、Vue.js を使用して PWA を作成する全手順を詳しく解説いたしました。PWA の基本概念から始まり、実際の開発環境構築、Service Worker の実装、Web App Manifest の設定、そしてオフライン機能の実装まで、実践的な内容を網羅しています。

重要なポイントの再確認

  • PWA の三つの柱: Service Worker、Web App Manifest、HTTPS が必須
  • キャッシュ戦略: リソースの性質に応じて適切な戦略を選択
  • オフライン対応: IndexedDB を活用したデータ同期機能
  • ユーザー体験: 通知機能やインストール機能による利便性向上

今後の発展

PWA 技術は今後も進化し続けており、以下のような新機能が期待されています:

  • WebAssembly 統合: より高性能な処理の実現
  • デバイス API 拡張: より多くのネイティブ機能へのアクセス
  • オフライン AI: 端末内での AI 処理機能

Vue.js と PWA の組み合わせにより、ユーザーにとって価値の高い Web アプリケーションを効率的に開発できます。本記事で紹介した手法を参考に、ぜひ実際のプロジェクトで PWA を活用してください。

関連リンク