HTMX とは?
の記事を見ればわかるが、Javascript の知識なしで ajax通信や高度なUIの実装ができるライブラリになっています。
賛否両論はあると思いますが、自分はあまり実装がどうなっているか分からず気になったので、ソースコードを少し見てオレオレHTMXを作ってみることにしました(誰にでも分かるように簡易な実装にとどめています)。
ではまずは、ソースコードを見てみましょう!
HTMX のソースコードを読んでみる
HTMX のソースコードは、主に
の 3900行程度のコード(2024年1月27日現在)になっています。
最初からこのコードを読むのは骨が折れますが、どうやらエントリーポイントは、
// initialize the document
ready(function () {
mergeMetaConfig();
insertIndicatorStyles();
var body = getDocument().body;
++ processNode(body);
らへんになっているようです。
簡単に流れを説明すると、
-
hx-
から始まるそれっぽい attribute がある HTML を抽出 - 1で抽出した要素に Event を追加
の2段階で、HTMX は ユーザーから与えられた attributeのみを参考に Ajax通信などを実現しているようです。それぞれを説明していきます。
1 : hx-
から始まるそれっぽい attribute がある HTML を抽出
先ほどの上のソースコードの processNode
を見ると、
function processNode(elt) {
elt = resolveTarget(elt);
if (closest(elt, htmx.config.disableSelector)) {
cleanUpElement(elt)
return;
}
initNode(elt);
++ forEach(findElementsToProcess(elt), function(child) { initNode(child) });
// Because it happens second, the new way of adding onHandlers superseeds the old one
// i.e. if there are any hx-on:eventName attributes, the hx-on attribute will be ignored
forEach(findHxOnWildcardElements(elt), processHxOnWildcard);
}
findElementsToProcess
という関数で要素を取り、それぞれの見つけた DOM に initNode
を適用しているように見えます。
この findElementsToProcess
を見ると以下のようになっています。
function findElementsToProcess(elt) {
if (elt.querySelectorAll) {
var boostedSelector = ", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";
++ var results = elt.querySelectorAll(VERB_SELECTOR + boostedSelector + ", form, [type='submit'], [hx-sse], [data-hx-sse], [hx-ws]," +
++ " [data-hx-ws], [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger], [hx-on], [data-hx-on]");
return results;
} else {
return [];
}
}
ここで querySelectorAll
を使って hx-trigger
などのattribute を持った要素を取得しているのが分かります。
ここで取得した要素に、イベントが割り振られます。
2 : 1で抽出した要素に Event を追加
Eventを追加するのは上で出てきた initNode
という関数になります。
この initNode
で重要な部分は 2116・2117行目の部分でして、
var triggerSpecs = getTriggerSpecs(elt);
var hasExplicitHttpAction = processVerbs(elt, nodeData, triggerSpecs);
の部分になります。
getTriggerSpecs
で hx-trigger の情報を取得(once、delayなど含め)し、processVerbs
の中の addTriggerHandler
がイベント追加を担い、そのコールバック引数の中の issueAjaxRequest
で ajax通信(xhrを使用、古いブラウザに対応するため fetchは使っていない)を行っています。
function processVerbs(elt, nodeData, triggerSpecs) {
var explicitAction = false;
forEach(VERBS, function (verb) {
if (hasAttribute(elt,'hx-' + verb)) {
var path = getAttributeValue(elt, 'hx-' + verb);
explicitAction = true;
nodeData.path = path;
nodeData.verb = verb;
triggerSpecs.forEach(function(triggerSpec) {
++ addTriggerHandler(elt, triggerSpec, nodeData, function (elt, evt) {
++ if (closest(elt, htmx.config.disableSelector)) {
++ cleanUpElement(elt)
++ return
++ }
++ issueAjaxRequest(verb, path, elt, evt)
++ })
});
}
});
return explicitAction;
}
issueAjaxRequest は結構長くなるので、今回は触れませんが、大まかな流れは結構簡単だったのではなかったでしょうか?
ではこれを参考にオレオレHTMXを作ってみましょう!
オレオレHTMXを作ってみる
今回作る オレオレHTMX の仕様は以下のようにします(腕に自信がある方は intersect や once, delay などもっと難しい仕様を詰めてもいいかもしれませんね。)。
HTMX も 8割の要求を満たせばいいと言っているので、HTMX で一番使いそうな 6割〜8割の仕様を詰め込もうと思います。
hx-post / hx-get
tx-target (#id / this / find)
hx-swap (innerHTML / beforebegin / beforeend / afterbegin / afterend)
hx-trigger (click / load / change)
では作ってみましょう!
ここでは、上の流れと同じように、
- 要素を抽出する
- 1で抽出した各要素に、イベントを追加する
の流れで実装したいと思います。ちなみに xhr とか使うのはだるかったので、fetch とか使っています。
完成品(簡易版)はこちらです。
開発中のブランチがこちらです(outerHTML, revealedなど実装しました、一応 JSON から値を取得できるようにもしています)。
まずは、[hx-trigger]
attribute がついた 要素 を取得する関数を作ります。
function findAllTriggers(ele) {
const result = ele.querySelectorAll("[hx-trigger]");
return result;
}
これで、1つ目の「要素を抽出する」の部分は実装できました。
次に「1で抽出した各要素に、イベントを追加する」を実装してきます。
今回は、
- hx-get/hx-post
- hx-taget
- hx-swap
- hx-trigger
の4つのattributeとしか使いませんが、それぞれの attribute でどのような返り値が必要になるか考えてみます。
まず hx-get
hx-post
ですが下のように使います。
<sample hx-get="https://jsonplaceholder.typicode.com/posts/1"></sample>
ここでは、getかpostかの情報と URL が必要に見えますね。なので、hx-get
hx-post
からgetかpostかの情報と URL を取得する関数を作りましょう。
function parseMethodAttribute(ele) {
const getContent = ele.getAttribute("hx-get");
const postContent = ele.getAttribute("hx-post");
if (getContent) return ["get", getContent];
if (postContent) return ["post", postContent];
return ["get", undefined];
}
次に、hx-taget
です。
<sample hx-target="#target" hx-get="https://jsonplaceholder.typicode.com/posts/1"></sample>
ここでは、target を #id 形式でする他に、this(このノード)と find(最初に見つかったクラスを取得)を定義してみましょう。
では、この返り値は何でしょうか?
ここではイベント内での使い方を鑑みて、一旦DOMで取得してみましょう。すると以下のようなコードになります。
function parseTargetAttribute(ele) {
const targetContent = ele.getAttribute("hx-target");
if (targetContent[0] === "#") {
const id = targetContent.slice(1);
const targetEle = document.getElementById(id);
return targetEle;
} else if (targetContent === "this") {
return ele;
} else if (targetContent.slice(0, 5) === "find ") {
const className = targetContent.slice(5);
// TODO : id や selector で取得できるようにする
const targetEle = document.getElementsByClassName(className);
if (targetEle.length) return targetEle[0];
return null;
}
return ele;
}
3つ目に、hx-swap
を見てみましょう。ここでは、
<sample hx-swap="beforeend" hx-target="#target" hx-get="https://jsonplaceholder.typicode.com/posts/1"></sample>
HTML への挿入方法を指定します。
などを参考にすると、beforebegin / beforeend / afterbegin / afterend と innerHTML の方法があるみたいです。
ここで必要になる情報は、イベント内で insertAdjacentHTML
を使いますので、文字列だけで良さそうです。実装すると以下のような感じです。
function parseSwapAttribute(ele) {
const swapContent = ele.getAttribute("hx-swap");
const swapEles = ["innerHTML", "beforebegin", "beforeend", "afterbegin", "afterend"];
if (swapEles.includes(swapContent)) return swapContent;
return "innerHTML";
}
最後に、hx-trigger
ですが、これもイベントリスナーの
addEventListner("hoge", () => {})
の hoge の部分に入れるだけなので、文字列を取得するだけですね。以下のような実装になります。
function parseTriggerAttribute(ele) {
const triggerContent = ele.getAttribute("hx-trigger");
const triggerEles = ["load", "click", "change"];
if (triggerEles.includes(triggerContent)) return triggerContent;
return "load";
}
そして、これらの hx-get/hx-post, hx-taget, hx-swap, hx-trigger の返り値から、イベントを作っていきます。
ここの流れは、
- イベントを加える要素に addEventListener("hoge") を定義する
- 1のコールバック引数に、url から fetch するコードを書く
- 2の結果を innerHTML か insertAdjacentHTML するかで分岐する
のような実装の流れになります。
それを実装したのが以下のコードになります。
function generateEvent(ele, target, method, trigger, swap) {
ele.addEventListener(trigger, async() => {
if (!method[1]) return;
const result = await fetch(method[1], {method: method[0]});
const json = await result.json();
const stringifyJson = JSON.stringify(json);
if (swap === "innerHTML") {
target.innerHTML = stringifyJson;
} else {
target.insertAdjacentHTML(swap, stringifyJson);
}
});
if (trigger === "load") {
ele.dispatchEvent(new Event("load"));
}
}
最後の部分で load の場合にみ手動で loadイベントを発火させている部分以外は結構簡単な実装だと思います。
そして最後に、DOMContentLoaded の段階で上のような、要素にイベントをつける動きを追加するようにします。
document.addEventListener("DOMContentLoaded", () => {
const allEles = findAllTriggers(document.body);
allEles.forEach((ele) => {
const target = parseTargetAttribute(ele);
const method = parseMethodAttribute(ele);
const trigger = parseTriggerAttribute(ele);
const swap = parseSwapAttribute(ele);
generateEvent(ele, target, method, trigger, swap);
});
});
HTMX も結構凄そうに見えても、オレオレであれば中身は結構簡単に作れたのではないでしょうか?
感想
HTMX は React とかを代替する、と言われかねない勢いな気がするのですが、中身を見ると、入力文字のシリアライズや一度限りのDOMのイベント追加(もしかしたら違うかもしれませんが WS とか SSE はあまり分からなかったので)をしているに過ぎず、仮想DOM でダブルバッファリングして Fiberを 差分検知するアルゴリズムの綺麗さには及ばないと思いました(もちろん人それぞれ意見は違うかもしれませんが)。
何はともあれ、これを機に、オレオレHTMXを作る人が増えればいいなと思い末筆にしたいと思います!
Happy Hacking !