T-CREATOR

Vite の SSR とクライアントサイドの切り替え戦略

Vite の SSR とクライアントサイドの切り替え戦略

モダンな Web アプリケーション開発において、サーバーサイドレンダリング(SSR)とクライアントサイドの使い分けは重要な課題です。Vite を使った開発では、この切り替えをどのように実装するかが、アプリケーションのパフォーマンスとユーザー体験を大きく左右します。

本記事では、Vite の SSR とクライアントサイドの切り替えについて、実装手法を中心に詳しく解説していきます。実際のコード例とエラーハンドリングを含めて、実践的なアプローチをお伝えします。

Vite SSR の基本概念

Vite SSR とは

Vite の SSR(Server-Side Rendering)は、サーバー側で JavaScript を実行して HTML を生成し、クライアントに送信する技術です。従来のクライアントサイドレンダリング(CSR)とは異なり、初期表示が高速になり、SEO にも有利な特徴があります。

typescript// vite.config.ts - SSRの基本設定
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  ssr: {
    // SSR用のエントリーポイントを指定
    entry: 'src/entry-server.tsx',
    // 外部化するパッケージを指定
    external: ['react', 'react-dom'],
  },
});

この設定により、Vite は SSR 用のビルドを生成し、サーバー側でレンダリングできるようになります。

クライアントサイドとの違い

SSR とクライアントサイドの主な違いは、JavaScript の実行環境にあります。

typescript// クライアントサイドでの実行
// ブラウザ環境でのみ動作
if (typeof window !== 'undefined') {
  console.log('クライアントサイドで実行中');
}

// SSRでの実行
// サーバー環境でのみ動作
if (typeof window === 'undefined') {
  console.log('サーバーサイドで実行中');
}

この違いを理解することで、適切な切り替え戦略を立てることができます。

なぜ切り替えが必要なのか

SSR とクライアントサイドの切り替えが必要な理由は、それぞれに最適なユースケースがあるからです。

SSR が適している場合:

  • 初期表示速度が重要な場合
  • SEO が重要な場合
  • 静的コンテンツが多い場合

クライアントサイドが適している場合:

  • インタラクティブな機能が多い場合
  • リアルタイム更新が必要な場合
  • 複雑な状態管理が必要な場合

動的インポートによる切り替え戦略

import()を使った動的インポート

動的インポートを使用することで、実行時にコンポーネントやモジュールを条件付きで読み込むことができます。

typescript// 動的インポートによる切り替え
const loadComponent = async (componentName: string) => {
  try {
    // 動的にコンポーネントをインポート
    const module = await import(
      `./components/${componentName}.tsx`
    );
    return module.default;
  } catch (error) {
    console.error(
      'コンポーネントの読み込みに失敗しました:',
      error
    );
    // フォールバックコンポーネントを返す
    return () => <div>読み込みエラー</div>;
  }
};

この方法により、必要な時だけコンポーネントを読み込むことができ、バンドルサイズを削減できます。

条件分岐による実装

実行環境に応じて、異なる実装を選択する条件分岐を実装します。

typescript// 環境に応じた条件分岐
const createRenderer = () => {
  if (typeof window === 'undefined') {
    // サーバーサイドでの実装
    return {
      render: (component: React.ReactElement) => {
        return renderToString(component);
      },
      hydrate: () => {
        throw new Error(
          'サーバーサイドではハイドレーションできません'
        );
      },
    };
  } else {
    // クライアントサイドでの実装
    return {
      render: (component: React.ReactElement) => {
        return createRoot(
          document.getElementById('root')!
        ).render(component);
      },
      hydrate: (component: React.ReactElement) => {
        return hydrateRoot(
          document.getElementById('root')!,
          component
        );
      },
    };
  }
};

この実装により、同じ API で異なる環境に対応できます。

パフォーマンスの最適化

動的インポートを活用して、パフォーマンスを最適化します。

typescript// パフォーマンス最適化のための遅延読み込み
const LazyComponent = lazy(() =>
  import('./HeavyComponent').then((module) => ({
    default: module.default,
  }))
);

// 使用例
function App() {
  return (
    <Suspense fallback={<div>読み込み中...</div>}>
      <LazyComponent />
    </Suspense>
  );
}

この方法により、初期ロード時間を短縮し、ユーザー体験を向上させることができます。

環境変数による切り替え

import.meta.env の活用

Vite のimport.meta.envを使用して、環境に応じた設定を管理します。

typescript// 環境変数による切り替え
const getConfig = () => {
  return {
    isSSR: import.meta.env.SSR,
    isDev: import.meta.env.DEV,
    isProd: import.meta.env.PROD,
    apiUrl:
      import.meta.env.VITE_API_URL ||
      'http://localhost:3000',
  };
};

// 使用例
const config = getConfig();
if (config.isSSR) {
  console.log('SSRモードで実行中');
} else {
  console.log('クライアントサイドで実行中');
}

この方法により、環境に応じた適切な設定を自動的に選択できます。

開発・本番環境での制御

開発環境と本番環境で異なる動作を実装します。

typescript// 環境別の設定管理
const getEnvironmentConfig = () => {
  const baseConfig = {
    enableDebug: false,
    cacheTimeout: 30000,
    maxRetries: 3,
  };

  if (import.meta.env.DEV) {
    return {
      ...baseConfig,
      enableDebug: true,
      cacheTimeout: 5000,
    };
  }

  return baseConfig;
};

// 使用例
const envConfig = getEnvironmentConfig();
if (envConfig.enableDebug) {
  console.log('デバッグモードが有効です');
}

この実装により、開発時は詳細なログを出力し、本番環境ではパフォーマンスを最適化できます。

設定ファイルの管理

環境別の設定ファイルを管理して、切り替えを効率化します。

typescript// config/environment.ts
interface EnvironmentConfig {
  ssr: {
    enabled: boolean;
    entryPoint: string;
  };
  client: {
    enableHydration: boolean;
    enablePrefetch: boolean;
  };
}

const developmentConfig: EnvironmentConfig = {
  ssr: {
    enabled: true,
    entryPoint: 'src/entry-server.tsx',
  },
  client: {
    enableHydration: true,
    enablePrefetch: false,
  },
};

const productionConfig: EnvironmentConfig = {
  ssr: {
    enabled: true,
    entryPoint: 'src/entry-server.tsx',
  },
  client: {
    enableHydration: true,
    enablePrefetch: true,
  },
};

export const getConfig = (): EnvironmentConfig => {
  return import.meta.env.PROD
    ? productionConfig
    : developmentConfig;
};

この設定ファイルにより、環境に応じた適切な設定を一元管理できます。

プラグインを使った切り替え

Vite プラグインの作成

カスタム Vite プラグインを作成して、ビルド時に適切な切り替えを実装します。

typescript// plugins/ssr-switch.ts
import type { Plugin } from 'vite';

export function ssrSwitchPlugin(): Plugin {
  return {
    name: 'ssr-switch',
    config(config, { command, mode }) {
      // SSRモードの判定
      const isSSR = mode === 'ssr';

      return {
        define: {
          __IS_SSR__: JSON.stringify(isSSR),
          __IS_CLIENT__: JSON.stringify(!isSSR),
        },
      };
    },
    transform(code, id) {
      // 条件付きコードの変換
      if (id.includes('.tsx') || id.includes('.ts')) {
        return {
          code: code
            .replace(
              /__IS_SSR__/g,
              'typeof window === "undefined"'
            )
            .replace(
              /__IS_CLIENT__/g,
              'typeof window !== "undefined"'
            ),
          map: null,
        };
      }
    },
  };
}

このプラグインにより、ビルド時に適切な条件分岐コードが生成されます。

ビルド時の処理分岐

ビルド時に環境に応じた処理を分岐させます。

typescript// vite.config.ts - プラグインの適用
import { defineConfig } from 'vite';
import { ssrSwitchPlugin } from './plugins/ssr-switch';

export default defineConfig(({ mode }) => {
  const isSSR = mode === 'ssr';

  return {
    plugins: [ssrSwitchPlugin()],
    build: {
      rollupOptions: {
        input: isSSR
          ? 'src/entry-server.tsx'
          : 'src/main.tsx',
        output: {
          format: isSSR ? 'cjs' : 'es',
          dir: isSSR ? 'dist/server' : 'dist/client',
        },
      },
    },
  };
});

この設定により、SSR とクライアントサイドで異なるビルド設定を適用できます。

カスタムプラグインの実装例

より高度なカスタムプラグインを実装します。

typescript// plugins/environment-switch.ts
import type { Plugin } from 'vite';

interface SwitchOptions {
  ssrEntry?: string;
  clientEntry?: string;
  enableDebug?: boolean;
}

export function environmentSwitchPlugin(
  options: SwitchOptions = {}
): Plugin {
  const {
    ssrEntry = 'src/entry-server.tsx',
    clientEntry = 'src/main.tsx',
    enableDebug = false,
  } = options;

  return {
    name: 'environment-switch',
    config(config, { mode }) {
      const isSSR = mode === 'ssr';

      if (enableDebug) {
        console.log(`ビルドモード: ${mode}, SSR: ${isSSR}`);
      }

      return {
        define: {
          __BUILD_MODE__: JSON.stringify(mode),
          __IS_SSR_BUILD__: JSON.stringify(isSSR),
        },
        build: {
          rollupOptions: {
            input: isSSR ? ssrEntry : clientEntry,
          },
        },
      };
    },
    generateBundle(options, bundle) {
      // バンドル生成時の処理
      if (options.format === 'cjs') {
        console.log('SSRバンドルを生成しました');
      } else {
        console.log('クライアントバンドルを生成しました');
      }
    },
  };
}

このプラグインにより、より柔軟な環境切り替えが可能になります。

ハイドレーション戦略

サーバーサイドレンダリング後の処理

SSR で生成された HTML をクライアントサイドでハイドレーションする処理を実装します。

typescript// entry-client.tsx - クライアントサイドエントリーポイント
import { hydrateRoot } from 'react-dom/client';
import { App } from './App';

const container = document.getElementById('root');
if (!container) {
  throw new Error('ルート要素が見つかりません');
}

// ハイドレーションの実行
hydrateRoot(container, <App />);

この処理により、SSR で生成された HTML にクライアントサイドの機能を統合できます。

クライアントサイドでの状態復元

サーバーサイドで生成された状態をクライアントサイドで復元します。

typescript// utils/hydration.ts
export const restoreState = () => {
  try {
    // サーバーサイドで生成された状態を取得
    const stateElement = document.getElementById(
      '__INITIAL_STATE__'
    );
    if (stateElement) {
      const initialState = JSON.parse(
        stateElement.textContent || '{}'
      );
      return initialState;
    }
  } catch (error) {
    console.error('状態の復元に失敗しました:', error);
  }

  return {};
};

// 使用例
const initialState = restoreState();
const store = createStore(reducer, initialState);

この実装により、サーバーサイドとクライアントサイドで一貫した状態を維持できます。

エラーハンドリング

ハイドレーション時のエラーを適切に処理します。

typescript// utils/error-boundary.tsx
import React, {
  Component,
  ErrorInfo,
  ReactNode,
} from 'react';

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}

interface State {
  hasError: boolean;
  error?: Error;
}

export class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error(
      'ハイドレーションエラー:',
      error,
      errorInfo
    );

    // エラーをログに記録
    if (typeof window !== 'undefined') {
      // クライアントサイドでのエラーログ
      console.error('クライアントサイドエラー:', error);
    }
  }

  render() {
    if (this.state.hasError) {
      return (
        this.props.fallback || (
          <div>
            <h2>エラーが発生しました</h2>
            <p>ページを再読み込みしてください</p>
          </div>
        )
      );
    }

    return this.props.children;
  }
}

このエラーバウンダリーにより、ハイドレーション時のエラーを適切にキャッチし、ユーザーに分かりやすいメッセージを表示できます。

パフォーマンス最適化

バンドルサイズの削減

動的インポートとコード分割を活用してバンドルサイズを削減します。

typescript// utils/code-splitting.ts
export const createLazyComponent = (
  importFn: () => Promise<any>
) => {
  return lazy(() =>
    importFn().catch((error) => {
      console.error(
        'コンポーネントの読み込みに失敗:',
        error
      );
      // フォールバックコンポーネントを返す
      return { default: () => <div>読み込みエラー</div> };
    })
  );
};

// 使用例
const LazyHeavyComponent = createLazyComponent(
  () => import('./HeavyComponent')
);

const LazyChartComponent = createLazyComponent(
  () => import('./ChartComponent')
);

この実装により、必要な時だけコンポーネントを読み込み、バンドルサイズを削減できます。

読み込み速度の向上

プリロードとプリフェッチを活用して読み込み速度を向上させます。

typescript// utils/preload.ts
export const preloadComponent = (
  importFn: () => Promise<any>
) => {
  // バックグラウンドでプリロード
  const promise = importFn();

  return {
    component: lazy(() => promise),
    preload: () => promise,
  };
};

// 使用例
const { component: LazyModal, preload: preloadModal } =
  preloadComponent(() => import('./Modal'));

// ユーザーがボタンにホバーした時にプリロード
const handleMouseEnter = () => {
  preloadModal();
};

この方法により、ユーザーの操作を予測して事前にコンポーネントを読み込み、体感速度を向上させることができます。

キャッシュ戦略

適切なキャッシュ戦略を実装してパフォーマンスを最適化します。

typescript// utils/cache.ts
interface CacheEntry<T> {
  data: T;
  timestamp: number;
  ttl: number;
}

class Cache {
  private cache = new Map<string, CacheEntry<any>>();

  set<T>(key: string, data: T, ttl: number = 300000): void {
    this.cache.set(key, {
      data,
      timestamp: Date.now(),
      ttl,
    });
  }

  get<T>(key: string): T | null {
    const entry = this.cache.get(key);
    if (!entry) return null;

    const isExpired =
      Date.now() - entry.timestamp > entry.ttl;
    if (isExpired) {
      this.cache.delete(key);
      return null;
    }

    return entry.data;
  }

  clear(): void {
    this.cache.clear();
  }
}

// グローバルキャッシュインスタンス
export const globalCache = new Cache();

// 使用例
const fetchData = async (url: string) => {
  const cacheKey = `data:${url}`;
  const cached = globalCache.get(cacheKey);

  if (cached) {
    return cached;
  }

  const response = await fetch(url);
  const data = await response.json();

  globalCache.set(cacheKey, data, 60000); // 1分間キャッシュ
  return data;
};

このキャッシュ戦略により、重複した API 呼び出しを避け、アプリケーションの応答性を向上させることができます。

まとめ

Vite の SSR とクライアントサイドの切り替え戦略について、実装手法を中心に詳しく解説しました。

動的インポートによる条件分岐、環境変数を使った設定管理、カスタムプラグインの作成、適切なハイドレーション戦略、そしてパフォーマンス最適化まで、実践的なアプローチをお伝えしました。

これらの手法を組み合わせることで、ユーザー体験とパフォーマンスの両方を最適化した Web アプリケーションを構築できます。特に、エラーハンドリングとキャッシュ戦略は、本番環境での安定性を確保するために重要な要素です。

Vite の柔軟性を活かして、プロジェクトの要件に応じた最適な切り替え戦略を選択し、実装していくことをお勧めします。

関連リンク