Web開発
最悪のHtmxを作ろう
Let's make the worst Htmx (zserge.com)
要約
この記事は、人気のあるWebフレームワークのミニチュア版を構築するシリーズの一環として、HTMXの機能を模倣したシンプルなJavaScriptライブラリの作成について解説しています。HTMXの基本的な概念であるイベントトリガー、AJAXリクエスト、DOM更新(スワップ)を、少ないコード量で実現する方法を示しています。
全文翻訳
最悪のhtmxを作ろう!
これは、人気のあるWebフレームワークの小さな「クローン」を構築する一連の投稿の続きです。
Webは急速に変化しており、Webアプリを構築するために使用するツールも同様です(それでも、私のReact/Vueに関する投稿が7年前になり、それらのフレームワークが依然として人気があり関連性があることを心から嬉しく思います)。
今日、ホットなトピックはhtmxです。基本的に、JavaScriptには全く関心のないバックエンド開発者のためのフロントエンドライブラリです。HTMXはintercoolerとして始まり、その後進化し、より多くの機能が追加され、その中には美しさもあります。
<button hx-post="/clicked" hx-trigger="click" hx-target="#parent-div" hx-swap="outerHTML"> Click Me! </button>
宣言的に、これは次のように述べています:ボタンがクリックされたら、/clickedにPOSTリクエストを送信し、idが#parent-divの要素のouterHTMLをレスポンスデータで置き換えます。HTMXはイベント、AJAXリクエスト、DOM更新を連携させ、フロントエンドの複雑さを隠蔽します。バックエンドがHTMLテンプレートを送信できる限り、JavaScriptを一行も書かずに、かなりの範囲で進歩し、完全なアプリを構築できます。
メソッド、トリガー、ターゲット、スワップ
上記の例を見ると、私たちのライブラリは、いくつかのトリガーに基づいてURLを取得し(fetch)、コンテンツをいくつかのターゲット要素に更新(スワップ)する必要があるようです。このような感じです。
<button x-get="/click">Click me</button>
document.querySelectorAll('[x-get]').forEach(el => {
el.addEventListener('click', async (e) => {
e.preventDefault();
const url = el.getAttribute('x-get');
const response = await fetch(url);
const data = await response.text();
el.outerHTML = data;
console.log(data);
});
});
もし私たちのバックエンドが「Button clicked」をレスポンスとして送信するなら、これは機能するはずです。ページを開き、ボタンをクリックすると、ボタンが消え、代わりにテキストが表示されます。これが10行のコードでのHTMXです。
ここで改善できる点がいくつかあります。まず、レスポンスがHTMLであることを確認するために、レスポンスをサニタイズするのは良いことです。次に、ユーザーがコンテンツをスワップするターゲット要素を指定できるようにする必要があります。第三に、ターゲット内のコンテンツを正確にどのようにスワップするかを指定できるようにする必要があります:置換、追加、挿入、要素の削除などです。
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, f) => 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());
};
デフォルトでは、x-targetまたはx-swapが指定されていない限り、要素のコンテンツを更新します。また、HTMXの慣習に従い、リクエストと共にカスタムヘッダーをバックエンドに送信します。
これらすべてをまとめるために、トリガーに基づいてリクエストをディスパッチする簡単なイベントリスナーを追加します。
const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
const defaultTrigger = el => el.matches('form') ? 'submit' : el.matches('input,select,textarea') ? 'change' : 'click';
const scan = (root = document.body) => METHODS.forEach(m => root.querySelectorAll(`[x-${m}]`).forEach(el => {
if (el.$hx) return;
el.$hx = true;
const evt = attr(el, 'x-trigger') || defaultTrigger(el);
el.addEventListener(evt, e => {
e.preventDefault();
send(el, m, el.getAttribute(`hx-${m}`));
});
}));
scan(); // swap() の後に scan() も呼び出す必要があります
そしてこれで、すべてのHTTPメソッド、カスタムターゲット、スワップ戦略をサポートする、動作するHTMXクローンが40行のコードで完成しました。小さな最適化として、各スワップ後にDOM全体を再スキャンするのを避け、新しく追加されたコンテンツのみをスキャンすることが考えられます。
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 });
これで、小さくパフォーマンスも向上しました。
より良いトリガー
カンマ区切りのイベントリスト、オプションの遅延、「changed」または「once」修飾子などを許可するカスタム構文を導入できます。
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 };
});
}
これは、オプション(HTML属性でコロンで区切られたキー/値)を持つイベントのリストを返します。これで、x-trigger="load, click one, change changed delay:500" のようなものを書くことができます。また、scan() 関数でいくつかのイベントを特別に処理する必要があります。たとえば、loadは基本的にsetTimeout(() => send(...), 0) であり、changedは前の値をキャッシュして値が変わらない場合は送信をスキップし、onceは最初のトリガー後にイベントリスナーを削除し、delayは指定された遅延を持つ別のsetTimeoutです。あなたの想像力が限界です。HTMXが行ったように、ビューポートインタラクショントリガー、キューイング、デバウンス、周期的なイベントなどをサポートするまで進むことができます。しかし、私たちは悪いHTMXクローンを作っています。
より良いターゲット
これまでのところ、コンテンツをスワップする要素を見つけるために単純な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;
if (sel === 'body') return document.body;
if (sel === 'window') return window;
if (sel.startsWith('closest ')) return el.closest(sel.slice(8));
if (sel.startsWith('find ')) return el.querySelector(sel.slice(5));
return document.querySelector(sel);
};
send()内でdocument.querySelector(sel)の代わりにこれを呼び出すと、x-target="closest .container" や x-target="next" のように、グローバルクエリよりも具体的な要素をターゲットにできるようになります。HTMXも実際にはほぼ同じことをしています。コードを調べる場合は、そのようにしてください。
より良いイベント
HTMXはhtmx:beforeRequestのようなカスタムイベントを発行し、開発者がそれをインターセプトし、リクエストフローを変更および制御できるようにします。私たちも同様のことを行うことができます。合計で4つのイベントを発行します:beforeRequest、afterRequest、beforeSwap、afterSwap。各イベントは、現在のsend()の完全なコンテキストと共に、リクエストをトリガーした要素で発行されます。イベントはevent.preventDefault()を呼び出すことでキャンセルできます。
サーバーサイドイベント、たとえばHX-TriggerやHX-Redirectもサポートしてみましょう。サーバーがHX-Triggerヘッダーを送信した場合、そのヘッダーの内容を持つカスタムイベントを発行します。サーバーがHX-Redirectを送信した場合、単にブラウザをそのURLにリダイレクトします。HXプレフィックスは、X-...ヘッダーが標準HTTPヘッダーの一般的な規約であるため、ここに保持しました。
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);
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-Redi