T-CREATOR

Vite でマルチページアプリ(MPA)を構築する方法

Vite でマルチページアプリ(MPA)を構築する方法

近年、Web 開発の世界では興味深い変化が起きています。一時期は SPA(Single Page Application)が主流となっていましたが、最近では MPA(Multi Page Application)への回帰が注目されているのです。特に、高速なビルドツールとして人気を集める Vite を使った MPA 開発が、多くの開発者の関心を集めています。

本記事では、Vite でマルチページアプリケーションを構築する具体的な方法について、基礎から実践まで詳しく解説いたします。従来の課題を解決し、モダンな開発体験を実現するためのアプローチをご紹介しますので、ぜひ最後までお付き合いください。

背景

SPA から MPA への回帰トレンド

Web 開発の歴史を振り返ると、静的な HTML ページから始まり、Ajax 技術の普及、そして React や Vue などのフレームワークによる SPA 全盛期を経て、現在はMPA への再評価が高まっています。

この背景には、いくつかの重要な要因があります。まず、SEO 対応の複雑さが挙げられます。SPA では初期ロード時に JavaScript が実行されるまでコンテンツが表示されないため、検索エンジンクローラーに対する配慮が必要でした。一方、MPA は各ページが独自の HTML を持つため、自然に SEO フレンドリーな構造となります。

typescript// SPA(React)でのSEO対応例
import { Helmet } from 'react-helmet';

const ProductPage = ({ product }) => {
  return (
    <>
      <Helmet>
        <title>{product.name} - ECサイト</title>
        <meta
          name='description'
          content={product.description}
        />
      </Helmet>
      {/* コンポーネント内容 */}
    </>
  );
};

また、パフォーマンスの観点からも重要な変化があります。SPA では全ての機能を含んだ大きな JavaScript バンドルをダウンロードする必要がありますが、MPA では必要な機能のみを各ページで読み込むことができます。

Vite が注目される理由

Vite は 2020 年に Evan You 氏によって開発された、次世代のフロントエンドビルドツールです。従来の Webpack ベースのツールと比較して、劇的に速い開発サーバーの起動時間と**高速な HMR(Hot Module Replacement)**を実現しています。

Vite が注目される主な理由は以下の通りです:

項目WebpackVite
開発サーバー起動時間10-30 秒1-3 秒
HMR 応答時間1-5 秒50-200ms
ビルド方式バンドル方式ES モジュール + Rollup
設定の複雑さ高い低い

特に MPA 開発においては、Vite のマルチエントリーポイント対応柔軟な設定システムが大きなメリットとなります。

javascript// vite.config.js でのマルチエントリー設定例
export default {
  build: {
    rollupOptions: {
      input: {
        main: 'index.html',
        about: 'about.html',
        contact: 'contact.html',
      },
    },
  },
};

マルチページアプリケーションのメリット

MPA には従来の SPA にはない独自のメリットがあります。ページの独立性が最も重要な特徴で、各ページが独自の JavaScript と CSS を持つため、一つのページでエラーが発生しても他のページに影響を与えません。

初期表示速度の向上も見逃せないポイントです。ユーザーは必要なページのリソースのみをダウンロードするため、特に回線速度が遅い環境モバイルデバイスでのユーザー体験が大幅に改善されます。

さらに、段階的な移行が可能な点も企業開発では重要です。既存のサイトから一部のページをモダン化したい場合、SPA では全体を書き換える必要がありますが、MPA なら必要な部分から段階的に更新できます。

課題

従来の MPA 開発の問題点

従来の MPA 開発では、多くの技術的課題が存在していました。最も大きな問題はコード重複の発生です。各ページで似たような機能を実装する際、共通化が困難で、保守性が著しく低下していました。

html<!-- 従来のMPA:各ページでスクリプトが重複 -->
<!-- index.html -->
<script>
  function validateForm() {
    // フォームバリデーション処理
  }
</script>

<!-- contact.html -->
<script>
  function validateForm() {
    // 同じバリデーション処理が重複
  }
</script>

また、ビルドプロセスの複雑化も深刻な問題でした。Sass のコンパイル、JS のトランスパイル、画像の最適化などを個別に設定する必要があり、開発者の負担が増大していました。

開発効率の低下も無視できません。ファイルの変更後にブラウザを手動リロードする必要があり、リアルタイムでの開発体験が提供されていませんでした。これらの問題は、モダンな Web 開発の標準と比較すると明らかに劣っていたのです。

ビルドツールの選定における課題

MPA 開発におけるビルドツール選定は、特に複雑な判断を要します。Webpackは非常に強力ですが、設定の複雑さが問題となります。特にマルチエントリーポイントの設定では、以下のようなエラーに遭遇することがよくありました:

javascriptError: Cannot resolve entry module 'undefined'
    at compilation.seal (/node_modules/webpack/lib/Compilation.js:1423:28)

このエラーは、エントリーポイントの設定が正しく認識されない場合に発生し、解決には深い Webpack の知識が必要でした。

Parcelはゼロコンフィグを謳っていますが、カスタマイズの自由度が制限されており、企業開発では要件を満たせないケースが多く見られました。

Rollupは軽量で高速ですが、開発サーバー機能が弱く、開発体験の面で課題がありました。このように、各ツールには一長一短があり、完璧な解決策を見つけることが困難だったのです。

パフォーマンスと DX の両立

開発者体験(DX:Developer Experience)とプロダクションパフォーマンスの両立は、従来の MPA 開発における最大の課題の一つでした。

開発時には高速なビルド即座のフィードバックが求められる一方で、プロダクションでは最適化されたバンドル高いパフォーマンスが必要です。しかし、これらの要求は往々にして相反するものでした。

json{
  "scripts": {
    "dev": "webpack serve --mode development",
    "build": "webpack --mode production"
  }
}

開発モードでは未最適化のコードで高速ビルドを実現し、プロダクションモードでは重い最適化処理を行うという方法が一般的でしたが、この二重の設定管理が複雑さを増していました。

また、コード分割の実装も課題でした。共通コンポーネントを効率的に分割し、キャッシュ効率を高めつつ、初期ロード時間を短縮することは、高度な技術とノウハウを要求していました。

解決策

Vite による MPA 構築のアプローチ

Vite は前述の課題を革新的なアプローチで解決します。開発時にはネイティブ ES モジュールを活用し、プロダクションビルドでは Rollup による最適化を行うというハイブリッド方式を採用しています。

まず、Vite の基本的な MPA 設定を見てみましょう:

javascript// vite.config.js
import { defineConfig } from 'vite';
import { resolve } from 'path';

export default defineConfig({
  // 開発サーバーの設定
  server: {
    port: 3000,
    open: true,
  },
  // ビルド設定
  build: {
    rollupOptions: {
      // 複数のエントリーポイントを定義
      input: {
        main: resolve(__dirname, 'index.html'),
        about: resolve(__dirname, 'about/index.html'),
        products: resolve(__dirname, 'products/index.html'),
        contact: resolve(__dirname, 'contact/index.html'),
      },
    },
  },
});

この設定により、各 HTML ファイルが独立したエントリーポイントとして認識され、適切な JavaScript と CSS が自動的に注入されます。

Vite の依存関係の事前ビルド機能も重要です。初回起動時に依存関係を解析し、ES モジュール形式に変換してキャッシュすることで、2 回目以降の起動を劇的に高速化します。

bash# 依存関係の事前ビルドが実行される
$ yarn dev
vite v4.4.9 dev server running at:
> Local: http://localhost:3000/
> Network: use `--host` to expose

Pre-bundling dependencies:
  react
  react-dom
  @mui/material
✓ 142 dependencies pre-bundled in 1.2s

設定ファイルの最適化

Vite の設定ファイルは、従来のビルドツールと比較して大幅にシンプルです。しかし、MPA 開発においては、さらなる最適化が可能です。

typescript// vite.config.ts(TypeScript版)
import { defineConfig } from 'vite';
import { resolve } from 'path';
import { glob } from 'glob';

// HTMLファイルを自動検出する関数
const getInputFiles = () => {
  const files = glob.sync('**/*.html', {
    ignore: ['node_modules/**', 'dist/**'],
  });

  const input = {};
  files.forEach((file) => {
    const name = file
      .replace(/\.html$/, '')
      .replace(/\//g, '-');
    input[name] = resolve(__dirname, file);
  });

  return input;
};

export default defineConfig({
  plugins: [
    // プラグイン設定
  ],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
      '@components': resolve(__dirname, 'src/components'),
      '@utils': resolve(__dirname, 'src/utils'),
    },
  },
  build: {
    rollupOptions: {
      input: getInputFiles(),
      output: {
        // ファイル名のパターンを設定
        chunkFileNames: 'assets/js/[name]-[hash].js',
        entryFileNames: 'assets/js/[name]-[hash].js',
        assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
      },
    },
  },
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@import "@/styles/variables.scss";`,
      },
    },
  },
});

この設定により、新しい HTML ファイルを追加するだけで自動的にエントリーポイントとして認識され、手動での設定更新が不要になります。

環境変数の管理も重要な要素です:

typescript// .env.development
VITE_API_BASE_URL=http://localhost:8080
VITE_APP_TITLE=開発環境
VITE_ENABLE_ANALYTICS=false

// .env.production
VITE_API_BASE_URL=https://api.example.com
VITE_APP_TITLE=本番環境
VITE_ENABLE_ANALYTICS=true

開発環境とプロダクション環境の構築

Vite の最大の魅力は、開発環境とプロダクション環境の設定を統一できることです。従来のツールでは、開発用とビルド用で異なる設定が必要でしたが、Vite では一つの設定ファイルで両方をカバーできます。

開発環境では、Vite の HMR 機能により、ファイルの変更が即座にブラウザに反映されます:

javascript// src/main.js
console.log('アプリケーション起動');

// HMRに対応したモジュール更新
if (import.meta.hot) {
  import.meta.hot.accept(
    './modules/app.js',
    (newModule) => {
      console.log('モジュールが更新されました');
      newModule.init();
    }
  );
}

プロダクション環境では、自動的に最適化が適用されます:

bash# プロダクションビルドの実行
$ yarn build

Building for production...
✓ 142 modules transformed.
dist/index.html                     2.35 kB │ gzip:  1.21 kB
dist/about/index.html                2.18 kB │ gzip:  1.15 kB
dist/assets/main-4f7b8c2a.js       125.67 kB │ gzip: 41.23 kB
dist/assets/main-5d8e9f1b.css        8.91 kB │ gzip:  2.34 kB
✓ built in 3.45s

具体例

プロジェクトセットアップ

実際の Vite MPA プロジェクトの構築手順を詳しく見ていきましょう。まず、新しいプロジェクトを作成します:

bash# Viteテンプレートを使用してプロジェクトを作成
$ yarn create vite my-mpa-project --template vanilla-ts
$ cd my-mpa-project

# 依存関係をインストール
$ yarn install

# 追加パッケージのインストール
$ yarn add -D sass postcss autoprefixer
$ yarn add axios

プロジェクト構造を以下のように設計します:

csharpmy-mpa-project/
├── index.html              # トップページ
├── about/
│   └── index.html         # 会社概要ページ
├── products/
│   └── index.html         # 製品ページ
├── contact/
│   └── index.html         # お問い合わせページ
├── src/
│   ├── main.ts            # メインエントリーポイント
│   ├── components/        # 共通コンポーネント
│   │   ├── Header.ts
│   │   └── Footer.ts
│   ├── utils/             # ユーティリティ関数
│   │   └── api.ts
│   └── styles/            # スタイルファイル
│       ├── main.scss
│       └── components.scss
├── public/                # 静的ファイル
│   └── images/
├── vite.config.ts         # Vite設定
└── package.json

基本的な HTML テンプレートを作成します:

html<!-- index.html -->
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>ホーム - マイサイト</title>
    <meta
      name="description"
      content="マイサイトのホームページです"
    />
  </head>
  <body>
    <div id="app">
      <header id="header"></header>
      <main>
        <h1>ホームページへようこそ</h1>
        <p>こちらは弊社のホームページです。</p>
      </main>
      <footer id="footer"></footer>
    </div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

複数エントリーポイントの設定

複数のエントリーポイントを効率的に管理するため、動的な設定システムを構築します:

typescript// vite.config.ts
import { defineConfig } from 'vite';
import { resolve } from 'path';
import fs from 'fs';
import path from 'path';

// HTMLファイルを再帰的に検索する関数
function findHtmlFiles(
  dir: string,
  fileList: string[] = []
) {
  const files = fs.readdirSync(dir);

  files.forEach((file) => {
    const filePath = path.join(dir, file);
    const stat = fs.statSync(filePath);

    if (
      stat.isDirectory() &&
      !file.startsWith('.') &&
      file !== 'node_modules'
    ) {
      findHtmlFiles(filePath, fileList);
    } else if (file.endsWith('.html')) {
      fileList.push(filePath);
    }
  });

  return fileList;
}

// エントリーポイントオブジェクトを生成
function generateInputs() {
  const htmlFiles = findHtmlFiles('.');
  const inputs = {};

  htmlFiles.forEach((file) => {
    const relativePath = path.relative('.', file);
    const key =
      relativePath
        .replace(/\.html$/, '')
        .replace(/\//g, '-')
        .replace(/^-/, '') || 'index';

    inputs[key] = resolve(__dirname, relativePath);
  });

  console.log('検出されたエントリーポイント:', inputs);
  return inputs;
}

export default defineConfig({
  plugins: [],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
      '@components': resolve(__dirname, 'src/components'),
      '@utils': resolve(__dirname, 'src/utils'),
      '@styles': resolve(__dirname, 'src/styles'),
    },
  },
  server: {
    port: 3000,
    open: '/',
    host: true,
  },
  build: {
    rollupOptions: {
      input: generateInputs(),
    },
    outDir: 'dist',
    assetsDir: 'assets',
    sourcemap: true,
  },
});

共通コンポーネントの管理

MPA では、各ページ間での共通コンポーネントの管理が重要です。以下のようなアプローチで実装します:

typescript// src/components/Header.ts
export class Header {
  private element: HTMLElement;

  constructor(containerId: string) {
    this.element = document.getElementById(containerId)!;
    this.render();
    this.bindEvents();
  }

  private render(): void {
    this.element.innerHTML = `
      <nav class="navbar">
        <div class="navbar-brand">
          <a href="/">マイサイト</a>
        </div>
        <ul class="navbar-nav">
          <li><a href="/" class="nav-link">ホーム</a></li>
          <li><a href="/about/" class="nav-link">会社概要</a></li>
          <li><a href="/products/" class="nav-link">製品</a></li>
          <li><a href="/contact/" class="nav-link">お問い合わせ</a></li>
        </ul>
        <button class="navbar-toggle" id="navbar-toggle">
          <span></span>
          <span></span>
          <span></span>
        </button>
      </nav>
    `;
  }

  private bindEvents(): void {
    const toggle = this.element.querySelector(
      '#navbar-toggle'
    );
    const nav = this.element.querySelector('.navbar-nav');

    toggle?.addEventListener('click', () => {
      nav?.classList.toggle('active');
    });

    // 現在のページをハイライト
    this.highlightCurrentPage();
  }

  private highlightCurrentPage(): void {
    const currentPath = window.location.pathname;
    const links =
      this.element.querySelectorAll('.nav-link');

    links.forEach((link) => {
      const href = (link as HTMLAnchorElement).getAttribute(
        'href'
      );
      if (
        href === currentPath ||
        (currentPath === '/' && href === '/')
      ) {
        link.classList.add('active');
      }
    });
  }
}
typescript// src/components/Footer.ts
export class Footer {
  private element: HTMLElement;

  constructor(containerId: string) {
    this.element = document.getElementById(containerId)!;
    this.render();
  }

  private render(): void {
    const currentYear = new Date().getFullYear();

    this.element.innerHTML = `
      <div class="footer-content">
        <div class="footer-section">
          <h3>会社情報</h3>
          <ul>
            <li><a href="/about/">会社概要</a></li>
            <li><a href="/contact/">お問い合わせ</a></li>
          </ul>
        </div>
        <div class="footer-section">
          <h3>製品情報</h3>
          <ul>
            <li><a href="/products/">製品一覧</a></li>
          </ul>
        </div>
        <div class="footer-bottom">
          <p>&copy; ${currentYear} マイサイト. All rights reserved.</p>
        </div>
      </div>
    `;
  }
}

各ページでこれらのコンポーネントを使用します:

typescript// src/main.ts
import { Header } from '@components/Header';
import { Footer } from '@components/Footer';
import '@styles/main.scss';

// DOM読み込み完了後に実行
document.addEventListener('DOMContentLoaded', () => {
  // 共通コンポーネントの初期化
  new Header('header');
  new Footer('footer');

  // ページ固有の初期化
  initPageSpecificFeatures();
});

function initPageSpecificFeatures(): void {
  const path = window.location.pathname;

  switch (path) {
    case '/':
      initHomePage();
      break;
    case '/about/':
      initAboutPage();
      break;
    case '/products/':
      initProductsPage();
      break;
    case '/contact/':
      initContactPage();
      break;
    default:
      console.log('Unknown page:', path);
  }
}

function initHomePage(): void {
  console.log('ホームページを初期化');
  // ホームページ固有の処理
}

function initAboutPage(): void {
  console.log('会社概要ページを初期化');
  // 会社概要ページ固有の処理
}

function initProductsPage(): void {
  console.log('製品ページを初期化');
  // 製品ページ固有の処理
}

function initContactPage(): void {
  console.log('お問い合わせページを初期化');
  // お問い合わせフォームの初期化
  const form = document.getElementById(
    'contact-form'
  ) as HTMLFormElement;
  if (form) {
    form.addEventListener('submit', handleContactSubmit);
  }
}

async function handleContactSubmit(
  event: Event
): Promise<void> {
  event.preventDefault();

  const formData = new FormData(
    event.target as HTMLFormElement
  );
  const data = Object.fromEntries(formData.entries());

  try {
    // API送信処理(例)
    const response = await fetch('/api/contact', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(data),
    });

    if (response.ok) {
      alert('お問い合わせを受け付けました。');
    } else {
      throw new Error('送信に失敗しました');
    }
  } catch (error) {
    console.error('Error:', error);
    alert('送信中にエラーが発生しました。');
  }
}

ビルド設定の詳細

最適なビルド設定により、パフォーマンスの向上を図ります:

typescript// vite.config.ts(完全版)
import { defineConfig } from 'vite';
import { resolve } from 'path';

export default defineConfig({
  // 開発サーバー設定
  server: {
    port: 3000,
    host: true,
    open: true,
  },

  // パス解決設定
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
      '@components': resolve(__dirname, 'src/components'),
      '@utils': resolve(__dirname, 'src/utils'),
      '@styles': resolve(__dirname, 'src/styles'),
    },
  },

  // CSS設定
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `
          @import "@styles/variables.scss";
          @import "@styles/mixins.scss";
        `,
      },
    },
    devSourcemap: true,
  },

  // ビルド設定
  build: {
    outDir: 'dist',
    assetsDir: 'assets',
    sourcemap: true,
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,
        drop_debugger: true,
      },
    },
    rollupOptions: {
      input: {
        main: resolve(__dirname, 'index.html'),
        about: resolve(__dirname, 'about/index.html'),
        products: resolve(__dirname, 'products/index.html'),
        contact: resolve(__dirname, 'contact/index.html'),
      },
      output: {
        chunkFileNames: 'assets/js/[name]-[hash].js',
        entryFileNames: 'assets/js/[name]-[hash].js',
        assetFileNames: (assetInfo) => {
          const info = assetInfo.name.split('.');
          const extType = info[info.length - 1];

          if (
            /\.(mp4|webm|ogg|mp3|wav|flac|aac)$/.test(
              assetInfo.name
            )
          ) {
            return `assets/media/[name]-[hash].[ext]`;
          } else if (
            /\.(png|jpe?g|gif|svg|webp|avif)$/.test(
              assetInfo.name
            )
          ) {
            return `assets/images/[name]-[hash].[ext]`;
          } else if (
            /\.(woff2?|eot|ttf|otf)$/.test(assetInfo.name)
          ) {
            return `assets/fonts/[name]-[hash].[ext]`;
          }

          return `assets/${extType}/[name]-[hash].[ext]`;
        },
      },
    },
    // チャンク分割の設定
    chunkSizeWarningLimit: 1000,
  },

  // 最適化設定
  optimizeDeps: {
    include: ['axios'],
  },
});

package.json にビルドスクリプトを追加します:

json{
  "name": "my-mpa-project",
  "version": "1.0.0",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "serve": "yarn build && yarn preview",
    "clean": "rm -rf dist"
  },
  "devDependencies": {
    "vite": "^4.4.9",
    "typescript": "^5.1.6",
    "sass": "^1.64.2"
  },
  "dependencies": {
    "axios": "^1.5.0"
  }
}

ビルド実行時の典型的な出力:

bash$ yarn build
yarn run v1.22.19
$ tsc && vite build
✓ TypeScript compilation completed
vite v4.4.9 building for production...
✓ 89 modules transformed.
dist/index.html                     3.21 kB │ gzip:  1.67 kB
dist/about/index.html                2.98 kB │ gzip:  1.54 kB
dist/products/index.html             3.45 kB │ gzip:  1.78 kB
dist/contact/index.html              4.12 kB │ gzip:  2.01 kB
dist/assets/js/main-a1b2c3d4.js    89.67 kB │ gzip: 32.14 kB
dist/assets/js/about-e5f6g7h8.js   12.34 kB │ gzip:  4.56 kB
dist/assets/css/main-i9j0k1l2.css  15.67 kB │ gzip:  3.21 kB
✓ built in 4.23s
✨  Done in 6.45s.

まとめ

Vite によるマルチページアプリケーション開発は、従来の MPA 構築における多くの課題を解決する画期的なアプローチです。高速な開発サーバー直感的な設定、そして優れたビルド最適化により、モダンな Web 開発の要求を満たしています。

特に注目すべきは、開発体験とパフォーマンスの両立が実現できている点です。開発時は ES モジュールによる高速な HMR を活用し、プロダクション時は Rollup による最適化されたバンドルを生成することで、妥協のない開発環境を提供しています。

企業の Web サイト開発においては、SEO 対応の容易さ段階的な移行の可能性が大きなメリットとなるでしょう。既存のサイトから部分的にモダン化を進めたい場合や、各ページの独立性を重視したい場合に、Vite MPA は最適な選択肢となります。

今回ご紹介した手法を参考に、ぜひ Vite を使ったマルチページアプリケーション開発にチャレンジしてみてください。きっと、その開発体験の素晴らしさに驚かれることでしょう。

関連リンク