JavaScriptなしで動的なページ?人気急上昇中のHTMXの基礎を学ぶ
JavaScriptを利用することなく動的なページを簡単に作成したいなと思ったことはありませんか?そんな時に利用できるのが現在人気急上昇中のHTMXです。HTMXは開発者がJavaScriptを利用することなくサーバからHTMLを受け取りページ上に動的に受け取ったHTMLを追加することができます。本文書では実際にHTMXを利用しながらHTMXではどのようなことができるのかの理解を深めていきます。
目次
HTMXとは
HTMXはUIライブラリでHTMX自体はJavaScriptで記述されていますが開発者はJavaScriptのコードを記述することなくHTMLのマークアップにHTMXが提供する属性を追加することでAjaxを利用してサーバと通信を行うことができます。複雑なJavaScriptのコードを記述することなくシンプルな方法で動的なWEBページを作成したい開発者に最適な技術です。
シンプルな例としてはHTMXを利用することで次のようなことが可能です。button要素にHTMXが提供する属性を設定します。属性を設定したボタンをクリックするとサーバにajaxリクエストが送信されサーバから戻されるHTMLを受け取りページを再読み込みすることなく動的にページ上に表示させることができます。開発者が行うのはHTMLタグへのHTMXの属性の設定で、サーバとの通信などはすべてHTMXが自動で行ってくれます。
はじめてのHTMX
HTMXではリクエストを送信するとHTMLを戻すサーバを準備する必要がありますがまずJSONデータを戻すサーバを利用して動作確認を行います。
後ほどNode.jsのExpressを利用してサーバを構築することに備えディレクトリを作成してその中にindex.htmlファイルを作成しています。ここではhtmx_first_timeというディレクトリを作成して以下のコードをindex.htmlファイルに記述します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script
src="https://unpkg.com/htmx.org@1.9.10"
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
crossorigin="anonymous"
></script>
</head>
<body>
<h1>HTMX</h1>
<button hx-get="https://jsonplaceholder.typicode.com/users/1">Click</button>
</body>
</html>
コードではscriptタグでHTMXライブラリをCDNを通してダウンロードしています。button要素にHTMXが提供するhx-get属性を追加してサーバのURLを指定しています。hx-get属性を設定することでボタンをクリックするとhx-getで指定したURLにGETリクエストを送信します。URLには無料のサービスJSONPlaceHolderを設定しており指定したURLにGETリクエストを送信するとJSONでユーザ情報が戻されます。ブラウザからでアクセスしてもどのようなJSONデータが戻されるかも確認することができます。
index.htmlファイルをブラウザで確認するとh1タグのHTMXの文字列とClickボタンが表示されます。
Clickボタンをクリックしてみましょう。
CSSの設定もしていないのに戻されたJSONデータが色付きの四角で囲まれているので見た瞬間ははぜこのような表示になっているのか不思議に思うかもしれませんがhx-getのURLから戻されたデータがそのままbutton要素のコンテンツに置き換わっているためです。ボタン要素なので表示されている文字列をクリックすることができます。
デベロッパーツールのネットワークタブを確認するとJSONPlaceHolderにAJAXリクエストが送信されステータス200で戻されていることがわかります。
buttonタグにHTMXが提供する属性を設定するだけで簡単にサーバから戻されたデータをブラウザ上に表示できることがわかりました。
hx-swap属性
hx-get属性のみ利用した場合は取得したデータがbutton要素の中に挿入されます。hx-swap属性を追加してhx-swap属性に”outerHTML”を指定することでbutton要素の外側の要素に取得したデータが表示されます。
<div>
<button
hx-get="https://jsonplaceholder.typicode.com/users/1"
hx-swap="outerHTML"
>
Click
</button>
</div>
hx-swap=”outerHTML”を設定することでbutton要素の親要素であるdiv要素に取得したデータ表示されbutton要素は消えます。swapには交換、取り替えといった意味があるのでその通りの動作になっています。
デフォルトではhx-swapはinnerHTMLに設定されているのでbuttun要素の中に挿入されます。その他のhx-swap属性に設定できる値についてはドキュメントに記載されています。設定する値を変更することで取得したデータをどこに表示させるのかを指定することができます。後ほど動作確認を行いますがリストのli要素の下に新たに要素を追加したい場合などに”beforeend”の値を設定することができます。
hx-target属性
hx-swapを利用して取得したデータを表示する場所を設定することができましたがhx-target属性を利用することもできます。
hx-target属性を利用することで指定した場所に取得したデータを表示させることができます。
<h1>HTMX</h1>
<button
hx-get="https://jsonplaceholder.typicode.com/users/1"
hx-target="#h2"
>
Click
</button>
<h2 id="h2">ここに表示</h2>
ボタンのクリック前にはh2タグで設定した”ここに表示”が表示されます。
ボタンをクリックするとデフォルトのhx-swap=”innerText”によりhx-targetに指定したh2の中身が取得したデータに置き換わります。
hx-swapの”beforeend”を利用することでサーバから取得したデータで要素の中の文字列を置き換えるのではなく追加していくことも可能です。ボタンをクリックする度に取得したデータが追加されるので文字列が長くなります。
<button
hx-get="https://jsonplaceholder.typicode.com/users/1"
hx-target="h2"
hx-swap="beforeend"
>
Click
</button>
hx-targetに”this”を設定することでhx-targetを設定した要素がtargetとなります。
<button
hx-get="https://jsonplaceholder.typicode.com/users/1"
hx-target="this"
>
Click
</button>
バックエンドサーバの設定
HTMXの属性を利用してリクエストが送信されてくるとサーバ側ではHTMLを戻すだけでいいという説明をしました。実際にバックエンドサーバを利用して動作確認していきます。
本文書ではNode.jsのExpressを利用しますがサーバはなんでも構いません。HTMXを利用する場合は、使いなれた好みのフレームワーク/言語でバックエンドサーバを構築してください。
Expressサーバの設定
作成済みのhtmx_first_timeディレクトリの中でnpm init -yコマンドを実行します。実行するとpackage.jsonファイルが作成されます。
% npm init -y
Wrote to /Users/mac/htmx_first_time/package.json:
{
"name": "htmx_first_time",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
npm installコマンドでexpressのインストールを行います。
% npm install express
index.jsファイルを作成して以下のコードを記述します。
const express = require('express');
const app = express();
const port = 3000;
app.get('/', (req, res) => res.send('Hello World!'));
app.listen(port, () => console.log(`Example app listening on port ${port}!`));
node index.jsでExpressサーバを起動します。
% node index.js
Example app listening on port 3000!
起動後http://localhost:3000にブラウザからアクセスを行い、”Hello World”が表示されればExpressサーバは正常に起動しています。
index.jsファイルの変更の検知を行い、再読み込みしてくれるnodemonのインストールを行います。
% npm install --save-dev nodemon
インストール後にpackage.jsonのscriptsを設定します。
{
"name": "htmx_first_time",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "nodemon index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.18.2"
},
"devDependencies": {
"nodemon": "^3.0.3"
}
}
npm run devコマンドを実行してExpressサーバを起動します。
% npm run dev
> htmx_first_time@1.0.0 dev
> nodemon index.js
[nodemon] 3.0.3
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,cjs,json
[nodemon] starting `node index.js`
Example app listening on port 3000!
http://localhost:3000にアクセスすると先ほど前利用していたindex.htmlファイルの内容が表示されるようにindex.jsファイルを更新します。
const express = require('express');
const app = express();
const port = 3000;
app.use(express.static('public'));
app.listen(port, () => console.log(`Example app listening on port ${port}!`));
publciディレクトリを作成してindex.htmlをpublicディレクトリに下に移動します。index.htmlファイルには以下のコードが記述されています。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script
src="https://unpkg.com/htmx.org@1.9.10"
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
crossorigin="anonymous"
></script>
</head>
<body>
<h1>HTMX</h1>
<button
hx-get="https://jsonplaceholder.typicode.com/users/1"
hx-target="#h2"
>
Click
</button>
<h2 id="h2">ここに表示</h2>
</body>
</html>
ブラウザからhttp://localhost:3000にアクセスすると以下の画面が表示されます。
HTMXの動作確認
Expressを利用したバックエンドサーバの設定も完了したのでバックエンドサーバを利用してHTMXの動作確認を行っていきます。
HTMLを戻す
Expressに新たなルーティングを追加してルーティングにアクセスがあった場合にHTMLを戻すように設定します。spanタグにstyle属性で文字の色を設定します。
const express = require('express');
const app = express();
const port = 3000;
app.use(express.static('public'));
app.get('/greeting', (req, res) =>
res.send('<span style="color:gray">Hello HTMX!</span>')
);
app.listen(port, () => console.log(`Example app listening on port ${port}!`));
index.htmlではhx-getで設定したURLを”/greeting”に変更します。
<body>
<h1>HTMX</h1>
<button hx-get="/greeting" hx-target="#h2">Click</button>
<h2 id="h2">ここに表示</h2>
</body>
http://localhost:3000にアクセスして動作確認を行います。
ボタンをクリックすると戻されたHTMLが表示されます。設定したstyle属性も反映されていることが確認できます。
button要素にhx-swap属性を追加したouterHTMLを設定します。
<button hx-get="/greeting" hx-target="#h2" hx-swap="outerHTML">
バックエンドのサーバに戻されるHTMLタグがspanタグなのでClickボタンの横に表示されます。
サーバから戻されるレスポンスもHTMLであることを確認しておきます。
Loadingの表示
サーバにリクエストを送信してデータが戻されるまでにLoading Indicatorを表示させたい場合もHTMXでは簡単に行うことができます。
サーバ側でリクエストを受け取ってから戻すまでに2秒待機できるようにPromiseを利用して設定します。
app.get('/greeting', async (req, res) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
res.send('<span style="color:gray">Hello HTMX!</span>');
});
loading Indicatiorを表示させるspanタグを追加してclassにはhtmx-indicatorを追加してidを設定します。classにhtmx-indicatorは設定するのは必須です。このclassを利用してHTMXで表示・非表示を切り替えています。
<body>
<h1>HTMX</h1>
<button hx-get="/greeting" hx-target="#h2" hx-indicator="#indicator">
Click
</button>
<span class="htmx-indicator" id="indicator">Loading...</span>
<h2 id="h2">ここに表示</h2>
</body>
buttonタグにhx-indicator属性を追加して値にはLoading Indicatorのidを設定します。リクエスト中には”Loading…”の文字列が表示されるように設定を行っています。
“Click”ボタンを押すと”Loading…”の文字が表示されます。リクエストが完了すると”Loading…”の文字は消えます。
ブラウザのデベロッパーツールを利用して要素のCSSを確認するとリクエストが行われていない場合はopacity:0が設定されます。
.htmx-indicator {
opacity: 0;
}
リクエスト中には以下のclassが適用されていることが確認できます。
.htmx-request.htmx-indicator {
opacity: 1;
transition: opacity 200ms ease-in;
}
hx-trigger
ボタンをクリックするとリクエストが送信されていましたがリクエストを送信するイベントはhx-triggerによって設定することができます。
ボタンにマウスが乗った時にリクエストを送信したい場合にはhx-trigger属性にmouseoverを設定することで実現できます。
<button
hx-get="/greeting"
hx-target="#h2"
hx-indicator="#indicator"
hx-trigger="mouseover"
>
Click
</button>
ユーザ一覧の表示
バックエンドのサーバからJSONPlaceHolderにアクセスを行い、取得したJSONデータをサーバ上でHTMLに変換して戻すことでブラウザ上にユーザ一覧を表示させることができます。
HTML側ではhx-getによるリクエストの送信先とhx-targetの変更を行います。
<body>
<h1>HTMX</h1>
<button
hx-get="/users"
hx-target="#users"
hx-indicator="#indicator"
hx-swap="innerHTML"
>
Click
</button>
<span class="htmx-indicator" id="indicator">Loading...</span>
<h2>ユーザ一覧</h2>
<ul id="users"></ul>
</body>
サーバ側ではfetch関数でJSONPlaceHolderにアクセスして取得したデータをHTMLに変換して戻します。
app.get('/users', async (req, res) => {
const response = await fetch('https://jsonplaceholder.typicode.com/users');
const users = await response.json();
const html = `${users.map((user) => `<li>${user.name}</li>`).join('')}`;
return res.send(html);
});
ブラウザ上で”Click”ボタンをクリックするとユーザ一覧が表示されます。
ページを開いた直後にユーザ一覧を表示したい場合にはhx-trigger属性の値をloadにすることで実現できます。
<button
hx-get="/users"
hx-target="#users"
hx-indicator="#indicator"
hx-swap="innerHTML"
hx-trigger="load"
>
Click
</button>
hx-post属性
これまではhx-get属性を利用してサーバからデータを取得していましたがhx-post属性を利用してサーバにデータを送信する方法を確認します。
<body>
<h1>HTMX</h1>
<form
hx-post="/users/create"
hx-trigger="submit"
hx-target="#users"
hx-swap="beforeend"
>
<input type="text" name="name" />
<button type="submit">Add</button>
</form>
<span class="htmx-indicator" id="indicator">Loading...</span>
<h2
hx-get="/users"
hx-target="#users"
hx-indicator="#indicator"
hx-swap="innerHTML"
hx-trigger="load"
>
ユーザ一覧
</h2>
<ul id="users"></ul>
</body>
formタグにhx-post, hx-trigger, hx-target, hx-swap属性を設定します。hx-swap属性で設定しているbeforeendは表示されているユーザ一覧の最後の要素の下にサーバから戻されるデータを追加します。
hx-trigger属性でsubmitイベントが発火されたらhx-posts属性に指定したURLの/users/createにPOSTリクエストが送信されます。
バックエンドサーバ側に/users/createのルーティングを追加します。送信されてきたデータを取得できるようにapp.use(express.urlencoded({ extended: true }))を追加します。送信されてきたデータはreq.body.nameに含まれています。
//略
app.use(express.urlencoded({ extended: true }));
//略
app.post('/users/create', (req, res) => res.send(`<li>${req.body.name}</li>`));
//略
送信されてきたデータは通常データベースなどに保存しますがここではそのままHTMLと一緒に戻しています。
ブラウザ上で実行するとユーザ一覧の一番下にinput要素に入力した名前が追加されます。
hx-swap-oob属性
フォームに名前を入力して一覧に追加することができましたがinput要素に入力した文字列はそのままフォームに残った状態です。入力した文字列をリセットするためにhx-swap-oob属性を利用します。
これまでの動作確認ではサーバから戻されたHTMLは1つの場所のみの更新に利用されました。hx-swap-oob属性を利用することで戻したHTMLを複数の箇所(target)に対して更新を行うことができます。1つのHTMLを複数の箇所の更新に利用するのではなく、HTMLを構成する要素毎に更新する場所を指定することができます。oobはOut of boundの略です。
index.html側でinput要素を識別できるようにidを設定します。
<input type="text" name="name" id="name" />
サーバ側から戻すHTMLを更新します。liタグに追加し、新たにindex.htmlのinputタグを置き換えるinputタグを追加します。追加したinputタグにはhx-swap-oob属性を追加し値はtrueを設定します。idを設定することでどの要素に対応したHTMLかを識別しています。idを設定していない場合は置き換える要素が特定できないのでリセットされません。hx-swap-oob属性を指定していない要素はhx-targetで指定した場所で更新が行われます。
app.post('/users/create', (req, res) =>
res.send(
`<li>${req.body.name}</li><input type="text" name="name" id="name" hx-swap-oob="true" />`
)
);
設定後はフォームに名前を入力して”Add”ボタンをクリックすると入力した文字列がリストの最後に挿入され、input要素に入力した値はリセットされます。
hx-swap-oob属性を利用すると複数の箇所(target)を更新できるのでさらにメッセージを表示できるように更新します。index.htmlファイルにidにmessageを持つdiv要素を追加しています。
<body>
<h1>HTMX</h1>
<form
hx-post="/users/create"
hx-trigger="submit"
hx-target="#users"
hx-swap="beforeend"
>
<input type="text" name="name" id="name" />
<button type="submit">Add</button>
</form>
<span class="htmx-indicator" id="indicator">Loading...</span>
<div id="message"></div>
<h2
hx-get="/users"
hx-target="#users"
hx-indicator="#indicator"
hx-swap="innerHTML"
hx-trigger="load"
>
ユーザ一覧
</h2>
<ul id="users"></ul>
</body>
サーバから戻すHTMLも更新します。hx-swap-oob属性を持つdiv要素を追加しています。
app.post('/users/create', (req, res) =>
res.send(
`<li>${req.body.name}</li><input type="text" name="name" id="name" hx-swap-oob="true" /><div hx-swap-oob="true" id="message" style="color:blue;">ユーザ追加しました。</div>`
)
);
動作確認を行うためにフォームに名前を入力後に”add”ボタンをクリックするとユーザ一覧には入力した名前が追加表示され、フォームはリセット、メッセージも表示されることが確認できます。
Alpine.jsの利用
Alpine.jsはドロップダウンメニューなどクライアントサイドに動的な機能を追加したい場合に利用されるJavaScriptのフレームワークです。HTMXと一緒に利用することでよりインタラクティブなアプリケーションを構築することができます。
ページ内でAlpine.jsが利用できるようにheadタグの中にscriptタグを追加します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script
src="https://unpkg.com/htmx.org@1.9.10"
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
crossorigin="anonymous"
></script>
<script src="//unpkg.com/alpinejs" defer></script> //追加
</head>
//略
flash message
ユーザを追加した際にメッセージを表示させました。表示させたメッセージを表示させ続けるのではなくしばらく時間が経過すると消去される機能(Flash message)をAlpine.jsを利用して追加します。
x-dataディレクティブで変数showを定義してtrueを設定します。x-showディレクティブではbool値により要素の表示・非表示を切り替えることができます。表示した直後はshowの値はtrueなので表示されます。x-initディレクティブは初期化を行う際に実行するコードを記述することができるので表示直後にsetTimeoutを利用して2秒後にshowの値がfalseになるように設定しています。
app.post('/users/create', (req, res) =>
res.send(
`<li>${req.body.name}</li>
<input id="name" type="text" name="name" hx-swap-oob="true" />
<div hx-swap-oob="true" id="message" style="color:blue;" x-data="{show:true}" x-show="show" x-init="setTimeout(()=> show=false,2000)">ユーザ追加しました。</div>`
)
);
設定後はメッセージが表示された2秒後にメッセージは自動で消えます。
ファイルのアップロード
HTMXを利用したファイルアップロードの方法を確認します。input要素のtype属性をfileに設定し、formタグにhx-post、hx-indicator, hx-trigger, hx-target, hx-encoding属性を設定します。hx-encodingのみここで初めて利用してますが通常のapplication/x-www-form-urlencoded
からmultipart/form-dataに
エンコードを行う際に必要な設定で通常のファイルアップロードフォームでenctype=”multipart/form-data”を設定するのと同じことを行っています。
<body>
<h1>HTMX</h1>
<div id="message"></div>
<form
hx-post="/file_upload"
hx-indicator="#indicator"
hx-trigger="submit"
hx-target="#message"
hx-encoding="multipart/form-data"
>
<input type="file" name="file" id="file" /><br /><br />
<button type="submit">Upload</button>
</form>
<span class="htmx-indicator" id="indicator">Uploading...</span>
</body>
multerモジュールの設定
バックエンド側は利用するサーバによってコードが変わります。Expressではmulterモジュールを利用してファイルを保存することができます。
const express = require('express');
const multer = require('multer');
const app = express();
const port = 3000;
app.use(express.static('public'));
app.use(express.urlencoded({ extended: true }));
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'public/images/');
},
filename: (req, file, cb) => {
cb(null, file.originalname);
},
});
const upload = multer({ storage: storage });
app.post('/file_upload', upload.single('file'), (req, res, next) => {
console.log(req.file);
res.send(
'<input type="file" name="file" id="file" hx-swap-oob="true" /><div x-data="{show:true}" x-show="show" x-init="setTimeout(()=> show=false,2000)">ファイルのアップロードが完了しました。</div>'
);
});
app.listen(port, () => console.log(`Example app listening on port ${port}!`));
Expressでのmulterの設定についてはこちらの記事を参考にしてください。
フォームからファイルを選択して”Upload”ボタンをクリックするとアップロード中にはブラウザ上にIndicaterで”Uploading…”の文字が表示されます。サーバ側で保存処理が完了するとpublic/uploadsディレクトリの下にアップロードしたファイルが保存されます。ブラウザ上には”ファイルのアップロードが完了しました”が表示されますがAlpine.jsを利用しているので2秒後にメッセージがブラウザ上から消えます。
filepondの利用
ファイルのアップロードをドラッグ&ドロップで行いたい時に簡単に実装ができるfilepondを利用します。filepondはHTMXとは直接関係がありませんがHTMXで作成したアプリケーションによりインタラクティブな機能を手軽に実装したい時に利用できます。
index.htmlにlinkタグでfilepondのcssを追加し、scriptタグでfilepondのjavascriptのCDNを設定します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script
src="https://unpkg.com/htmx.org@1.9.10"
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
crossorigin="anonymous"
></script>
<script src="//unpkg.com/alpinejs" defer></script>
<link
href="https://unpkg.com/filepond@^4/dist/filepond.css"
rel="stylesheet"
/>
</head>
<body>
<h1>HTMX</h1>
<div id="message"></div>
<form
hx-post="/file_upload"
hx-indicator="#indicator"
hx-trigger="submit"
hx-target="#message"
hx-encoding="multipart/form-data"
>
<input type="file" name="file" id="file" /><br /><br />
<button type="submit">Upload</button>
</form>
<span class="htmx-indicator" id="indicator">Uploading...</span>
<script src="https://unpkg.com/filepond@^4/dist/filepond.js"></script>
<script>
const inputElement = document.querySelector('input[type="file"]');
const pond = FilePond.create(inputElement, {
storeAsFile: true,
});
</script>
</body>
</html>
FilePond.createメソッドの第一引数にinput要素を指定して第二引数でstoreAsFileをtrueに設定します。sotoreAsFileをtrueに設定することでinput要素にアップロードしたファイルを保存することができます。
ファイルのDrag&Dropするとファイル名とサイズが表示されます。そのまま”Upload”ボタンをクリックするとファイルのアップロード処理が行われます。
まとめ
ここまでの内容でHTMXがどのようなものなのか理解が進んだのではないかと思います。Reactなどのフレームワークを利用するほど複雑ではなくシンプルなインタラクティブのあるページを作成したい時の選択肢としてHTMXを入れてはどうでしょうか。