PCでは広いナビゲーションと複数カラムの商品一覧を表示し、スマートフォンではハンバーガーメニューと1カラムの画面へ切り替えたい。このような要件に対して、PC用とモバイル用で別々のURLを用意する方法もありますが、共有されたリンクや検索結果、計測、運用を考えると、同じURLを維持したい場面は少なくありません。
ただし、「端末を判定してPC版とモバイル版を出し分ける」と考えると、必要以上に複雑な構成になりやすくなります。画面の違いがレイアウトだけならCSSで解決できます。コンポーネントの構造まで変わる場合はJavaScriptが必要です。配信するHTMLやJavaScriptのまとまり自体を分けたい場合に、初めてサーバー側の分岐を検討します。
この記事では、同じURLでPCとスマートフォンへ適切な画面を返す方法を、次の三段階に分けて整理します。
- CSSのMedia QueryとContainer Query
- JavaScriptとフレームワーク内の条件レンダリング
- サーバー側での配信先・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のprovide/injectを使えば、判定結果をアプリケーション上位から共有できます。
ただし、グローバルにisMobileを配ること自体を目的にしない方がよいでしょう。共有する値は、端末名よりもUI上の意味を表す方が変更へ対応しやすくなります。
export type NavigationMode = "compact" | "expanded";
たとえば、mobileとdesktopではなく、compactとexpandedを共有します。将来タブレット用の中間表示を追加しても、端末分類へ縛られずに済みます。
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文字列の種類が多いため、キャッシュが細かく分割される可能性があります。運用上は、サーバー内部でmobileとdesktopのような少数の分類へ正規化し、その値をキャッシュキーへ含める方法を検討します。
検索エンジンに対しても、利用者と異なる内容を意図せず返さないよう確認が必要です。同じURLの主要コンテンツは共通にし、ナビゲーションや操作UIだけを変更する方が管理しやすくなります。
実装方法の選び方
同じURLで画面を適応させる方法は、次の順番で検討すると過剰な実装を避けやすくなります。
| 要件 | 第一候補 | 理由 |
|---|---|---|
| 余白、列数、文字サイズを変える | CSS Media Query | JavaScriptなしで初期表示から反映できる |
| 再利用コンポーネントを配置領域へ適応させる | CSS Container Query | ビューポートではなく親要素の幅を基準にできる |
| PCとモバイルでコンポーネントツリーを変える | matchMedia()と条件レンダリング | 不要なコンポーネントの生成を避けられる |
| 多数の子コンポーネントへ表示モードを共有する | Contextまたはprovide/inject | 判定規則を一か所へ集約できる |
| HTMLやバンドルを端末ごとに分ける | サーバー側分岐 | 初回レスポンスから配信物を変えられる |
| 旧PC版・旧モバイル版を暫定的に共存させる | サーバー分岐またはリダイレクト | 移行期間の境界を明確にできる |
多くのサイトでは、CSSだけで大部分を処理し、一部の操作UIにJavaScriptを加える構成で足ります。サーバー分岐を追加するのは、転送量や初期HTMLを分ける明確な理由がある場合です。
実装時に確認する項目
入力方法を端末名から決めない
PCにもタッチスクリーンがあり、スマートフォンへキーボードやポインティングデバイスを接続することもできます。ホバー表示やボタンの大きさを決める場合は、端末名ではなくhover、pointerなどの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やprovide/injectへ集約します。
サーバー側のUA分岐は、HTMLやJavaScriptの配信物を本当に分ける必要がある場合の選択肢です。導入する場合は、Hydration、キャッシュキー、検索エンジン、UA判定の誤りまで含めて設計しなければなりません。
端末名を当てることより、利用可能な領域、入力方法、必要な機能を基準に画面を組み立てる方が、未知の端末にも対応しやすい設計になります。