T-CREATOR

htmx パフォーマンス実測:同等 UI を SPA/SSR/htmx で作った場合の応答時間比較

htmx パフォーマンス実測:同等 UI を SPA/SSR/htmx で作った場合の応答時間比較

モダン Web 開発において、技術選択の幅が急速に広がっています。従来の SPA(Single Page Application)や SSR(Server-Side Rendering)に加えて、近年注目を集める htmx が新たな選択肢として浮上しています。

開発者の皆さんにとって、プロジェクトに最適な技術を選択することは、ユーザー体験の品質を左右する重要な決断です。特にパフォーマンスは、ユーザーの離脱率や満足度に直接影響するため、慎重な検討が求められます。

本記事では、同一機能を SPA・SSR・htmx の 3 つの手法で実装し、実際の応答時間を測定・比較した結果をご紹介します。理論的な特徴だけでなく、実測データに基づいた客観的な判断材料を提供することで、皆さんの技術選択をサポートいたします。

背景

Web アプリケーション開発手法の進化

Web アプリケーション開発は、この 10 年間で劇的な変化を遂げています。静的な HTML ページから始まり、Ajax による部分更新、そして SPA の普及へと進化してきました。

mermaidflowchart LR
  static["静的 HTML"] -->|Ajax 登場| partial["部分更新"]
  partial -->|フレームワーク普及| spa["SPA"]
  spa -->|SEO 対応| ssr["SSR"]
  ssr -->|軽量化| htmx["htmx"]

  style static fill:#e1f5fe
  style partial fill:#f3e5f5
  style spa fill:#e8f5e8
  style ssr fill:#fff3e0
  style htmx fill:#fce4ec

図で理解できる要点:

  • 各技術は前世代の課題を解決するために登場
  • htmx は SPA の複雑さを軽減する新しいアプローチ
  • 技術選択は目的に応じて使い分けることが重要

現在主流となっている開発手法は以下の通りです。

手法特徴メリットデメリット
SPAクライアントサイドでの動的レンダリング高速なページ遷移、リッチな UX初期読み込み時間、SEO 課題
SSRサーバーサイドでの HTML 生成SEO 対応、初期表示の高速化サーバー負荷、開発複雑性
htmxHTML 属性によるインタラクション軽量、学習コスト低エコシステム規模

SPA・SSR・htmx それぞれの特徴

SPA(Single Page Application)の特徴

SPA は、初回アクセス時にアプリケーション全体をダウンロードし、その後はクライアントサイドで画面遷移を行う手法です。React や Vue.js などのフレームワークが代表的ですね。

typescript// React SPA の基本構造例
import React, { useState, useEffect } from 'react';
import {
  BrowserRouter as Router,
  Route,
  Switch,
} from 'react-router-dom';

const App: React.FC = () => {
  const [data, setData] = useState<any[]>([]);

  useEffect(() => {
    // API からデータを取得
    fetch('/api/data')
      .then((response) => response.json())
      .then(setData);
  }, []);

  return (
    <Router>
      <Switch>
        <Route path='/' component={HomePage} />
        <Route path='/dashboard' component={Dashboard} />
      </Switch>
    </Router>
  );
};

SPA の主な特徴は、一度読み込み後の高速なページ遷移と、リッチなユーザーインタラクションの実現です。ただし、初期バンドルサイズが大きくなりがちで、SEO 対応に工夫が必要という課題もあります。

SSR(Server-Side Rendering)の特徴

SSR は、サーバーサイドで HTML を生成してクライアントに送信する手法です。Next.js や Nuxt.js が代表的なフレームワークとなります。

typescript// Next.js SSR の実装例
import { GetServerSideProps } from 'next';

interface Props {
  data: any[];
}

const Dashboard: React.FC<Props> = ({ data }) => {
  return (
    <div>
      <h1>ダッシュボード</h1>
      {data.map((item) => (
        <div key={item.id}>{item.name}</div>
      ))}
    </div>
  );
};

export const getServerSideProps: GetServerSideProps =
  async () => {
    // サーバーサイドでデータを取得
    const response = await fetch(
      'http://localhost:3000/api/data'
    );
    const data = await response.json();

    return { props: { data } };
  };

export default Dashboard;

SSR では、サーバーで完全な HTML を生成するため、SEO 対応と初期表示の高速化を両立できます。一方で、サーバー処理が増加し、開発時の複雑性も高まる傾向があります。

htmx の特徴

htmx は、HTML 属性を使って Ajax リクエストや DOM 操作を行う軽量なライブラリです。JavaScript を最小限に抑えながら、動的なユーザーインタラクションを実現できます。

html<!-- htmx の基本的な使用例 -->
<div id="dashboard">
  <h1>ダッシュボード</h1>
  <button
    hx-get="/api/data"
    hx-target="#content"
    hx-trigger="click"
  >
    データを読み込む
  </button>
  <div id="content">
    <!-- ここにコンテンツが挿入される -->
  </div>
</div>
javascript// htmx 使用時の最小限の JavaScript
document.addEventListener('DOMContentLoaded', function () {
  // htmx が自動で処理するため、追加のコードは不要
  console.log('htmx ready');
});

htmx の魅力は、その軽量性と学習コストの低さです。従来の HTML 知識があれば、比較的簡単に動的な Web アプリケーションを構築できます。

パフォーマンス測定の重要性

Web アプリケーションのパフォーマンスは、ユーザー体験に直接的な影響を与える重要な要素です。Google の研究によると、ページの読み込み時間が 1 秒から 3 秒に増加すると、直帰率が 32% 増加するとされています。

mermaidgraph LR
  load_time["読み込み時間"] -->|1秒| bounce_32["直帰率 +32%"]
  load_time -->|2秒| bounce_50["直帰率 +50%"]
  load_time -->|3秒| bounce_90["直帰率 +90%"]

  style load_time fill:#f9f9f9
  style bounce_32 fill:#fff3cd
  style bounce_50 fill:#ffeaa7
  style bounce_90 fill:#fab1a0

パフォーマンス測定においては、以下の指標が特に重要です:

指標説明目標値
FCPFirst Contentful Paint - 最初のコンテンツ表示1.8 秒以下
LCPLargest Contentful Paint - 最大コンテンツ表示2.5 秒以下
TTITime to Interactive - インタラクション可能時点3.8 秒以下
CLSCumulative Layout Shift - レイアウト変動0.1 以下

これらの指標を正確に測定し、技術選択の判断材料とすることが、成功する Web アプリケーション開発の鍵となります。

課題

開発者が直面する技術選択の困難

現代の Web 開発者は、プロジェクト開始時に数多くの技術選択肢の中から最適解を見つける必要があります。この選択は、プロジェクトの成功を左右する重要な決断となりますが、以下のような困難に直面することが少なくありません。

まず、情報の氾濫という問題があります。各技術のメリットを謳う記事や情報は豊富に存在しますが、実際のプロジェクト要件に合致する客観的な比較データは限られています。開発者は、マーケティング的な情報と実用的な情報を見極める必要があります。

mermaidflowchart TD
  developer["開発者"] -->|情報収集| sources["情報源"]
  sources --> blog["技術ブログ"]
  sources --> docs["公式ドキュメント"]
  sources --> community["コミュニティ"]

  blog -->|主観的| bias["バイアス"]
  docs -->|理想的| gap["現実とのギャップ"]
  community -->|断片的| incomplete["不完全な情報"]

  bias --> confusion["選択の混乱"]
  gap --> confusion
  incomplete --> confusion

  style developer fill:#e3f2fd
  style confusion fill:#ffcdd2

図で理解できる要点:

  • 多様な情報源からの情報収集が必要
  • 各情報源には固有のバイアスや限界が存在
  • 客観的な判断を困難にする要因が多数存在

次に、プロジェクト固有の要件との適合性を判断する困難さがあります。同じ Web アプリケーションでも、ユーザー数、データ量、更新頻度、SEO 要件などによって最適な技術は変わります。

要件項目SPA 適合度SSR 適合度htmx 適合度
高頻度なユーザーインタラクション★★★★★★★
SEO 重視★★★★★★
開発速度★★★★★
保守性★★★★★★★
チーム学習コスト★★★★★

さらに、長期的な保守性を予測することも課題となります。技術の進歩が早い Web 開発分野では、数年後の技術サポートやコミュニティの活況を予測することは困難です。

理論値と実測値のギャップ

技術選択を困難にするもう一つの大きな要因は、公式ドキュメントやベンチマークで示される理論値と、実際のプロジェクトでの実測値との間に生じるギャップです。

ベンチマーク環境と実環境の違い

多くのパフォーマンスベンチマークは、理想的な条件下で実施されます。しかし、実際のプロジェクトでは以下のような制約が存在します:

typescript// 理論的なベンチマーク例(単純な API 呼び出し)
const startTime = performance.now();
const response = await fetch('/api/simple');
const data = await response.json();
const endTime = performance.now();
console.log(`処理時間: ${endTime - startTime}ms`);
typescript// 実際のプロジェクトでの複雑な処理
const startTime = performance.now();

// 認証チェック
const authToken = await getAuthToken();

// 複数の API 呼び出し
const [userData, permissions, settings] = await Promise.all(
  [
    fetch('/api/user', {
      headers: { Authorization: authToken },
    }),
    fetch('/api/permissions', {
      headers: { Authorization: authToken },
    }),
    fetch('/api/settings', {
      headers: { Authorization: authToken },
    }),
  ]
);

// データ変換処理
const transformedData = transformUserData(
  userData,
  permissions,
  settings
);

// キャッシュ処理
await updateCache(transformedData);

const endTime = performance.now();
console.log(`実際の処理時間: ${endTime - startTime}ms`);

実環境では、認証、エラーハンドリング、キャッシュ、ロギングなど、ベンチマークでは考慮されない処理が多数存在し、これらがパフォーマンスに影響を与えます。

ネットワーク環境による影響

理論値と実測値のギャップは、ネットワーク環境の違いによっても生じます。ベンチマークは通常、高速なネットワーク環境で実施されますが、実際のユーザーは様々な環境でアプリケーションを利用します。

ネットワーク環境帯域幅レイテンシSPA 影響SSR 影響htmx 影響
高速ブロードバンド100Mbps+10ms 以下
モバイル 4G10-50Mbps50-100ms
モバイル 3G1-5Mbps100-300ms
不安定な接続変動変動

このような環境の違いを考慮した実測データの必要性が、本記事の実験を実施する動機となっています。

公平な比較環境の構築

技術間の公平な比較を行うためには、測定環境の統一が不可欠です。しかし、この環境構築には以下のような課題があります。

機能同等性の担保

同じ機能を異なる技術で実装する際、完全な同等性を保つことは困難です。各技術には固有の実装パターンがあり、それに従って最適化された実装を行う必要があります。

mermaidflowchart LR
  requirement["要求機能"] --> spa_impl["SPA 実装"]
  requirement --> ssr_impl["SSR 実装"]
  requirement --> htmx_impl["htmx 実装"]

  spa_impl --> spa_optimize["SPA 最適化"]
  ssr_impl --> ssr_optimize["SSR 最適化"]
  htmx_impl --> htmx_optimize["htmx 最適化"]

  spa_optimize --> comparison["公平な比較"]
  ssr_optimize --> comparison
  htmx_optimize --> comparison

  style requirement fill:#e8f5e8
  style comparison fill:#fff3e0

測定条件の統一

パフォーマンス測定において、以下の条件を統一することが重要です:

  • ハードウェア環境: CPU、メモリ、ストレージの仕様
  • ソフトウェア環境: OS、ブラウザ、Node.js バージョン
  • ネットワーク環境: 帯域幅、レイテンシの制御
  • データ条件: テストデータの量と複雑さ
  • 負荷条件: 同時アクセス数、リクエスト頻度

これらの条件を適切に制御し、再現可能な測定環境を構築することが、信頼性の高い比較結果を得るための前提条件となります。

解決策

実測環境の設計

公平で信頼性の高い比較を実現するため、以下の原則に基づいて実測環境を設計いたします。

ハードウェア・ソフトウェア環境の統一

すべての測定は、以下の統一された環境で実施します:

yaml# 測定環境設定
hardware:
  cpu: 'Intel Core i7-12700K'
  memory: '32GB DDR4-3200'
  storage: 'NVMe SSD 1TB'

software:
  os: 'Ubuntu 22.04 LTS'
  node: '18.17.0'
  browser: 'Chrome 115.0.5790.170'

network:
  bandwidth: '100Mbps'
  latency: '10ms'
  packet_loss: '0%'

Docker による環境隔離

各技術の実装は、Docker コンテナ内で実行することで、環境の一貫性を保ちます。

dockerfile# 共通ベースイメージ
FROM node:18-alpine

WORKDIR /app

# パフォーマンス測定ツールのインストール
RUN npm install -g lighthouse @web/test-runner

# アプリケーション固有の設定
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

COPY . .
RUN yarn build

EXPOSE 3000
CMD ["yarn", "start"]

この統一されたコンテナ環境により、外部要因によるパフォーマンスのブレを最小限に抑えます。

測定ツールの選定

以下の測定ツールを組み合わせて、多角的な評価を行います:

ツール測定対象取得メトリクス
LighthouseCore Web VitalsFCP, LCP, TTI, CLS
WebPageTest実ネットワーク環境Start Render, Speed Index
Chrome DevTools詳細プロファイルJavaScript 実行時間, メモリ使用量
Artillery負荷テストスループット, レスポンス時間

測定指標の定義

主要パフォーマンス指標

本実測では、以下の指標を重点的に測定・比較いたします:

typescript// 測定指標の型定義
interface PerformanceMetrics {
  // Core Web Vitals
  firstContentfulPaint: number; // FCP (ms)
  largestContentfulPaint: number; // LCP (ms)
  timeToInteractive: number; // TTI (ms)
  cumulativeLayoutShift: number; // CLS (score)

  // カスタム指標
  initialLoadTime: number; // 初期読み込み時間 (ms)
  subsequentNavigation: number; // 後続ナビゲーション時間 (ms)
  memoryUsage: number; // メモリ使用量 (MB)
  bundleSize: number; // バンドルサイズ (KB)
}

測定シナリオの設計

実際のユーザー行動を模倣した以下のシナリオで測定を実施します:

mermaidsequenceDiagram
  participant User as ユーザー
  participant App as アプリケーション
  participant API as API サーバー

  User->>App: 1. 初回アクセス
  App->>API: データ取得
  API-->>App: レスポンス
  App-->>User: 初期画面表示

  User->>App: 2. リスト表示操作
  App->>API: リストデータ取得
  API-->>App: リストレスポンス
  App-->>User: リスト表示更新

  User->>App: 3. 詳細表示操作
  App->>API: 詳細データ取得
  API-->>App: 詳細レスポンス
  App-->>User: 詳細画面表示

  User->>App: 4. フォーム送信
  App->>API: データ送信
  API-->>App: 送信完了
  App-->>User: 完了画面表示

各シナリオにおいて、上記のパフォーマンス指標を自動測定し、統計的に有意な結果を得るため、各測定を 50 回実施して平均値と標準偏差を算出します。

同等機能の実装方針

共通仕様の策定

3 つの技術で実装するアプリケーションの共通仕様を以下のように定義します:

typescript// 共通データモデル
interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
  createdAt: Date;
}

interface Task {
  id: string;
  title: string;
  description: string;
  status: 'pending' | 'in_progress' | 'completed';
  assigneeId: string;
  dueDate: Date;
}
typescript// 共通 API エンドポイント
const API_ENDPOINTS = {
  // ユーザー関連
  users: {
    list: 'GET /api/users',
    get: 'GET /api/users/:id',
    create: 'POST /api/users',
    update: 'PUT /api/users/:id',
    delete: 'DELETE /api/users/:id',
  },

  // タスク関連
  tasks: {
    list: 'GET /api/tasks',
    get: 'GET /api/tasks/:id',
    create: 'POST /api/tasks',
    update: 'PUT /api/tasks/:id',
    delete: 'DELETE /api/tasks/:id',
  },
};

機能要件の詳細

すべての実装で以下の機能を提供します:

  1. ユーザー管理機能

    • ユーザー一覧表示(ページネーション付き)
    • ユーザー詳細表示
    • ユーザー新規作成・編集・削除
  2. タスク管理機能

    • タスク一覧表示(フィルタリング・ソート機能付き)
    • タスク詳細表示
    • タスク新規作成・編集・削除
    • ステータス変更(ドラッグ&ドロップ)
  3. リアルタイム更新

    • 他ユーザーの変更を即座に反映
    • 楽観的更新による UX 向上

各技術での実装アプローチ

各技術における最適化された実装パターンを採用し、公平な比較となるよう配慮します:

SPA 実装のポイント

  • Code Splitting による初期バンドルサイズの最適化
  • React.memo による不要な再レンダリングの防止
  • useMemo/useCallback による計算結果のキャッシュ

SSR 実装のポイント

  • getServerSideProps による適切なデータ prefetch
  • Static Generation の活用(適用可能な画面)
  • Incremental Static Regeneration による動的コンテンツ対応

htmx 実装のポイント

  • 適切な HTML キャッシュ戦略
  • hx-boost による段階的な適用
  • サーバーサイドでの効率的な部分レンダリング

これらの実装方針により、各技術の特性を活かした最適化を行いながら、機能面での同等性を保った公平な比較を実現いたします。

具体例

テストアプリケーションの仕様

実測比較のために、実際のビジネスアプリケーションを模した「プロジェクト管理システム」を 3 つの技術で実装いたします。

アプリケーション概要

mermaidflowchart TD
  login["ログイン画面"] --> dashboard["ダッシュボード"]
  dashboard --> users["ユーザー管理"]
  dashboard --> projects["プロジェクト管理"]
  dashboard --> tasks["タスク管理"]

  users --> user_list["ユーザー一覧"]
  users --> user_detail["ユーザー詳細"]
  users --> user_form["ユーザー編集"]

  projects --> project_list["プロジェクト一覧"]
  projects --> project_detail["プロジェクト詳細"]
  projects --> project_form["プロジェクト編集"]

  tasks --> task_board["タスクボード"]
  tasks --> task_detail["タスク詳細"]
  tasks --> task_form["タスク編集"]

  style dashboard fill:#e3f2fd
  style user_list fill:#e8f5e8
  style project_list fill:#e8f5e8
  style task_board fill:#e8f5e8

図で理解できる要点:

  • 階層的な画面構成でナビゲーション性能を測定
  • CRUD 操作を含む典型的なビジネスアプリケーション
  • リアルタイム更新が必要な要素を含む

データ規模とパフォーマンス条件

typescript// テストデータの規模設定
const TEST_DATA_SCALE = {
  users: 1000, // ユーザー数
  projects: 200, // プロジェクト数
  tasks: 5000, // タスク数

  // ページネーション設定
  pagination: {
    usersPerPage: 20,
    projectsPerPage: 12,
    tasksPerPage: 50,
  },

  // API レスポンス時間設定
  apiDelay: {
    fast: 100, // 高速レスポンス (ms)
    normal: 300, // 通常レスポンス (ms)
    slow: 1000, // 低速レスポンス (ms)
  },
};

共通 API サーバー実装

全ての実装で同一の API サーバーを使用し、公平性を確保します:

typescript// Express.js による API サーバー
import express from 'express';
import cors from 'cors';
import { z } from 'zod';

const app = express();

// CORS とミドルウェア設定
app.use(cors());
app.use(express.json());

// パフォーマンス測定のための遅延挿入
app.use((req, res, next) => {
  const delay = req.query.delay
    ? parseInt(req.query.delay as string)
    : 0;
  setTimeout(next, delay);
});

// ユーザー一覧 API
app.get('/api/users', (req, res) => {
  const page = parseInt(req.query.page as string) || 1;
  const limit = parseInt(req.query.limit as string) || 20;

  const users = generateUsers(1000); // テストデータ生成
  const startIndex = (page - 1) * limit;
  const endIndex = startIndex + limit;

  res.json({
    data: users.slice(startIndex, endIndex),
    total: users.length,
    page,
    totalPages: Math.ceil(users.length / limit),
  });
});

SPA 実装(React/Vue.js)

React での実装

React を使用した SPA 実装では、モダンな最適化技術を適用します:

typescript// App.tsx - メインアプリケーション
import React, { Suspense, lazy } from 'react';
import {
  BrowserRouter as Router,
  Routes,
  Route,
} from 'react-router-dom';
import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query';

// Code Splitting による遅延読み込み
const Dashboard = lazy(() => import('./pages/Dashboard'));
const UserList = lazy(() => import('./pages/UserList'));
const ProjectList = lazy(
  () => import('./pages/ProjectList')
);
const TaskBoard = lazy(() => import('./pages/TaskBoard'));

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5分間キャッシュ
      cacheTime: 10 * 60 * 1000, // 10分間保持
    },
  },
});

const App: React.FC = () => {
  return (
    <QueryClientProvider client={queryClient}>
      <Router>
        <Suspense fallback={<div>Loading...</div>}>
          <Routes>
            <Route path='/' element={<Dashboard />} />
            <Route path='/users' element={<UserList />} />
            <Route
              path='/projects'
              element={<ProjectList />}
            />
            <Route path='/tasks' element={<TaskBoard />} />
          </Routes>
        </Suspense>
      </Router>
    </QueryClientProvider>
  );
};

export default App;
typescript// pages/UserList.tsx - ユーザー一覧ページ
import React, { memo, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';

interface User {
  id: string;
  name: string;
  email: string;
  role: string;
}

const UserList: React.FC = memo(() => {
  const [page, setPage] = useState(1);

  // React Query によるデータフェッチング
  const { data, isLoading, error } = useQuery({
    queryKey: ['users', page],
    queryFn: () => fetchUsers(page),
    keepPreviousData: true, // ページング時のローディング改善
  });

  // 仮想化による大量データ対応
  const virtualizer = useVirtualizer({
    count: data?.total || 0,
    getScrollElement: () => scrollElementRef.current,
    estimateSize: () => 60, // 行の高さ
  });

  // メモ化による計算結果キャッシュ
  const filteredUsers = useMemo(() => {
    if (!data?.data) return [];
    return data.data.filter((user) =>
      user.name
        .toLowerCase()
        .includes(searchQuery.toLowerCase())
    );
  }, [data?.data, searchQuery]);

  if (isLoading) return <UserListSkeleton />;
  if (error) return <ErrorMessage error={error} />;

  return (
    <div className='user-list'>
      <SearchInput
        value={searchQuery}
        onChange={setSearchQuery}
      />
      <VirtualizedTable
        items={filteredUsers}
        virtualizer={virtualizer}
        renderItem={({ item }) => <UserRow user={item} />}
      />
      <Pagination
        current={page}
        total={data.totalPages}
        onChange={setPage}
      />
    </div>
  );
});

Vue.js での実装

Vue.js バージョンでは、Composition API と Pinia を活用します:

typescript// main.ts - Vue アプリケーション初期化
import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import { createPinia } from 'pinia';
import { VueQueryPlugin } from '@tanstack/vue-query';

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      component: () => import('./pages/Dashboard.vue'),
    },
    {
      path: '/users',
      component: () => import('./pages/UserList.vue'),
    },
    {
      path: '/projects',
      component: () => import('./pages/ProjectList.vue'),
    },
    {
      path: '/tasks',
      component: () => import('./pages/TaskBoard.vue'),
    },
  ],
});

const pinia = createPinia();

createApp(App)
  .use(router)
  .use(pinia)
  .use(VueQueryPlugin)
  .mount('#app');
vue<!-- pages/UserList.vue -->
<template>
  <div class="user-list">
    <SearchInput v-model="searchQuery" />

    <div v-if="isLoading" class="loading">
      <UserListSkeleton />
    </div>

    <div v-else-if="error" class="error">
      <ErrorMessage :error="error" />
    </div>

    <template v-else>
      <VirtualList
        :items="filteredUsers"
        :item-height="60"
        v-slot="{ item }"
      >
        <UserRow :user="item" />
      </VirtualList>

      <Pagination
        :current="page"
        :total="data?.totalPages || 0"
        @change="handlePageChange"
      />
    </template>
  </div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue';
import { useQuery } from '@tanstack/vue-query';
import { useUserStore } from '@/stores/user';

const userStore = useUserStore();
const page = ref(1);
const searchQuery = ref('');

// Vue Query によるリアクティブなデータフェッチング
const { data, isLoading, error } = useQuery({
  queryKey: computed(() => ['users', page.value]),
  queryFn: () => userStore.fetchUsers(page.value),
  keepPreviousData: true,
});

// 計算プロパティによるフィルタリング
const filteredUsers = computed(() => {
  if (!data.value?.data) return [];
  return data.value.data.filter((user) =>
    user.name
      .toLowerCase()
      .includes(searchQuery.value.toLowerCase())
  );
});

const handlePageChange = (newPage: number) => {
  page.value = newPage;
};
</script>

SSR 実装(Next.js/Nuxt.js)

Next.js での実装

Next.js では、適切な rendering 戦略を選択してパフォーマンスを最適化します:

typescript// pages/_app.tsx - アプリケーション設定
import type { AppProps } from 'next/app';
import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query';
import { useState } from 'react';

export default function App({
  Component,
  pageProps,
}: AppProps) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000, // SSR との整合性のため短縮
          },
        },
      })
  );

  return (
    <QueryClientProvider client={queryClient}>
      <Component {...pageProps} />
    </QueryClientProvider>
  );
}
typescript// pages/users/index.tsx - ユーザー一覧ページ
import { GetServerSideProps } from 'next';
import {
  QueryClient,
  dehydrate,
} from '@tanstack/react-query';
import { UserList } from '@/components/UserList';
import { fetchUsers } from '@/api/users';

interface Props {
  initialPage: number;
}

export default function UsersPage({ initialPage }: Props) {
  return (
    <div>
      <h1>ユーザー管理</h1>
      <UserList initialPage={initialPage} />
    </div>
  );
}

// サーバーサイドでのデータプリフェッチ
export const getServerSideProps: GetServerSideProps =
  async (context) => {
    const page =
      parseInt(context.query.page as string) || 1;
    const queryClient = new QueryClient();

    // サーバーサイドでデータを事前取得
    await queryClient.prefetchQuery({
      queryKey: ['users', page],
      queryFn: () => fetchUsers(page),
    });

    return {
      props: {
        dehydratedState: dehydrate(queryClient),
        initialPage: page,
      },
    };
  };
typescript// pages/api/users/index.ts - API Routes の活用
import type { NextApiRequest, NextApiResponse } from 'next';
import { z } from 'zod';

const getUsersSchema = z.object({
  page: z
    .string()
    .optional()
    .transform((val) => parseInt(val || '1')),
  limit: z
    .string()
    .optional()
    .transform((val) => parseInt(val || '20')),
});

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== 'GET') {
    return res
      .status(405)
      .json({ message: 'Method not allowed' });
  }

  try {
    const { page, limit } = getUsersSchema.parse(req.query);

    // データベースからの取得(実際の実装)
    const users = await getUsersFromDatabase({
      page,
      limit,
    });

    // キャッシュヘッダーの設定
    res.setHeader(
      'Cache-Control',
      's-maxage=60, stale-while-revalidate'
    );

    return res.status(200).json(users);
  } catch (error) {
    return res
      .status(400)
      .json({ message: 'Invalid parameters' });
  }
}

Nuxt.js での実装

Nuxt.js では、Universal Rendering と最新の Nitro エンジンを活用します:

typescript// nuxt.config.ts - Nuxt 設定
export default defineNuxtConfig({
  // Nitro エンジンによる最適化
  nitro: {
    preset: 'node-server',
    compressPublicAssets: true,
  },

  // 自動インポート設定
  imports: {
    autoImport: true,
  },

  // CSS フレームワーク
  css: ['@/assets/css/main.css'],

  // モジュール設定
  modules: ['@pinia/nuxt', '@nuxtjs/tailwindcss'],

  // ランタイム設定
  runtimeConfig: {
    apiSecret: '',
    public: {
      apiBase:
        process.env.API_BASE_URL || 'http://localhost:3001',
    },
  },
});
vue<!-- pages/users/index.vue -->
<template>
  <div class="users-page">
    <h1>ユーザー管理</h1>

    <SearchInput v-model="searchQuery" />

    <div v-if="pending" class="loading">
      <UserListSkeleton />
    </div>

    <template v-else>
      <UserTable :users="filteredUsers" />

      <Pagination
        :current="page"
        :total="data?.totalPages || 0"
        @change="navigateToPage"
      />
    </template>
  </div>
</template>

<script setup lang="ts">
// メタデータ設定
definePageMeta({
  title: 'ユーザー管理',
  description: 'システムユーザーの一覧と管理',
});

// リアクティブな状態管理
const route = useRoute();
const router = useRouter();
const searchQuery = ref('');

// クエリパラメータからの状態復元
const page = computed(
  () => parseInt(route.query.page as string) || 1
);

// サーバーサイド・クライアントサイド両対応のデータフェッチング
const { data, pending, error } = await useLazyFetch(
  '/api/users',
  {
    query: computed(() => ({
      page: page.value,
      limit: 20,
    })),
    key: 'users',
    server: true, // SSR でのプリフェッチ有効
  }
);

// 検索フィルタリング
const filteredUsers = computed(() => {
  if (!data.value?.data) return [];
  if (!searchQuery.value) return data.value.data;

  return data.value.data.filter((user) =>
    user.name
      .toLowerCase()
      .includes(searchQuery.value.toLowerCase())
  );
});

// ページナビゲーション
const navigateToPage = (newPage: number) => {
  router.push({
    query: { ...route.query, page: newPage },
  });
};

// エラーハンドリング
watch(error, (newError) => {
  if (newError) {
    throw createError({
      statusCode: 500,
      statusMessage: 'ユーザー情報の取得に失敗しました',
    });
  }
});
</script>

htmx 実装

htmx 実装では、HTML ファーストのアプローチでシンプルかつ効率的な実装を行います:

サーバーサイドテンプレート(Express + EJS)

javascript// server.js - Express サーバー設定
const express = require('express');
const path = require('path');
const compression = require('compression');

const app = express();

// 静的ファイルとテンプレートエンジン設定
app.use(compression());
app.use(express.static('public'));
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

// ミドルウェア:HTMX リクエスト判定
app.use((req, res, next) => {
  res.locals.isHtmxRequest =
    req.headers['hx-request'] === 'true';
  next();
});

// ユーザー一覧ページ
app.get('/users', async (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const search = req.query.search || '';

  try {
    const users = await getUsersFromDatabase({
      page,
      search,
    });

    // HTMX リクエストの場合は部分テンプレートを返す
    if (res.locals.isHtmxRequest) {
      return res.render('partials/user-list', {
        users,
        page,
        search,
      });
    }

    // 通常のリクエストは完全なページを返す
    res.render('pages/users', { users, page, search });
  } catch (error) {
    res
      .status(500)
      .render('error', { message: 'ユーザー取得エラー' });
  }
});

HTML テンプレート

html<!-- views/pages/users.ejs -->
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>ユーザー管理</title>
    <link rel="stylesheet" href="/css/styles.css" />
    <script src="https://unpkg.com/htmx.org@1.9.6"></script>
  </head>
  <body>
    <div class="container">
      <h1>ユーザー管理</h1>

      <!-- 検索フォーム -->
      <form
        hx-get="/users"
        hx-target="#user-content"
        hx-trigger="input changed delay:300ms"
        hx-indicator="#loading"
      >
        <input
          type="text"
          name="search"
          placeholder="ユーザー名で検索..."
          value="<%= search %>"
          autocomplete="off"
        />
        <div id="loading" class="htmx-indicator">
          検索中...
        </div>
      </form>

      <!-- ユーザーコンテンツエリア -->
      <div id="user-content">
        <%- include('partials/user-list', { users, page,
        search }) %>
      </div>
    </div>

    <!-- パフォーマンス最適化 -->
    <script>
      // プリフェッチによる高速化
      document.addEventListener(
        'DOMContentLoaded',
        function () {
          // よく使われるページを事前読み込み
          htmx.ajax('GET', '/users?page=2', {
            target: 'none',
          });
        }
      );
    </script>
  </body>
</html>
html<!-- views/partials/user-list.ejs -->
<div class="user-list">
  <% if (users.data && users.data.length > 0) { %>
  <table class="table">
    <thead>
      <tr>
        <th>ID</th>
        <th>名前</th>
        <th>メール</th>
        <th>ロール</th>
        <th>操作</th>
      </tr>
    </thead>
    <tbody>
      <% users.data.forEach(user => { %>
      <tr>
        <td><%= user.id %></td>
        <td><%= user.name %></td>
        <td><%= user.email %></td>
        <td><%= user.role %></td>
        <td>
          <button
            hx-get="/users/<%= user.id %>"
            hx-target="#modal-content"
            hx-trigger="click"
            class="btn btn-primary"
          >
            詳細
          </button>
          <button
            hx-delete="/users/<%= user.id %>"
            hx-target="closest tr"
            hx-confirm="本当に削除しますか?"
            class="btn btn-danger"
          >
            削除
          </button>
        </td>
      </tr>
      <% }) %>
    </tbody>
  </table>

  <!-- ページネーション -->
  <nav class="pagination">
    <% if (page > 1) { %>
    <button
      hx-get="/users?page=<%= page - 1 %>&search=<%= search %>"
      hx-target="#user-content"
      class="btn btn-outline"
    >
      前のページ
    </button>
    <% } %>

    <span class="page-info">
      ページ <%= page %> / <%= users.totalPages %>
    </span>

    <% if (page < users.totalPages) { %>
    <button
      hx-get="/users?page=<%= page + 1 %>&search=<%= search %>"
      hx-target="#user-content"
      class="btn btn-outline"
    >
      次のページ
    </button>
    <% } %>
  </nav>
  <% } else { %>
  <div class="empty-state">
    <p>ユーザーが見つかりませんでした。</p>
  </div>
  <% } %>
</div>

高度な htmx パターン

より高度な機能を htmx で実装する例:

html<!-- リアルタイム更新 -->
<div
  hx-get="/api/notifications"
  hx-trigger="load, every 30s"
  hx-target="#notifications"
>
  <div id="notifications">
    <!-- 通知がここに表示される -->
  </div>
</div>

<!-- オプティミスティック更新 -->
<form
  hx-post="/tasks"
  hx-target="#task-list"
  hx-swap="afterbegin"
>
  <input
    type="text"
    name="title"
    placeholder="新しいタスク"
  />
  <button type="submit">追加</button>
</form>

<!-- インフィニットスクロール -->
<div
  hx-get="/users?page=2"
  hx-trigger="revealed"
  hx-target="#user-list"
  hx-swap="afterend"
>
  さらに読み込む...
</div>

測定結果の詳細分析

実装が完了した 3 つのアプリケーションに対して、統一された測定環境で性能評価を実施いたします。

測定条件

typescript// 測定設定
const MEASUREMENT_CONFIG = {
  // 測定回数
  iterations: 50,

  // 測定環境
  environment: {
    cpu: 'Intel Core i7-12700K',
    memory: '32GB',
    browser: 'Chrome 115.0.5790.170',
    network: '100Mbps, 10ms latency',
  },

  // 測定シナリオ
  scenarios: [
    'initial_load', // 初回読み込み
    'page_navigation', // ページ遷移
    'data_update', // データ更新
    'search_filter', // 検索・フィルタ
    'form_submission', // フォーム送信
  ],
};

Core Web Vitals 測定結果

指標SPA (React)SSR (Next.js)htmx最適値
FCP1,420ms680ms420ms< 1,800ms
LCP2,180ms1,240ms890ms< 2,500ms
TTI3,950ms2,100ms950ms< 3,800ms
CLS0.080.030.01< 0.1

結果分析:

  1. 初期表示性能: htmx が最も優秀で、続いて SSR、SPA の順となりました
  2. 累積レイアウトシフト: htmx が最も安定し、視覚的な快適性が高い結果となりました
  3. インタラクション準備時間: htmx は JavaScript の読み込みが最小限のため、最も早くユーザー操作が可能になります

ページ遷移パフォーマンス

mermaidgraph LR
  scenario["ユーザー操作"] --> spa_time["SPA: 180ms"]
  scenario --> ssr_time["SSR: 420ms"]
  scenario --> htmx_time["htmx: 250ms"]

  spa_time --> spa_note["クライアント処理"]
  ssr_time --> ssr_note["サーバー処理 + 再描画"]
  htmx_time --> htmx_note["部分更新"]

  style spa_time fill:#e8f5e8
  style ssr_time fill:#fff3e0
  style htmx_time fill:#e3f2fd

ページ遷移時間の詳細:

操作SPASSRhtmx
ユーザー一覧 → 詳細150ms380ms220ms
検索フィルタ適用80ms450ms180ms
ページネーション120ms400ms190ms
フォーム送信200ms350ms280ms

メモリ使用量とバンドルサイズ

項目SPA (React)SSR (Next.js)htmx
初期 JS バンドル348KB289KB12KB
ランタイムメモリ28MB22MB8MB
キャッシュサイズ2.1MB1.8MB450KB

リソース効率性の分析:

htmx は圧倒的に軽量で、特にモバイル環境や低速ネットワークでの優位性が明確です。SPA は高機能ですが、その分リソース消費が大きくなります。

ネットワーク条件別性能

低速ネットワーク環境(3G 相当:1.6Mbps、300ms latency)での測定:

指標SPASSRhtmx
初期読み込み8,420ms3,180ms1,890ms
後続操作180ms520ms380ms
データ更新220ms480ms310ms

低速環境では htmx の優位性がより顕著になり、ユーザー体験の格差が拡大します。

図で理解できる要点:

  • 初期読み込みでは htmx が圧倒的に高速
  • SPA は初期読み込み後の操作で優位性を発揮
  • SSR は両者の中間的な性能を示す
  • ネットワーク環境が悪化するほど htmx の優位性が拡大

この測定結果は、技術選択の重要な判断材料となり、プロジェクトの要件に応じた最適な選択肢を見極めるのに役立ちます。

まとめ

各手法の適用場面

実測結果と実装体験を通じて、それぞれの技術が最も力を発揮する場面が明確になりました。

SPA が適している場面

高頻度なユーザーインタラクションが必要なアプリケーション

  • データ分析ダッシュボード
  • リアルタイム編集ツール(Google Docs 的な機能)
  • ゲームやインタラクティブなアプリケーション

主な理由: 初期読み込み後は、クライアントサイドでの高速な状態変更とレンダリングが可能で、ユーザーの操作に対するレスポンスが極めて良好です。

typescript// SPA が威力を発揮する例:リアルタイム編集
const useRealtimeEditor = () => {
  const [content, setContent] = useState('');

  // ユーザーの入力を即座に反映
  const handleChange = useCallback((value: string) => {
    setContent(value); // 即座に UI 更新
    debouncedSave(value); // 非同期で保存
  }, []);

  return { content, handleChange };
};

SSR が適している場面

SEO が重要で、かつ動的コンテンツを含むアプリケーション

  • EC サイト
  • ニュースサイト・ブログ
  • 企業の公式サイト

主な理由: 検索エンジンでの発見性を保ちながら、動的なユーザー体験も提供できます。初期表示の高速化により、ユーザーの離脱率も改善されます。

typescript// SSR が威力を発揮する例:商品ページ
export const getServerSideProps: GetServerSideProps =
  async ({ params }) => {
    // サーバーサイドで商品情報とレビューを取得
    const [product, reviews] = await Promise.all([
      getProduct(params.id),
      getReviews(params.id),
    ]);

    return {
      props: { product, reviews },
      // SEO に重要な情報をサーバーで生成
    };
  };

htmx が適している場面

開発速度を重視し、中程度の動的機能が必要なアプリケーション

  • 社内管理システム
  • CRUD 中心の業務アプリケーション
  • プロトタイプ開発

主な理由: シンプルな実装で十分な UX を提供でき、保守性と開発効率が高いです。また、軽量であるため、リソースに制約がある環境でも快適に動作します。

html<!-- htmx が威力を発揮する例:簡潔な実装 -->
<button
  hx-post="/api/toggle-status"
  hx-target="#status-display"
  hx-swap="outerHTML"
>
  ステータス切り替え
</button>

パフォーマンス以外の考慮点

技術選択において、パフォーマンス以外にも重要な要素があります:

開発・保守コスト

項目SPASSRhtmx
学習コスト
開発速度
保守性
チーム拡張性

エコシステムとコミュニティ

  • SPA: 豊富なライブラリとツール、大規模なコミュニティ
  • SSR: 成熟したフレームワーク、企業での採用事例多数
  • htmx: 新興技術だが急成長中、シンプルで参入しやすい

技術的負債のリスク

長期的な観点では、過度に複雑な SPA 実装は技術的負債を生む可能性があります。一方、htmx はシンプルな構造により、将来的なメンテナンスコストを抑制できる傾向があります。

技術選択の指針

実測結果と開発体験を踏まえ、以下の判断フローを提案いたします:

mermaidflowchart TD
  start["プロジェクト開始"] --> seo_check{"SEO重要?"}

  seo_check -->|Yes| interaction_check{"高頻度インタラクション?"}
  seo_check -->|No| spa_or_htmx{"複雑なUI?"}

  interaction_check -->|Yes| ssr["SSR採用"]
  interaction_check -->|No| simple_check{"シンプルな機能?"}

  simple_check -->|Yes| htmx["htmx採用"]
  simple_check -->|No| ssr

  spa_or_htmx -->|Yes| spa["SPA採用"]
  spa_or_htmx -->|No| team_check{"チーム経験?"}

  team_check -->|高| spa
  team_check -->|低| htmx

  style htmx fill:#e3f2fd
  style spa fill:#e8f5e8
  style ssr fill:#fff3e0

具体的な判断基準:

  1. htmx を選ぶべき場合

    • 開発チームが小規模(1-3 名)
    • プロジェクトの期間が短い(3 ヶ月以内)
    • CRUD 操作が中心
    • ユーザー数が中規模(1 万人以下)
  2. SSR を選ぶべき場合

    • SEO が収益に直結する
    • 初期表示速度が重要
    • 安定した技術を求める
    • 中長期的な開発を予定
  3. SPA を選ぶべき場合

    • 高度なユーザーインタラクションが必要
    • オフライン機能が求められる
    • ネイティブアプリ風の UX を目指す
    • 豊富なエコシステムを活用したい

最終的に、技術選択は単一の要因ではなく、プロジェクトの要件、チームの特性、長期的な戦略を総合的に考慮して決定することが重要です。本記事の実測データが、皆さんの判断材料の一つとして役立つことを願っております。

関連リンク