T-CREATOR

Turbopack で PWA 対応アプリを構築する

Turbopack で PWA 対応アプリを構築する

モダンな Web 開発において、高速なビルドツールと優れたユーザー体験を両立させることは、開発者の永遠の課題です。Turbopack という革新的なバンドラーの登場により、この課題に新しい解決策が提示されました。

特に PWA(Progressive Web App)の開発において、Turbopack の高速性と PWA の優れたユーザー体験を組み合わせることで、開発効率とユーザー満足度を同時に向上させることが可能になります。

この記事では、Turbopack を使用して PWA 対応アプリを構築する実践的な手順を、実際のコード例とエラー解決方法を交えて詳しく解説していきます。

Turbopack とは

Turbopack は、Vercel が開発した次世代の JavaScript バンドラーです。Rust で書かれた高速なバンドラーとして、従来の Webpack を大幅に上回るパフォーマンスを提供します。

高速なバンドラーとしての特徴

Turbopack の最大の特徴は、その驚異的な高速性にあります。主な特徴として以下が挙げられます:

  • インクリメンタルコンパイル: 変更されたファイルのみを再コンパイル
  • メモリ効率の最適化: 効率的なメモリ使用により大規模プロジェクトでも高速動作
  • 並列処理: 複数のファイルを同時に処理することで処理時間を短縮
  • キャッシュ戦略: 賢いキャッシュ機能により再ビルド時間を最小化

実際の開発では、Webpack と比較して 10 倍以上の高速化を実現できるケースも珍しくありません。

Webpack との比較

従来の Webpack と Turbopack の違いを理解することで、なぜ Turbopack が注目されているのかが明確になります。

項目WebpackTurbopack
開発言語JavaScriptRust
初期ビルド時間基準値約 700 倍高速
増分ビルド時間基準値約 10 倍高速
メモリ使用量
設定の複雑さ

この比較からも分かるように、Turbopack は開発体験を劇的に改善する可能性を秘めています。

Next.js 13+での採用状況

Next.js 13 以降では、Turbopack が実験的機能として組み込まれています。これにより、Next.js プロジェクトで簡単に Turbopack を試すことが可能になりました。

bash# Next.js 13+でTurbopackを有効化
yarn dev --turbo

このコマンド一つで、Turbopack の恩恵を受けることができます。ただし、まだ実験的機能であるため、本番環境での使用には注意が必要です。

PWA(Progressive Web App)の基礎

PWA は、Web 技術を使用しながらネイティブアプリのような体験を提供するアプリケーションです。オフライン対応やプッシュ通知など、従来の Web アプリでは実現困難だった機能を実現できます。

PWA の定義とメリット

PWA は以下の 3 つの特徴を持つ Web アプリケーションです:

  1. 信頼性: ネットワークが不安定でも動作する
  2. 高速性: 素早く読み込まれ、スムーズに動作する
  3. 魅力的: ネイティブアプリのような体験を提供する

PWA の主なメリットは以下の通りです:

  • オフライン対応: インターネット接続がなくても基本的な機能が利用可能
  • インストール可能: ホーム画面に追加してネイティブアプリのように使用
  • プッシュ通知: ユーザーエンゲージメントの向上
  • 自動更新: 常に最新版が利用可能
  • SEO 対応: 検索エンジンでの発見性が高い

オフライン対応の仕組み

PWA のオフライン対応は、Service Worker と Cache API を組み合わせることで実現されます。

Service Worker は、ブラウザのバックグラウンドで動作するスクリプトで、ネットワークリクエストをインターセプトしてキャッシュ戦略を実装します。

javascript// Service Workerの基本的なキャッシュ戦略
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      // キャッシュに存在する場合はキャッシュから返す
      if (response) {
        return response;
      }
      // キャッシュにない場合はネットワークから取得
      return fetch(event.request);
    })
  );
});

この仕組みにより、一度アクセスしたリソースはローカルにキャッシュされ、オフライン時にも利用可能になります。

インストール可能な Web アプリの特徴

PWA をインストール可能にするためには、Web App Manifest ファイルが必要です。このファイルにより、アプリの名前、アイコン、テーマカラーなどの情報をブラウザに提供します。

json{
  "name": "My PWA App",
  "short_name": "PWA App",
  "description": "A Progressive Web App built with Turbopack",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#000000",
  "icons": [
    {
      "src": "/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    }
  ]
}

この設定により、ユーザーはブラウザの「ホーム画面に追加」機能を使用して PWA をインストールできます。

Turbopack 環境のセットアップ

Turbopack を使用した PWA 開発環境を構築していきます。段階的に設定を行い、各段階で動作確認を行うことで、問題の早期発見と解決が可能になります。

Next.js プロジェクトの作成

まず、新しい Next.js プロジェクトを作成します。Yarn を使用してプロジェクトを初期化しましょう。

bash# Next.jsプロジェクトの作成
yarn create next-app@latest my-pwa-app --typescript --tailwind --eslint
cd my-pwa-app

このコマンドにより、TypeScript、Tailwind CSS、ESLint が設定された Next.js プロジェクトが作成されます。

プロジェクト作成時に以下のような質問が表示されますが、PWA 開発に適した選択を行います:

vbnetWould you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? Yes
Would you like to use `src/` directory? Yes
Would you like to use App Router? Yes
Would you like to customize the default import alias? No

Turbopack の有効化方法

Next.js 13+では、Turbopack を簡単に有効化できます。package.json の scripts セクションを編集して、開発サーバーで Turbopack を使用するように設定します。

json{
  "scripts": {
    "dev": "next dev --turbo",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  }
}

また、next.config.js ファイルで Turbopack の設定をカスタマイズすることも可能です。

javascript/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    turbo: {
      rules: {
        '*.svg': {
          loaders: ['@svgr/webpack'],
          as: '*.js',
        },
      },
    },
  },
};

module.exports = nextConfig;

開発環境の確認

Turbopack が正しく動作しているかを確認するために、開発サーバーを起動します。

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

正常に起動すると、以下のようなメッセージが表示されます:

csharp- ready started server on 0.0.0.0:3000, url: http://localhost:3000
- event compiled client and server successfully in 2.3s (18 modules)

Turbopack が有効になっている場合、コンソールに「Turbopack」の文字が表示されることがあります。

もし以下のようなエラーが発生した場合は、Node.js のバージョンを確認してください:

arduinoError: Turbopack requires Node.js 18.17 or later

このエラーは、Node.js のバージョンが古い場合に発生します。Node.js 18.17 以降にアップデートすることで解決できます。

PWA 対応の実装手順

Turbopack 環境が整ったら、PWA 機能を段階的に実装していきます。各段階で動作確認を行い、問題があれば即座に修正することで、安定した PWA を構築できます。

Service Worker の設定

Service Worker は PWA の核となる機能です。まず、public ディレクトリに Service Worker ファイルを作成します。

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

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

// フェッチイベントの処理
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      // キャッシュに存在する場合はキャッシュから返す
      if (response) {
        return response;
      }
      return fetch(event.request);
    })
  );
});

次に、アプリケーションで Service Worker を登録します。app/layout.tsx ファイルを編集して、Service Worker の登録処理を追加します。

typescript// app/layout.tsx
'use client';

import { useEffect } from 'react';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  useEffect(() => {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker
        .register('/sw.js')
        .then((registration) => {
          console.log('SW registered: ', registration);
        })
        .catch((registrationError) => {
          console.log(
            'SW registration failed: ',
            registrationError
          );
        });
    }
  }, []);

  return (
    <html lang='ja'>
      <body>{children}</body>
    </html>
  );
}

Web App Manifest の作成

Web App Manifest ファイルを作成して、PWA のメタデータを定義します。

json// public/manifest.json
{
  "name": "Turbopack PWA App",
  "short_name": "Turbopack PWA",
  "description": "A Progressive Web App built with Turbopack and Next.js",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#000000",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable any"
    }
  ]
}

HTML の head セクションに manifest ファイルへのリンクを追加します。

typescript// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang='ja'>
      <head>
        <link rel='manifest' href='/manifest.json' />
        <meta name='theme-color' content='#000000' />
        <meta
          name='apple-mobile-web-app-capable'
          content='yes'
        />
        <meta
          name='apple-mobile-web-app-status-bar-style'
          content='default'
        />
        <meta
          name='apple-mobile-web-app-title'
          content='Turbopack PWA'
        />
        <link
          rel='apple-touch-icon'
          href='/icon-192x192.png'
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

オフライン機能の実装

より高度なオフライン機能を実装するために、Service Worker を改良します。Network First 戦略を使用して、ネットワークが利用可能な場合はネットワークから取得し、利用できない場合はキャッシュから取得するようにします。

javascript// public/sw.js(改良版)
const CACHE_NAME = 'my-pwa-cache-v2';
const STATIC_CACHE = 'static-cache-v2';
const DYNAMIC_CACHE = 'dynamic-cache-v2';

// キャッシュする静的リソース
const STATIC_ASSETS = [
  '/',
  '/offline.html',
  '/static/js/bundle.js',
  '/static/css/main.css',
];

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

// アクティベート時の処理
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (
            cacheName !== STATIC_CACHE &&
            cacheName !== DYNAMIC_CACHE
          ) {
            console.log('Deleting old cache:', cacheName);
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

// フェッチイベントの処理(Network First戦略)
self.addEventListener('fetch', (event) => {
  const { request } = event;

  // APIリクエストの場合はNetwork First戦略
  if (request.url.includes('/api/')) {
    event.respondWith(
      fetch(request)
        .then((response) => {
          const responseClone = response.clone();
          caches.open(DYNAMIC_CACHE).then((cache) => {
            cache.put(request, responseClone);
          });
          return response;
        })
        .catch(() => {
          return caches.match(request);
        })
    );
  } else {
    // 静的リソースの場合はCache First戦略
    event.respondWith(
      caches.match(request).then((response) => {
        return response || fetch(request);
      })
    );
  }
});

オフライン時のフォールバックページも作成します。

html<!-- public/offline.html -->
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>オフライン - Turbopack PWA</title>
    <style>
      body {
        font-family: Arial, sans-serif;
        text-align: center;
        padding: 50px;
        background-color: #f5f5f5;
      }
      .offline-message {
        background: white;
        padding: 30px;
        border-radius: 10px;
        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
      }
    </style>
  </head>
  <body>
    <div class="offline-message">
      <h1>オフラインです</h1>
      <p>インターネット接続を確認してください。</p>
      <button onclick="window.location.reload()">
        再試行
      </button>
    </div>
  </body>
</html>

パフォーマンス最適化

Turbopack と PWA を組み合わせることで、開発効率とユーザー体験の両方を最適化できます。各段階でパフォーマンスを測定し、継続的な改善を行うことが重要です。

Turbopack の高速化機能

Turbopack の高速化機能を最大限活用するために、設定を最適化します。

javascript// next.config.js(最適化版)
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    turbo: {
      rules: {
        // SVGファイルの最適化
        '*.svg': {
          loaders: ['@svgr/webpack'],
          as: '*.js',
        },
        // 画像の最適化
        '*.{png,jpg,jpeg,gif,webp}': {
          loaders: ['file-loader'],
          as: '*.js',
        },
      },
    },
  },
  // 画像最適化の設定
  images: {
    formats: ['image/webp', 'image/avif'],
    deviceSizes: [
      640, 750, 828, 1080, 1200, 1920, 2048, 3840,
    ],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
  // 圧縮設定
  compress: true,
  // 不要なファイルの除外
  webpack: (config, { dev, isServer }) => {
    if (!dev && !isServer) {
      config.optimization.splitChunks = {
        chunks: 'all',
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
            chunks: 'all',
          },
        },
      };
    }
    return config;
  },
};

module.exports = nextConfig;

PWA の読み込み速度改善

PWA の読み込み速度を改善するために、リソースの最適化を行います。

typescript// app/layout.tsx(最適化版)
import { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Turbopack PWA App',
  description:
    'A Progressive Web App built with Turbopack and Next.js',
  manifest: '/manifest.json',
  themeColor: '#000000',
  viewport:
    'width=device-width, initial-scale=1, maximum-scale=1',
  appleWebApp: {
    capable: true,
    statusBarStyle: 'default',
    title: 'Turbopack PWA',
  },
  icons: {
    icon: [
      {
        url: '/icon-192x192.png',
        sizes: '192x192',
        type: 'image/png',
      },
      {
        url: '/icon-512x512.png',
        sizes: '512x512',
        type: 'image/png',
      },
    ],
    apple: [
      {
        url: '/icon-192x192.png',
        sizes: '192x192',
        type: 'image/png',
      },
    ],
  },
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang='ja'>
      <head>
        {/* プリロード設定 */}
        <link
          rel='preload'
          href='/static/css/main.css'
          as='style'
        />
        <link
          rel='preload'
          href='/static/js/bundle.js'
          as='script'
        />

        {/* DNSプリフェッチ */}
        <link
          rel='dns-prefetch'
          href='//fonts.googleapis.com'
        />
        <link rel='dns-prefetch' href='//cdn.example.com' />
      </head>
      <body>{children}</body>
    </html>
  );
}

キャッシュ戦略の実装

より効率的なキャッシュ戦略を実装して、ユーザー体験を向上させます。

javascript// public/sw.js(高度なキャッシュ戦略)
const CACHE_NAMES = {
  STATIC: 'static-cache-v3',
  DYNAMIC: 'dynamic-cache-v3',
  API: 'api-cache-v3',
};

const STATIC_ASSETS = [
  '/',
  '/offline.html',
  '/static/css/main.css',
  '/static/js/bundle.js',
];

const API_CACHE_DURATION = 5 * 60 * 1000; // 5分

// インストール時の処理
self.addEventListener('install', (event) => {
  event.waitUntil(
    Promise.all([
      caches
        .open(CACHE_NAMES.STATIC)
        .then((cache) => cache.addAll(STATIC_ASSETS)),
      caches.open(CACHE_NAMES.DYNAMIC),
      caches.open(CACHE_NAMES.API),
    ])
  );
});

// アクティベート時の処理
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (
            !Object.values(CACHE_NAMES).includes(cacheName)
          ) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

// フェッチイベントの処理
self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // APIリクエストの処理
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(handleApiRequest(request));
  }
  // 静的リソースの処理
  else if (
    request.destination === 'style' ||
    request.destination === 'script'
  ) {
    event.respondWith(handleStaticRequest(request));
  }
  // その他のリクエスト
  else {
    event.respondWith(handleOtherRequest(request));
  }
});

// APIリクエストの処理(Stale While Revalidate戦略)
async function handleApiRequest(request) {
  const cache = await caches.open(CACHE_NAMES.API);
  const cachedResponse = await cache.match(request);

  try {
    const networkResponse = await fetch(request);

    if (networkResponse.ok) {
      cache.put(request, networkResponse.clone());
    }

    return networkResponse;
  } catch (error) {
    if (cachedResponse) {
      return cachedResponse;
    }
    throw error;
  }
}

// 静的リソースの処理(Cache First戦略)
async function handleStaticRequest(request) {
  const cache = await caches.open(CACHE_NAMES.STATIC);
  const cachedResponse = await cache.match(request);

  if (cachedResponse) {
    return cachedResponse;
  }

  try {
    const networkResponse = await fetch(request);
    cache.put(request, networkResponse.clone());
    return networkResponse;
  } catch (error) {
    return new Response('Offline', { status: 503 });
  }
}

// その他のリクエストの処理(Network First戦略)
async function handleOtherRequest(request) {
  const cache = await caches.open(CACHE_NAMES.DYNAMIC);

  try {
    const networkResponse = await fetch(request);
    cache.put(request, networkResponse.clone());
    return networkResponse;
  } catch (error) {
    const cachedResponse = await cache.match(request);
    if (cachedResponse) {
      return cachedResponse;
    }
    return cache.match('/offline.html');
  }
}

デプロイとテスト

構築した PWA アプリケーションを本番環境にデプロイし、各種機能が正常に動作することを確認します。段階的なテストにより、問題の早期発見と解決が可能になります。

本番環境での動作確認

まず、本番用のビルドを作成してローカルでテストします。

bash# 本番用ビルドの作成
yarn build

# 本番サーバーの起動
yarn start

ビルドが成功すると、以下のような出力が表示されます:

css✓ Creating an optimized production build
✓ Compiled successfully
✓ Collecting page data
✓ Generating static pages
✓ Finalizing page optimization

Route (app)                              Size     First Load JS
┌ ○ /                                  5.4 kB        89.2 kB
└ ○ /_not-found                        182 B          84 kB
+ First Load JS shared by all          83.8 kB
  ├ chunks/main-app.js                 83.8 kB
  └ chunks/webpack.js                  0 B

✓ Ready in 2.3s

ビルド時に以下のような警告が表示される場合がありますが、PWA 機能には影響しません:

vbnetWarning: The following files are missing from the build output:
  - public/sw.js
  - public/manifest.json

これらのファイルは手動で作成したため、Next.js のビルドプロセスでは認識されませんが、正常に動作します。

PWA 機能の検証方法

PWA 機能が正常に動作しているかを確認するために、ブラウザの開発者ツールを使用します。

Service Worker の確認:

  1. ブラウザの開発者ツールを開く
  2. Application タブを選択
  3. Service Workers セクションで登録状況を確認

正常に登録されている場合、以下のような情報が表示されます:

makefileName: /sw.js
Status: activated and running
Source: /sw.js

Manifest の確認:

  1. Application タブの Manifest セクションを確認
  2. アプリ名、アイコン、テーマカラーが正しく設定されているか確認

インストール可能の確認:

  1. アドレスバーの右側にインストールアイコンが表示されるか確認
  2. または、開発者ツールの Application タブで「Installable」が「Yes」と表示されるか確認

パフォーマンス測定

PWA のパフォーマンスを測定するために、Lighthouse を使用します。

bash# Lighthouse CLIのインストール
yarn global add lighthouse

# パフォーマンス測定の実行
lighthouse http://localhost:3000 --output html --output-path ./lighthouse-report.html

Lighthouse の測定結果で、以下の項目を確認します:

  • Performance: 読み込み速度のスコア
  • Accessibility: アクセシビリティのスコア
  • Best Practices: ベストプラクティスの遵守度
  • SEO: SEO 最適化のスコア
  • Progressive Web App: PWA 機能の実装度

理想的なスコアは各項目で 90 点以上です。特に PWA 項目では、以下の条件を満たす必要があります:

  • Service Worker が登録されている
  • Web App Manifest が設定されている
  • HTTPS で配信されている
  • オフライン機能が動作する

よくある問題と解決策:

  1. Service Worker が登録されない

    • ファイルパスが正しいか確認
    • HTTPS 環境でテストしているか確認
  2. Manifest が読み込まれない

    • manifest.json ファイルが public ディレクトリに配置されているか確認
    • HTML の head セクションにリンクが正しく記述されているか確認
  3. オフライン機能が動作しない

    • Service Worker のキャッシュ戦略を確認
    • キャッシュするリソースのパスが正しいか確認

まとめ

Turbopack と PWA を組み合わせることで、開発効率とユーザー体験の両方を大幅に向上させることができます。

Turbopack の高速なビルド機能により、開発時の待機時間を最小限に抑え、PWA の優れたユーザー体験により、ユーザーの満足度を高めることが可能です。

この記事で紹介した手順に従って実装することで、モダンな Web アプリケーションを効率的に構築できます。特に、段階的な実装と継続的なテストにより、安定した PWA を開発できるでしょう。

Turbopack はまだ発展途上の技術ですが、その可能性は非常に大きく、今後の Web 開発において重要な役割を果たすことが期待されます。PWA と組み合わせることで、ネイティブアプリに匹敵するユーザー体験を提供しながら、Web 技術の柔軟性と保守性を維持できます。

開発を始める際は、まず小規模なプロジェクトで Turbopack と PWA の基本機能を試し、徐々に機能を拡張していくことをお勧めします。この段階的なアプローチにより、問題の早期発見と解決が可能になり、より安定したアプリケーションを構築できるでしょう。

関連リンク