
2026/07/31 14:16
史上最悪の Htmx を作ろう
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
記事は、人気のある Web フレームワークの小型な「クローン」を構築するためのシリーズを継続しており、本日は htmx というライブラリに焦点を当てています。htm x はバックエンド開発者が最小限の JavaScript でインタラクティブなアプリを構築することを可能にするライブラリです。当初は Intercooler から進化したものであり、宣言的な属性(例:
hx-get、hx-post)を使用して、イベントと DOM 更新を HTML に直接接続します。このプロジェクトでは、fetch() API などの標準的なブラウザ機能のみを使用して htmx の軽量なオープンソース JavaScript クローンを実装しており、10 行で核心的な挙動を再現し、宣言的な AJAX リクエストや DOM スワップといった複雑な機能をサポートしています。実装では、基本的なクローンを強化するために、サニタイズ化、ユーザー指定のターゲット、各種のスワップモード(例:outerHTML、none)、複数の HTTP メソッドおよびトリガーを処理する堅牢な send() 関数を含んでいます。また、X-Request などのカスタムヘッダーをサポートし、イベントリスナーを通じてロジックをバインドすることを実現しており、「changed」や「once」などの修飾子付きの強化されたトリガー構文を実装しています。重要なのは、クローンがサーバー側ディレクティブ(例:HX-Trigger、HX-Redirect)を完全にサポートし、リクエストコンテキスト管理のためにカスタムイベントを発行することです。現在の実装は「SSS」API(scan、send、swap)を維持しており、バリデーションや SSE などの機能に対してプラグインをサポートしていますが、限定的な点として、失敗したリクエストに対するエラーハンドリングの改善、Promise ベースのキャンセル、モダンなビュー遷移の実装が必要です。完全なコードは https://github.com/zserge/x で入手でき、記事は 2026 年 7 月 27 日に公開されました。本文
htmx のミニチュア版構築:宣言的記述からプラグインアーキテクチャまで
はじめに
本稿では、人気 Web フレームワークの「ミニチュア版」を構築する一連の記事の続編として、htmx に焦点を当てます。JavaScript 関心を伴わないバックエンド開発者向けのこのライブラリは、フロントエンドの複雑さを裏側で統合し、HTML テンプレートだけで強力なアプリケーションを構築可能にします。
htmx の基本動作
htmx はイベント、AJAX リクエスト、DOM 更新といった処理を隠蔽し、以下のような宣言的な記述で動作します。
<button hx-post="/clicked" hx-trigger="click" hx-target="#parent-div" hx-swap="outerHTML"> Click Me! </button>
動作の概要:
- ボタンクリック時に
へ POST リクエスト を送信/clicked - レスポンスを受け取り、
要素をレスポンスの内容で置換(スワップ)#parent-div
これにより、JavaScript コードなしに動的な UI を実現できます。
コア機能の実装:メソッド、トリガー、ターゲット、スワップ
htmx は
fetch() で URL を叩き、得られたコンテンツを指定した要素へ更新します。以下はこれを JavaScript で単純化して実装する例です。
単純なフェッチャ
まずは最も基本的な挙動(40 行程度)を実装します。
const attr = (el, name) => el.closest(`[${name}]`)?.getAttribute(name); // スワップモードの定義 const SWAP = { outerHTML: (t, f) => t.replaceWith(f), beforebegin: (t, f) => t.before(f), afterbegin: (t, f) => t.prepend(f), beforeend: (t, f) => t.append(f), afterend: (t, f) => t.after(f), delete: t => t.remove(), none: () => {}, }; // 共通のスワップ処理 const swap = (mode, target, html) => { const tpl = document.createElement('template'); tpl.innerHTML = html; // モードに応じた動作実行 (SWAP[mode] || (() => t.replaceChildren(f)))(target, tpl.content); }; // メインの送信処理 const send = async (el, method, url) => { const sel = attr(el, 'x-target'); // ターゲットが指定されていない場合は要素そのものをターゲットとする const target = sel ? document.querySelector(sel) : el; const mode = attr(el, 'x-swap') || 'innerHTML'; const opts = { method: method.toUpperCase(), headers: { 'X-Request': 'true' } }; if (el.matches('form')) opts.body = new URLSearchParams(new FormData(el)); const res = await fetch(url, opts); // スワップ実行 swap(mode, target, await res.text()); }; // イベントリスナーの追加(トリガーはクリックをデフォルトとする) const scan = (root = document.body) => ['get', 'post', 'put', 'patch', 'delete'].forEach(m => root.querySelectorAll(`[x-${m}]`).forEach(el => { if (el.$hx) return; // 既対応スキップ el.$hx = true; const evt = attr(el, 'x-trigger') || 'click'; el.addEventListener(evt, e => { e.preventDefault(); send(el, m, el.getAttribute(`hx-${m}`)); }); }) ); scan();
パフォーマンス改善:DOM 再スキャンの最適化
各スワップ後に全体をスキャンするのは非効率です。新たに追加されたコンテンツのみを対象とした MutationObserver を使用します。
new MutationObserver(ms => { for (const m of ms) m.addedNodes.forEach(n => { if (n.nodeType === 1) scan(n); }); }).observe(document.body, { childList: true, subtree: true });
高度な機能:トリガー、ターゲット、イベントの拡張
より優れたトリガー(Trigger)
カンマ区切りのリストやオプションを柔軟に扱えるようにします。
const parseTriggers = (s) => { const triggers = s.split(',').map(s => s.trim()); return triggers.map(trigger => { const [event, ...rest] = trigger.split(' '); const options = {}; rest.forEach(opt => { const [key, value = true] = opt.split(':'); options[key] = value; }); return { event, options }; }); }
使用例:
x-trigger="load, click one, change changed delay:500"
より優れたターゲット(Target)
querySelector だけでなく、親要素や兄弟要素への相対的参照をサポートします。
const resolve = (el, sel) => { if (!sel) return el; // 特殊なキーワード対応 if (sel === 'this') return el; if (sel === 'next') return el.nextElementSibling; if (sel === 'previous') return el.previousElementSibling; if (sel === 'document') return document; // CSS セレクターによる検索 if (sel.startsWith('closest ')) return el.closest(sel.slice(8)); if (sel.startsWith('find ')) return el.querySelector(sel.slice(5)); return document.querySelector(sel); };
より優れたイベント(Event)
リクエスト・スワップフローを制御するための 4 つのカスタムイベント を発火します。
: リクエスト前の処理x:beforeRequest
: リクエスト後の処理x:afterRequest
: スワップ前の処理x:beforeSwap
: スワップ後の処理x:afterSwap
これらに加え、サーバー側から届くヘッダーに基づいて動作を変更します。
: カスタムイベントを発火HX-Trigger
: ブラウザのリダイレクトHX-Redirect
: ページ更新HX-Refresh
: 動的なターゲット変更HX-Retarget
: 動的なスワップモード変更HX-Reswap
以下は、これらの機能を統合した最終的な
send() 関数の一部です。
const send = async (el, method, url) => { let target = resolve(el, attr(el, "x-target")); let mode = attr(el, "x-swap") || "innerHTML"; const opts = { method: method.toUpperCase(), headers: { "HX-Request": "true" }, }; // フォーム送信時の処理 if (el.matches("form")) opts.body = new URLSearchParams(new FormData(el)); // 送信前イベントフック if (!fire(el, "x:beforeSend", { el, url, target, mode, opts }, true)) return; const response = await fetch(url, opts); // サーバーからの HX ヘッダー処理 const hdrTrigger = response.headers.get("HX-Trigger"); if (hdrTrigger) { try { const data = JSON.parse(hdrTrigger); Object.entries(data).forEach(([ev, d]) => fire(document.body, ev, d)); } catch { fire(document.body, hdrTrigger); } } if (response.headers.get("HX-Redirect")) { window.location.href = response.headers.get("HX-Redirect"); return; } if (response.headers.get("HX-Refresh") === "true") { window.location.reload(); return; } // HX-Retarget と HX-Reswap の適用 if (response.headers.get("HX-Retarget")) target = resolve(el, response.headers.get("HX-Retarget")); if (response.headers.get("HX-Reswap")) mode = response.headers.get("HX-Reswap"); const html = await response.text(); // イベントフック(送信完了) fire(el, "x:afterSend", { el, url, opts, target, mode, response, html }); const detail = { el, url, target, mode, html, response }; // スワップ前イベントフック if (!fire(el, "x:beforeSwap", detail, true)) return; swap(detail.mode, detail.target, detail.html); // スワップ完了イベントフック fire(el, "x:afterSwap", detail); // 追加された要素へのリスナー再登録(SSS の一部) scan(detail.target); };
プラグインアーキテクチャ:コア機能の拡張
htmx は当初は単純な「送信+置換」でしたが、現在は多数の高度な属性がサポートされています。当実装でも SSS(スキャン+送信+置換) の核心を変更せず、プラグインで機能付与を目指します。
例:ブースティング(Boosting)
リンクやフォームを AJAX 化するための「ブースティング」機能は、コアではなく JS スニペットで容易に実装可能です。
document.addEventListener('click', e => { const boosted = e.target.closest('[x-boost]'); if (!boosted) return; const link = e.target.closest('a'); if (!link || !link.getAttribute('href') || link.getAttribute('target')) return; e.preventDefault(); window.x.send(boosted, 'get', link.getAttribute('href')); });
追加プラグインのアイデア
以下のような機能は、数行のコードとイベントフック(主に
x:beforeSend や x:afterSwap)で実装可能です。
: 送信前に確認ダイアログを表示(キャンセル時は処理を中止)。x-confirm
: ローディングスピナーを表示・非表示をトグル。x-indicator
: 送信中の要素を無効化し、重複送信を防ぐ。x-disable
: JSON 属性からリクエストヘッダーを追加。x-headers
/x-vals
: リクエストボディに値を追加。x-include
: レスポンスの一部だけを置換(バックエンドの簡略化)。x-select
: 並列実行中のリクエストを制御(中止・置換・キュー)。x-sync
: ブラウザのネイティブバリデーションを実行。x-validate
/x-push-url
: ブラウザ履歴を更新。x-replace-url
/x-sse
: サーバー送信用ストリーミングメッセージに対応。x-ws
今後の課題と互換性への道
完全に htmx と互換性を持たせるには、以下のような拡張が必要です。
- エラー処理:
の失敗や非 2xx レスポンスの適切なハンドリング。fetch() - イベントの増加: より多くのカスタムイベントを発火させる。
- 共通インターフェース: プラグイン間で共有する
などの関数の公開。resolve() - キャンセル機能: Promise ベースの非同期操作(例:確認ダイアログ、ビュー遷移)への対応。
これらの実装は読者の課題となります。あるいは、すでに完成した htmx をそのまま利用するのが最適かもしれません。
まとめとリソース
今回の実験で構築した全コードは、以下の GitHub リポジトリに公開されています。バグ報告やコントリビュートをご希望の方はぜひご協力ください。
- ソースコード: https://github.com/zserge/x
本記事のシリーズ(「Let's make the worst VueJS ever!」など)もご覧ください。