htmx 逆引きレシピ
直リンクでも破綻しない設計にするには?
公開日:
最終更新日:
最初にSSRでページ全体を成立させ、直URL・リロード・非JSでも成立する土台を作ります。
その上に hx-boost / hx-select / hx-target / hx-disinherit を重ねると、安全に“部分更新”を導入できます。
使用するhtmx属性
タグ:hx-boost / hx-select / hx-target / hx-disinherit
hx-boost:普通のリンク/フォームを、壊さずに部分更新へ上乗せします。hx-select:フルHTMLレスポンスから必要領域(例:#MAIN)だけ採用します。hx-target:差し替え先を明確化し、共通レイアウトを維持します。hx-disinherit:ダウンロード・外部リンク・フル遷移をboost対象から除外します。
利用シーン
- 直URLで開いてもOK:共有リンクやブックマーク、リロードでも「画面が成立する」SSR土台にしておきたい
- JSなしでも最低限動く:まずは普通のリンク/フォームとして完結させ、JSがある時だけboostで快適化したい
- 必要な部分だけ差し替える:ヘッダ/サイドは固定のまま #MAIN だけ更新し、遷移のチラつきと再描画コストを減らしたい
共通PHP(全文)
エスケープ関数・URL補助・メイン描画・フルHTML出力を _deep_link_safe_data.php に集約しています。
すべての表示値は共通関数経由で htmlspecialchars を適用します。
PHP(_deep_link_safe_data.php)
/htmx/demo/_deep_link_safe_data.php
<?php
// 型を厳密に扱う
declare(strict_types=1);
// HTML特殊文字を安全化する
function deep_link_safe_h(string $value): string
{
// エスケープ済み文字列を返す
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
// リクエスト文字列を安全に取り出す
function deep_link_safe_request_text(array $source, string $key, string $fallback): string
{
// 文字列を取り出して前後空白を除去する
$raw = trim((string)($source[$key] ?? ''));
// 空文字なら既定値を返す
if ($raw === '') {
// 既定値を返す
return $fallback;
}
// 入力値を返す
return $raw;
}
// 表示ビューを安全に決める
function deep_link_safe_request_view(array $source): string
{
// view値を取得する
$view = deep_link_safe_request_text($source, 'view', 'a');
// 許可ビューを定義する
$allowed = ['a', 'b', 'c'];
// 不正値ならaへ補正する
if (!in_array($view, $allowed, true)) {
// aへ補正する
$view = 'a';
}
// 補正後viewを返す
return $view;
}
// 表示デモ番号を安全に決める
function deep_link_safe_request_demo(array $source): string
{
// demo値を取得する
$demo = deep_link_safe_request_text($source, 'demo', '1');
// 許可デモを定義する
$allowed = ['1', '2', '3'];
// 不正値なら1へ補正する
if (!in_array($demo, $allowed, true)) {
// 1へ補正する
$demo = '1';
}
// 補正後demoを返す
return $demo;
}
// 一覧元データを返す
function deep_link_safe_items(): array
{
// 一覧データを返す
return [
['id' => 'A-100', 'title' => 'ダッシュボード構成の見直し', 'owner' => 'tanaka'],
['id' => 'A-110', 'title' => '監査ログの保存期間を更新', 'owner' => 'sato'],
['id' => 'B-200', 'title' => '一括メール配信の導線調整', 'owner' => 'suzuki'],
['id' => 'B-210', 'title' => '管理画面ヘルプを再編', 'owner' => 'kato'],
['id' => 'C-300', 'title' => '運用手順書のリンク更新', 'owner' => 'tanaka'],
['id' => 'C-310', 'title' => 'エクスポート仕様の確認', 'owner' => 'sato'],
];
}
// クエリで一覧を絞り込む
function deep_link_safe_filter_items(string $q): array
{
// 検索語を小文字化する
$q = mb_strtolower(trim($q));
// 元データを取得する
$items = deep_link_safe_items();
// 検索語が空なら全件返す
if ($q === '') {
// 全件返す
return $items;
}
// 抽出結果配列を初期化する
$picked = [];
// 1件ずつ確認する
foreach ($items as $row) {
// タイトルを取得する
$title = (string)($row['title'] ?? '');
// 担当を取得する
$owner = (string)($row['owner'] ?? '');
// タイトル一致を判定する
$hitTitle = (mb_stripos(mb_strtolower($title), $q) !== false);
// 担当一致を判定する
$hitOwner = (mb_stripos(mb_strtolower($owner), $q) !== false);
// どちらか一致なら採用する
if ($hitTitle || $hitOwner) {
// 結果へ追加する
$picked[] = $row;
}
}
// 抽出結果を返す
return $picked;
}
// ビュー情報を返す
function deep_link_safe_view_meta(string $view): array
{
// ビュー情報マップを定義する
$map = [
'a' => [
'label' => 'view=a',
'title' => '概要ビュー',
'desc' => 'SSRで成立する共通レイアウトを維持しつつ、MAINだけを差し替えるビューです。',
],
'b' => [
'label' => 'view=b',
'title' => 'レポートビュー',
'desc' => 'リンクとフォームを通常HTMLとして書き、JSあり時のみboostで部分更新します。',
],
'c' => [
'label' => 'view=c',
'title' => '運用ビュー',
'desc' => 'boost除外リンクを混在させる運用向けビューです。',
],
];
// 対象情報を返す
return $map[$view] ?? $map['a'];
}
// URL文字列を組み立てる
function deep_link_safe_build_url(string $selfPath, string $demo, string $view, string $q, bool $withDemo): string
{
// パラメータ配列を初期化する
$params = ['view' => $view];
// demoを含める場合は追加する
if ($withDemo) {
// demoを追加する
$params['demo'] = $demo;
}
// 検索語が空でなければ追加する
if ($q !== '') {
// qを追加する
$params['q'] = $q;
}
// クエリ文字列を作る
$query = http_build_query($params);
// URLを返す
return $selfPath . '?' . $query;
}
// MAIN領域のHTMLを返す
function deep_link_safe_render_main(string $selfPath, string $demo, string $view, string $q, bool $withDemo): string
{
// ビュー情報を取得する
$meta = deep_link_safe_view_meta($view);
// ビューラベルを取得する
$viewLabel = deep_link_safe_h((string)($meta['label'] ?? 'view=a'));
// ビュータイトルを取得する
$viewTitle = deep_link_safe_h((string)($meta['title'] ?? '概要ビュー'));
// ビュー説明を取得する
$viewDesc = deep_link_safe_h((string)($meta['desc'] ?? ''));
// 検索語をエスケープする
$qEsc = deep_link_safe_h($q);
// ビューaリンクを作る
$linkA = deep_link_safe_h(deep_link_safe_build_url($selfPath, $demo, 'a', $q, $withDemo));
// ビューbリンクを作る
$linkB = deep_link_safe_h(deep_link_safe_build_url($selfPath, $demo, 'b', $q, $withDemo));
// ビューcリンクを作る
$linkC = deep_link_safe_h(deep_link_safe_build_url($selfPath, $demo, 'c', $q, $withDemo));
// 検索結果を取得する
$rows = deep_link_safe_filter_items($q);
// HX-Requestヘッダ値を取得する
$hxHeader = deep_link_safe_h((string)($_SERVER['HTTP_HX_REQUEST'] ?? '(none)'));
// HTMLバッファを開始する
ob_start();
?>
<main id="MAIN" class="SECTION">
<h2><?= $viewTitle ?>(<?= $viewLabel ?>)</h2>
<p class="HTMX-NOTE"><?= $viewDesc ?></p>
<div class="FORM-RESULT is-ok">
<p><strong>server response:</strong> 常にフルHTMLを返却(クライアント側で <code>hx-select="#MAIN"</code> を適用)</p>
<p><strong>HX-Request:</strong> <?= $hxHeader ?></p>
</div>
<nav class="NAV">
<a class="BTN" href="<?= $linkA ?>">view=a</a>
<a class="BTN" href="<?= $linkB ?>">view=b</a>
<a class="BTN" href="<?= $linkC ?>">view=c</a>
</nav>
<?php if ($demo === '2'): ?>
<section class="CARD MT1rm">
<h3>通常フォーム(非JSでも動く)</h3>
<form class="FORM" method="get" action="<?= deep_link_safe_h($selfPath) ?>">
<?php if ($withDemo): ?>
<input type="hidden" name="demo" value="<?= deep_link_safe_h($demo) ?>">
<?php endif; ?>
<input type="hidden" name="view" value="<?= deep_link_safe_h($view) ?>">
<label>
キーワード
<input type="text" name="q" value="<?= $qEsc ?>" placeholder="例: tanaka / 運用">
</label>
<button class="BTN is-ok" type="submit">検索</button>
</form>
</section>
<section class="CARD MT1rm">
<h3>検索結果</h3>
<?php if ($rows === []): ?>
<p class="HTMX-NOTE">該当データがありません。</p>
<?php else: ?>
<ul class="HTMX-LIST">
<?php foreach ($rows as $row): ?>
<li>
<strong><?= deep_link_safe_h((string)($row['id'] ?? '')) ?></strong>
<?= deep_link_safe_h((string)($row['title'] ?? '')) ?>
(<span><?= deep_link_safe_h((string)($row['owner'] ?? '')) ?></span>)
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</section>
<?php endif; ?>
<?php if ($demo === '3'): ?>
<section class="CARD MT1rm">
<h3>boost除外リンク(局所無効化)</h3>
<ul class="HTMX-LIST">
<li><a class="BTN" href="/htmx/demo/_deep_link_safe_data.php" download hx-disinherit="hx-boost">設定データDL(通常遷移)</a></li>
<li><a class="BTN" href="https://htmx.org/docs/" target="_blank" rel="noopener noreferrer" hx-disinherit="hx-boost">外部ドキュメント(通常遷移)</a></li>
<li><a class="BTN" href="/htmx/demo/_deep_link_safe_page.php?demo=3&view=a" hx-disinherit="hx-boost">管理画面フル遷移(通常遷移)</a></li>
</ul>
</section>
<section class="CARD MT1rm" hx-disinherit="hx-boost">
<h3>モーダル内リンクは通常遷移の例</h3>
<p class="HTMX-NOTE">このカード内のリンクは <code>hx-disinherit="hx-boost"</code> によりboostを継承しません。</p>
<p><a class="BTN" href="/htmx/demo/_deep_link_safe_page.php?demo=3&view=b">通常遷移で開く</a></p>
</section>
<?php endif; ?>
</main>
<?php
// バッファ文字列を返す
return (string)ob_get_clean();
}
// ページ全体を出力する
function deep_link_safe_emit_page(string $selfPath, string $demo, string $view, string $q, bool $withDemo): void
{
// コンテンツタイプを返す
header('Content-Type: text/html; charset=UTF-8');
// ページタイトルを作る
$pageTitle = 'deep-link-safe demo ' . $demo;
// MAIN領域を作る
$mainHtml = deep_link_safe_render_main($selfPath, $demo, $view, $q, $withDemo);
// ビューaリンクを作る
$asideA = deep_link_safe_h(deep_link_safe_build_url($selfPath, $demo, 'a', $q, $withDemo));
// ビューbリンクを作る
$asideB = deep_link_safe_h(deep_link_safe_build_url($selfPath, $demo, 'b', $q, $withDemo));
// ビューcリンクを作る
$asideC = deep_link_safe_h(deep_link_safe_build_url($selfPath, $demo, 'c', $q, $withDemo));
// 自己URLを安全化する
$selfEsc = deep_link_safe_h($selfPath);
?>
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><?= deep_link_safe_h($pageTitle) ?></title>
<style>
body{margin:0;padding:0;font-family:sans-serif;line-height:1.5;background:#fafafa}
.HEADER{padding:1rem;background:#fff;border-bottom:1px solid #ddd}
.LAYOUT{display:grid;grid-template-columns:240px 1fr;gap:1rem;padding:1rem}
.ASIDE{background:#fff;border:1px solid #ddd;border-radius:8px;padding:1rem}
.SECTION{background:#fff;border:1px solid #ddd;border-radius:8px;padding:1rem}
.NAV,.FORM__FOOT{display:flex;flex-wrap:wrap;gap:.5rem}
.BTN{display:inline-block;padding:.45rem .75rem;border:1px solid #777;border-radius:6px;text-decoration:none;color:#222;background:#fff}
.BTN.is-ok{border-color:#2e7d32;color:#2e7d32}
.FORM{display:grid;gap:.5rem}
.FORM input{padding:.5rem;border:1px solid #bbb;border-radius:6px}
.FORM-RESULT{margin-top:.75rem;padding:.75rem;border:1px dashed #bbb;border-radius:8px;background:#fcfcfc}
.FORM-RESULT.is-ok{border-color:#2e7d32;background:#f3fff4}
.CARD{margin-top:.75rem;padding:.75rem;border:1px solid #e2e2e2;border-radius:8px;background:#fff}
.HTMX-NOTE{color:#555}
.HTMX-LIST{padding-left:1.2rem}
.FOOTER{padding:1rem;text-align:center;color:#666}
@media (max-width:800px){.LAYOUT{grid-template-columns:1fr}}
</style>
<script src="/htmx/htmx.min.js" defer></script>
</head>
<body>
<header class="HEADER">
<h1>DEMO<?= deep_link_safe_h($demo) ?>: SSRを土台にboostを上乗せする</h1>
<p class="HTMX-NOTE">直リンク・リロード・非JSでも成立するよう、サーバは常にフルHTMLを返します。</p>
</header>
<div
id="APP"
class="LAYOUT"
hx-boost="true"
hx-select="#MAIN"
hx-target="#MAIN"
hx-swap="innerHTML"
>
<aside class="ASIDE">
<h2>共通サイド</h2>
<p class="HTMX-NOTE">レイアウトは固定し、<code>#MAIN</code> だけ更新します。</p>
<ul class="HTMX-LIST">
<li><a href="<?= $asideA ?>">view=a</a></li>
<li><a href="<?= $asideB ?>">view=b</a></li>
<li><a href="<?= $asideC ?>">view=c</a></li>
</ul>
<p><a class="BTN" href="<?= $selfEsc ?>">初期状態へ</a></p>
</aside>
<?= $mainHtml ?>
</div>
<footer class="FOOTER">
<p>共通フッター(SSR固定)</p>
</footer>
</body>
</html>
<?php
}
PHP(_deep_link_safe_page.php)
/htmx/demo/_deep_link_safe_page.php
<?php
// 型を厳密に扱う
declare(strict_types=1);
// 共通関数を読み込む
require_once(__DIR__ . '/_deep_link_safe_data.php');
// demo番号を取得する
$demo = deep_link_safe_request_demo($_GET);
// view番号を取得する
$view = deep_link_safe_request_view($_GET);
// 検索語を取得する
$q = deep_link_safe_request_text($_GET, 'q', '');
// フルページを出力する
deep_link_safe_emit_page('/htmx/demo/_deep_link_safe_page.php', $demo, $view, $q, true);
① hx-boostで通常リンクをPJAX化(直リンクでも破綻しない)
サーバは常にフルHTMLを返し、クライアントは hx-select="#MAIN" + hx-target="#MAIN" + hx-swap="innerHTML" で必要部分だけ反映します。
HTML(view全文)
_demo_htmx_1.php
<div class="DEMO">
<h4>DEMO1: hx-boostで通常リンクを部分遷移化(直リンクでも破綻しない)</h4>
<p class="HTMX-NOTE">
SSRで全体を返し続けながら、<code>#MAIN</code> だけを差し替えます。<br>
<a class="BTN" href="/htmx/demo/_deep_link_safe_1.php?view=a" target="_blank" rel="noopener">別タブで開く</a>
</p>
<div class="CARD">
<iframe
src="/htmx/demo/_deep_link_safe_1.php?view=a"
title="DEMO1: 直リンクでも破綻しない"
style="width:100%;min-height:620px;border:1px solid #ddd;border-radius:8px;background:#fff;"
loading="lazy"
></iframe>
</div>
</div>
PHP(_deep_link_safe_1.php)
/htmx/demo/_deep_link_safe_1.php
<?php
// 型を厳密に扱う
declare(strict_types=1);
// 共通関数を読み込む
require_once(__DIR__ . '/_deep_link_safe_data.php');
// view番号を取得する
$view = deep_link_safe_request_view($_GET);
// 検索語を取得する
$q = deep_link_safe_request_text($_GET, 'q', '');
// DEMO1ページを出力する
deep_link_safe_emit_page('/htmx/demo/_deep_link_safe_1.php', '1', $view, $q, false);
デモ
② 非JSでも最低限動く(リンク/フォーム)+JSありで部分更新
UIは普通の a / form のままです。htmxが居る場合だけboost化され、非JSでも同じURLで成立します。
HTML(view全文)
_demo_htmx_2.php
<div class="DEMO">
<h4>DEMO2: JSなしでも最低限動く(リンク/フォーム)+JSありで部分更新</h4>
<p class="HTMX-NOTE">
同じ <code>a</code> / <code>form</code> を使い、非JSなら通常遷移、JSありならboostで <code>#MAIN</code> だけ更新します。<br>
<a class="BTN" href="/htmx/demo/_deep_link_safe_2.php?view=b" target="_blank" rel="noopener">別タブで開く</a>
</p>
<div class="CARD">
<iframe
src="/htmx/demo/_deep_link_safe_2.php?view=b"
title="DEMO2: 非JSでも成立するUI"
style="width:100%;min-height:650px;border:1px solid #ddd;border-radius:8px;background:#fff;"
loading="lazy"
></iframe>
</div>
</div>
PHP(_deep_link_safe_2.php)
/htmx/demo/_deep_link_safe_2.php
<?php
// 型を厳密に扱う
declare(strict_types=1);
// 共通関数を読み込む
require_once(__DIR__ . '/_deep_link_safe_data.php');
// view番号を取得する
$view = deep_link_safe_request_view($_GET);
// 検索語を取得する
$q = deep_link_safe_request_text($_GET, 'q', '');
// DEMO2ページを出力する
deep_link_safe_emit_page('/htmx/demo/_deep_link_safe_2.php', '2', $view, $q, false);
デモ
③ hx-disinheritでboost除外リンクを局所指定する
外部リンク・ダウンロード・管理画面のフル遷移など、boostしたくない導線だけ hx-disinherit="hx-boost" で除外できます。
HTML(view全文)
_demo_htmx_3.php
<div class="DEMO">
<h4>DEMO3: hx-disinheritで「boostしたくないリンク」を除外</h4>
<p class="HTMX-NOTE">
DL・外部リンク・管理画面フル遷移を局所的に通常遷移へ戻します。<br>
<a class="BTN" href="/htmx/demo/_deep_link_safe_3.php?view=c" target="_blank" rel="noopener">別タブで開く</a>
</p>
<div class="CARD">
<iframe
src="/htmx/demo/_deep_link_safe_3.php?view=c"
title="DEMO3: hx-disinherit の局所無効化"
style="width:100%;min-height:690px;border:1px solid #ddd;border-radius:8px;background:#fff;"
loading="lazy"
></iframe>
</div>
</div>
PHP(_deep_link_safe_3.php)
/htmx/demo/_deep_link_safe_3.php
<?php
// 型を厳密に扱う
declare(strict_types=1);
// 共通関数を読み込む
require_once(__DIR__ . '/_deep_link_safe_data.php');
// view番号を取得する
$view = deep_link_safe_request_view($_GET);
// 検索語を取得する
$q = deep_link_safe_request_text($_GET, 'q', '');
// DEMO3ページを出力する
deep_link_safe_emit_page('/htmx/demo/_deep_link_safe_3.php', '3', $view, $q, false);
デモ
次に読むオススメレシピ
参考リンク
このページの著者
経験:Webアプリ/業務システム
得意:PHP・JavaScript・MySQL・CSS
個人実績:フォーム生成基盤/クイズ学習プラットフォーム 等
詳しいプロフィールはこちら! もちもちみかんのプロフィール