はじめに
Raw JavaScript を書くのはツラいけど、Reactなどを必要とするほどのリッチなUIを作りたいわけでないため、モノリシックなアーキテクチャで楽にJSを使いたい。そのような場合の選択肢として、HTMXというものがあることを最近知りました。
少し前ですが、DjangoCon Europe 2022 でそのような事例紹介の発表がありました。
実際に、DjangoでHTMXを使ってみたいと思います。
1. ボタンを押したら、メッセージを表示する
まずは、次の画像のように、ボタンがクリックされたら、Ajaxを利用してメッセージを取得し、画面に表示される機能を作ってみます。
Raw JavaScript の場合
比較対象として、Raw JavaScript の場合から作ります。
from django.http import JsonResponse
from django.views import View
from django.shortcuts import render
class JavaScriptView(View):
def get(self, request, *args, **kwargs):
questions = Question.objects.all()
context = {"questions": questions}
return render(request, "sample/javascript.html", context)
class JsGetMessages(View):
def get(self, request, *args, **kwargs):
message = "Hello, World!"
return JsonResponse({'message': message})
<html lang="ja">
<head>
<title>JS-Sample</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="p-6">
<h1 class="text-3xl font-bold">
JavaScript Sample
</h1>
<!-- Message -->
<div class="mt-6 flex">
<button type="button" id="messageButton"
class="text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700"
>
Click me
</button>
<div class="ml-6 text-xl" id="messageText">
Let's display a Message by clicking a button!
</div>
</div>
</body>
<script>
// Buttonが押されたら、Messageを表示
document.getElementById('messageButton').addEventListener('click', function() {
// サーバーからメッセージを取得
fetch('/question/js/get-messages/')
.then(response => response.json())
.then(data => {
const messageText = document.getElementById('messageText');
messageText.innerText = data.message
});
});
</script>
</html>
HTMX の場合
続いて、HTMXを用いて同様の機能を実装してみます。
from django.http import JsonResponse
from django.views import View
from django.shortcuts import render
class HtmxView(View):
def get(self, request, *args, **kwargs):
questions = Question.objects.all()
context = {"questions": questions}
return render(request, "sample/htmx.html", context)
class HtmxGetMessages(View):
def get(self, request, *args, **kwargs):
message = "Hello, World!"
return render(request, 'sample/parts/message.html', {'message': message})
Raw JS のように JSON形式で返すと、JSONの内容がそのまま表示されてしまうので、置き換えたい要素をpartsとして作成します。
<div class="ml-6 text-xl" id="messageText">
{{ message }}
</div>
<html lang="ja">
<head>
<title>HTMX-Sample</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org"></script>
</head>
<body class="p-6">
<h1 class="text-3xl font-bold">
HTMX Sample
</h1>
<!-- Message -->
<div class="mt-6 flex">
<button type="button" id="messageButton" hx-get="/question/htmx/get-messages/" hx-trigger="click" hx-target="#messageText" hx-swap="outerHTML"
class="text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700"
>
Click me
</button>
<div class="ml-6 text-xl" id="messageText">
Let's display a Message by clicking a button!
</div>
</div>
</body>
</html>
HTMXを用いて、JavaScript を全く書かずに同様の機能を実装できました。htmlのタグに「hx」から始まる記述をすることで、 JavaScriptの記述と同様の処理を実現できるようです。
2. 選択された質問に合わせて、回答の選択肢を更新する
次はもう少し複雑な機能を実装してみましょう。選択された質問に合わせて、回答の選択肢を更新する機能を作っていきます。
Raw JavaScript の場合
from django.db import models
class Question(models.Model):
question_text = models.CharField(max_length=200)
pub_date = models.DateTimeField("date published")
class Choice(models.Model):
question = models.ForeignKey(Question, on_delete=models.CASCADE)
choice_text = models.CharField(max_length=200)
is_correct = models.BooleanField(default=False)
from .models import Question, Choice
class JsGetChoices(View):
def get(self, request, *args, **kwargs):
question_id = request.GET.get('question_id')
choices = Choice.objects.filter(question_id=question_id)
return JsonResponse({'choices': [{'id': choice.id, 'choice_text': choice.choice_text} for choice in choices]})
<html lang="ja">
<head>
<title>JS-Sample</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="p-6">
<h1 class="text-3xl font-bold">
JavaScript Sample
</h1>
<!-- 問題のセレクトボックス -->
<div class="mt-6">
<label for="questions" class="block mb-2 text-xl font-medium text-gray-900 dark:text-white">問題を選択してください。</label>
<select id="questionSelect" class="bg-gray-50 border border-gray-300 text-gray-900 text-l rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
{% for question in questions %}
<option value="{{ question.id }}">{{ question.question_text }}</option>
{% endfor %}
</select>
</div>
<!-- 回答のセレクトボックス -->
<div class="mt-6">
<label for="choices" class="block mb-2 text-xl font-medium text-gray-900 dark:text-white">回答を選択してください。</label>
<select id="choiceSelect" class="bg-gray-50 border border-gray-300 text-gray-900 text-l rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
</select>
</div>
</body>
<script>
// 質問のセレクトボックスでの選択変更を監視
document.getElementById('questionSelect').addEventListener('change', function() {
const questionId = this.value;
// サーバーから選択肢データを取得
fetch(`/question/js/get-choices/?question_id=${questionId}`)
.then(response => response.json())
.then(data => {
const choiceSelect = document.getElementById('choiceSelect');
// 既存の選択肢をすべて削除
while (choiceSelect.firstChild) {
choiceSelect.removeChild(choiceSelect.firstChild);
}
// 取得した選択肢データをセレクトボックスに追加
data.choices.forEach(function(choice) {
const option = new Option(choice.choice_text, choice.id);
choiceSelect.add(option);
});
});
});
</script>
</html>
このように、問題を選択すると、質問の選択肢が更新されます。
HTMX の場合
from .models import Question, Choice
class HtmxGetChoices(View):
def get(self, request, *args, **kwargs):
question_id = request.GET.get('question_id')
choices = Choice.objects.filter(question_id=question_id)
return render(request, 'sample/parts/choice_list.html', {'choices': choices})
<select id="choiceSelect" class="bg-gray-50 border border-gray-300 text-gray-900 text-l rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
{% for choice in choices %}
<option value="{{ choice.id }}">{{ choice.choice_text }}</option>
{% endfor %}
</select>
<html lang="ja">
<head>
<title>HTMX-Sample</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org"></script>
</head>
<body class="p-6">
<h1 class="text-3xl font-bold">
HTMX Sample
</h1>
<!-- 問題のセレクトボックス -->
<div class="mt-6">
<label for="questions" class="block mb-2 text-xl font-medium text-gray-900 dark:text-white">問題を選択してください。</label>
<!-- HTMXを使用して change event を処理する -->
<select id="questionSelect" hx-get="/question/htmx/get-choices/" hx-vals='js:{"question_id": calculateValue()}' hx-trigger="change" hx-target="#choiceSelect" hx-swap="outerHTML"
class="bg-gray-50 border border-gray-300 text-gray-900 text-l rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
>
{% for question in questions %}
<option name="question" value="{{ question.id }}">{{ question.question_text }}</option>
{% endfor %}
</select>
</div>
<!-- 回答のセレクトボックス -->
<div class="mt-6">
<label for="choices" class="block mb-2 text-xl font-medium text-gray-900 dark:text-white">回答を選択してください。</label>
<!-- HTMXを使用して選択肢を更新する -->
<select id="choiceSelect" class="bg-gray-50 border border-gray-300 text-gray-900 text-l rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
</select>
</div>
</body>
<script>
function calculateValue() {
return document.getElementById('questionSelect').value;
}
</script>
</html>
選択された option の value を親要素である select に渡すのに苦労しました。結局、JSで選択された option の value を返す関数を作って、select に渡すようにしましたが、よりスマートな方法があれば教えていただきたいです。
それでも、HTMXを使用することで確かにJSの記述量は(コード全体の記述量も)格段に減りました。大規模あるいは複雑なWebアプリケーションの場合はReactなどを導入してフロントエンドを分離させるべきだと思いますが、小規模かつ単純なWebアプリケーションにおいてHTMXは有用な選択肢かもしれません。今後もう少し本格的に使用してみたいと思います。
参考資料
公式ドキュメント