PCでは広いナビゲーションと複数カラムの商品一覧を表示し、スマートフォンではハンバーガーメニューと1カラムの画面へ切り替えたい。このような要件に対して、PC用とモバイル用で別々のURLを用意する方法もありますが、共有されたリンクや検索結果、計測、運用を考えると、同じURLを維持したい場面は少なくありません。

ただし、「端末を判定してPC版とモバイル版を出し分ける」と考えると、必要以上に複雑な構成になりやすくなります。画面の違いがレイアウトだけならCSSで解決できます。コンポーネントの構造まで変わる場合はJavaScriptが必要です。配信するHTMLやJavaScriptのまとまり自体を分けたい場合に、初めてサーバー側の分岐を検討します。

この記事では、同じURLでPCとスマートフォンへ適切な画面を返す方法を、次の三段階に分けて整理します。

  1. CSSのMedia QueryとContainer Query
  2. JavaScriptとフレームワーク内の条件レンダリング
  3. サーバー側での配信先・HTMLの分岐

同じURLを維持する理由

PC向けにwww.example.com、モバイル向けにm.example.comを用意する構成は、画面と開発体制を完全に分離できる点では分かりやすい方法です。一方で、二つのURLを持つことで、次の管理が必要になります。

  • PCで共有されたリンクをスマートフォンで開いた場合の転送
  • canonicalやalternateなど検索エンジン向けの関係付け
  • 二つのアプリケーション間での機能差と更新差分
  • Cookie、認証、アクセス解析の扱い
  • 二系統のデプロイ、監視、障害対応

製品やコンテンツが同じで、表示方法だけが異なるなら、URLも同じままにした方が扱いやすい場合があります。重要なのは、URLを一つにすることではなく、何を共通化し、どこから分岐させるかを決めることです。

基本は端末種別ではなく表示領域で考える

PCとスマートフォンの違いを考えるとき、最初にUser-Agentを解析したくなります。しかし、レイアウトの判断に本当に必要なのは、端末名ではなく利用できる表示領域であることが多いはずです。

PCでもウィンドウを狭くして使う場合があります。タブレットの横幅は小型ノートPCに近づくことがあります。タッチ操作に対応したPCもあり、「PCはマウス、スマートフォンはタッチ」と単純には分けられません。

そのため、余白、カラム数、ナビゲーションの配置、文字サイズといった違いは、まずCSSのMedia Queryで扱います。

.page {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1rem;
  padding: 1rem;
}

.desktop-navigation {
  display: none;
}

.mobile-navigation {
  display: flex;
}

@media (width >= 64rem) {
  .page {
    grid-template-columns: 16rem minmax(0, 1fr);
    gap: 2rem;
    max-width: 75rem;
    margin-inline: auto;
  }

  .desktop-navigation {
    display: flex;
  }

  .mobile-navigation {
    display: none;
  }
}

この構成では、HTMLの内容を共通化したまま、ビューポート幅に応じてレイアウトだけを変更できます。JavaScriptの実行前から適切なスタイルが適用されるため、初期表示時の切り替わりも起きにくくなります。

コンポーネント単位ではContainer Queryを使う

Media Queryはビューポート全体を基準にします。ところが、同じ商品カードでも、メイン領域では横並びにし、狭いサイドバーでは縦並びにしたいことがあります。このような再利用コンポーネントは、画面全体の幅よりも、配置されたコンテナーの幅を基準にした方が自然です。

.product-section {
  container-type: inline-size;
}

.product-card {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1rem;
}

@container (width >= 40rem) {
  .product-card {
    grid-template-columns: 12rem minmax(0, 1fr);
  }
}

Container Queryを使うと、コンポーネント自身がどのページに置かれているかを意識せず、利用可能な領域に応じて表示を変えられます。サイト全体の骨格はMedia Query、再利用コンポーネント内部はContainer Queryという分担が考えられます。

User-Agent判定を第一選択にしない

ブラウザーはHTTPリクエストのUser-Agentヘッダーや、JavaScriptのnavigator.userAgentを通して、自身に関する文字列を提供します。これを正規表現で調べれば、モバイルらしい端末を推測できます。

function isProbablyMobile(userAgent = navigator.userAgent) {
  return /Mobi|Android|iPhone|iPod/i.test(userAgent);
}

ただし、この結果は端末種別を厳密に保証するものではありません。UA文字列には互換性のため複数のブラウザー名が含まれることがあり、利用者や開発者ツールによる変更も可能です。新しい端末やブラウザーが登場すれば、判定規則の更新も必要になります。

User-Agent Client Hintsを利用できる環境もありますが、ブラウザー間で一様に使える仕組みではありません。UA文字列を別のAPIへ置き換えるだけでは、表示領域や入力方法を推測する問題そのものは解消しません。

UA判定を使うなら、次のような限定された用途に留める方が安全です。

  • 過去のモバイル専用アプリケーションへ暫定的に振り分ける
  • 特定端末でのみ発生する既知の不具合を回避する
  • サーバー側で初期値を推測し、CSSやクライアント側判定で補正する

レイアウトを決めるためだけにUAを使う必要はありません。

コンポーネント構造が異なる場合のJavaScript判定

CSSだけでは、表示・非表示は切り替えられても、両方のコンポーネントがDOMへ生成される設計になることがあります。PCとモバイルで読み込む機能やコンポーネントツリーが大きく異なるなら、JavaScriptのmatchMedia()を使って分岐できます。

以下はReact向けの最小例です。コードは構成を示す例であり、利用するSSRフレームワークのHydration方針に合わせた調整が必要です。

import { useEffect, useState } from "react";

export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(false);

  useEffect(() => {
    const media = window.matchMedia(query);
    const update = () => setMatches(media.matches);

    update();
    media.addEventListener("change", update);

    return () => {
      media.removeEventListener("change", update);
    };
  }, [query]);

  return matches;
}
import { DesktopLayout } from "./DesktopLayout";
import { MobileLayout } from "./MobileLayout";
import { useMediaQuery } from "./useMediaQuery";

export function App() {
  const isDesktop = useMediaQuery("(width >= 64rem)");

  return isDesktop ? <DesktopLayout /> : <MobileLayout />;
}

resizeイベントを直接監視してwindow.innerWidthを毎回読む方法もありますが、メディア条件との対応にはmatchMedia()の方が意図を表しやすくなります。イベントリスナーの登録と解除はEffect内に置き、コンポーネントのアンマウント時に必ず解除します。

SSRとHydrationのずれ

サーバーは通常、ブラウザーのビューポート幅を知りません。そのため、サーバーではモバイル版を出力し、クライアントの初回レンダリングではPC版を選ぶと、HTMLが一致しない可能性があります。

この問題を避ける方法は一つではありません。

  • レイアウト差はCSSへ任せ、HTML構造を共通化する
  • 初回は共通の骨格を描画し、マウント後に端末固有機能を追加する
  • SSRフレームワークが提供するClient-only境界を使う
  • サーバーで推測した初期値をクライアントへ渡し、初回レンダリングをそろえる

同じ内容を見せるだけなら、CSS中心の構成が最もずれを起こしにくい方法です。

React ContextやVueのprovide/injectを使う場面

画面内の多くのコンポーネントが同じ表示モードを必要とする場合、各コンポーネントで個別に判定すると、規則が分散します。React ContextやVueのprovideinjectを使えば、判定結果をアプリケーション上位から共有できます。

ただし、グローバルにisMobileを配ること自体を目的にしない方がよいでしょう。共有する値は、端末名よりもUI上の意味を表す方が変更へ対応しやすくなります。

export type NavigationMode = "compact" | "expanded";

たとえば、mobiledesktopではなく、compactexpandedを共有します。将来タブレット用の中間表示を追加しても、端末分類へ縛られずに済みます。

Vueでは、上位コンポーネントがリアクティブな値をprovide()し、子孫コンポーネントがinject()できます。ReactでもContextへ同様の表示モードを渡せます。ただし、単純な余白や列数まで状態管理へ持ち込まず、CSSで解決できる範囲はCSSへ残します。

サーバー側で分岐する条件

サーバー側の分岐が必要になるのは、レイアウトではなく配信物そのものを分けたい場合です。

  • PC版とモバイル版が独立したアプリケーションとして運用されている
  • 端末ごとに送るJavaScriptの量を大きく変えたい
  • サーバーレンダリングするHTML構造が完全に異なる
  • 段階的な統合中で、旧モバイル版への振り分けが残っている

Nginxではmapを使ってUser-Agentから大まかな分類値を作り、上流アプリケーションへ渡せます。以下は概念例です。実際の正規表現、キャッシュ、転送先は環境に合わせて検証してください。

map $http_user_agent $ui_variant {
    default                         desktop;
    ~*(Mobi|Android|iPhone|iPod)    mobile;
}

server {
    listen 443 ssl;
    server_name example.com;

    location / {
        proxy_set_header X-UI-Variant $ui_variant;
        proxy_pass http://frontend_renderer;
    }
}

アプリケーション側はX-UI-Variantを初期値として使い、対応するHTMLを生成できます。二つの別URLへリダイレクトせず、同じURLのレスポンス内容だけを変える構成も可能です。

キャッシュキーを忘れない

同じURLでレスポンス内容を変える場合、CDNやリバースプロキシのキャッシュ設計が重要です。モバイル向けHTMLをキャッシュした後、PC利用者へ同じキャッシュが返れば誤表示になります。

Vary: User-Agentを返す方法はありますが、UA文字列の種類が多いため、キャッシュが細かく分割される可能性があります。運用上は、サーバー内部でmobiledesktopのような少数の分類へ正規化し、その値をキャッシュキーへ含める方法を検討します。

検索エンジンに対しても、利用者と異なる内容を意図せず返さないよう確認が必要です。同じURLの主要コンテンツは共通にし、ナビゲーションや操作UIだけを変更する方が管理しやすくなります。

実装方法の選び方

同じURLで画面を適応させる方法は、次の順番で検討すると過剰な実装を避けやすくなります。

要件第一候補理由
余白、列数、文字サイズを変えるCSS Media QueryJavaScriptなしで初期表示から反映できる
再利用コンポーネントを配置領域へ適応させるCSS Container Queryビューポートではなく親要素の幅を基準にできる
PCとモバイルでコンポーネントツリーを変えるmatchMedia()と条件レンダリング不要なコンポーネントの生成を避けられる
多数の子コンポーネントへ表示モードを共有するContextまたはprovideinject判定規則を一か所へ集約できる
HTMLやバンドルを端末ごとに分けるサーバー側分岐初回レスポンスから配信物を変えられる
旧PC版・旧モバイル版を暫定的に共存させるサーバー分岐またはリダイレクト移行期間の境界を明確にできる

多くのサイトでは、CSSだけで大部分を処理し、一部の操作UIにJavaScriptを加える構成で足ります。サーバー分岐を追加するのは、転送量や初期HTMLを分ける明確な理由がある場合です。

実装時に確認する項目

入力方法を端末名から決めない

PCにもタッチスクリーンがあり、スマートフォンへキーボードやポインティングデバイスを接続することもできます。ホバー表示やボタンの大きさを決める場合は、端末名ではなくhoverpointerなどのMedia Featureも検討します。

@media (hover: hover) and (pointer: fine) {
  .menu-item:hover {
    background: var(--hover-background);
  }
}

表示を切り替えても機能を欠落させない

モバイル版でメニュー項目を削りすぎると、PCでしか実行できない操作が生まれます。配置は変えても、必要な機能へ到達できるかを確認します。

ブレークポイントを端末一覧から作らない

「iPhone用」「タブレット用」「PC用」と機種ごとに値を増やすと、未知の画面サイズへ対応しにくくなります。コンテンツが崩れる幅を基準にブレークポイントを決めます。

実機とウィンドウリサイズの両方で確認する

開発者ツールの端末エミュレーションだけでは、ブラウザーUI、セーフエリア、ソフトウェアキーボード、入力方法の違いを完全には再現できません。主要な実機に加え、PCの狭いウィンドウやタブレットの縦横表示も確認します。

まとめ

同じURLでPC向けWebとモバイル向けH5を出し分ける設計は、最初から二つのアプリケーションへ分割する必要はありません。

まず、レイアウト差をMedia QueryとContainer Queryで処理します。次に、コンポーネント構造を変える必要がある部分だけmatchMedia()と条件レンダリングを使います。表示モードを多数のコンポーネントへ共有する場合は、Contextやprovideinjectへ集約します。

サーバー側のUA分岐は、HTMLやJavaScriptの配信物を本当に分ける必要がある場合の選択肢です。導入する場合は、Hydration、キャッシュキー、検索エンジン、UA判定の誤りまで含めて設計しなければなりません。

端末名を当てることより、利用可能な領域、入力方法、必要な機能を基準に画面を組み立てる方が、未知の端末にも対応しやすい設計になります。

参考資料