MPAのWebアプリでの自動保存機能の実装
SPAではない(MPAの)Webアプリケーションで、フォームに入力した値の自動保存を実装している案件がある。
その案件では現状、主にVue.jsを使って自動保存の機能を実装している。以下はその概要。
- 対象のアプリはJava(SpringBoot)で実装(フロントはThymeleaf)
- DBはMySQLで、自動保存に関する項目はJSON型として1つのカラムで保持(項目数が多く、後から項目が増える可能性も高かったため)
- 自動保存機能がある画面ではCDNでVue.jsを導入
- Vue.jsのwatchを使って値の変更を検知し、1秒以上入力がなければ保存対象の全てのデータをbodyに含め、バックエンドにPUTリクエストで送信
- バックエンドでは送られてきたJSONデータでそのままカラムの値を上書き
- テーブルにはバージョン情報を保持し、更新時にバージョンを確認して不整合時はエラーにすることで、同時編集による上書きを回避
対象のアプリケーションは既にリリース済みで、一応上記の実装で正常に動いているが、プログラムの観点からはいくつか課題がある。
可読性の問題
まず、前提としてSPAではなく、CDNでVue.jsを導入している都合上、基本的にコンポーネントを分割することはしていない。それもあって、1ファイル内のhtmlやJavaScriptのコード量が多い。画面によっては自動保存の対象になっている項目の数が非常に多い画面もあり、そのような画面ではHTMLとJavaScriptそれぞれのコード量が非常に多い。つまるところ、コード量が多くて可読性が悪い。
対象のWebアプリはJavaなのでIDEにはIntelliJ IDEAを使用している。しかし、IntelliJ IDEAではJavaScriptのシンタックスハイライト機能が弱いので、その点でも開発裕にコードが読みづらいという欠点がある。(JavaScriptのコード量がある程度増えたらVS Codeで開くようにしている)
データ通信容量の問題
今回の自動保存の機能では、リクエスト毎に画面の全ての項目をbodyに含めて送信している。コード的にはシンプルだが、リクエストのデータ量という観点では多くの無駄が発生している。特に、項目数が非常に多い画面ほど、リクエストのデータ量が無駄に多くなってしまう。
htmx
最近、ハイパーメディアシステムという書籍を読んでhtmxという技術の存在を知った。
htmxをざっくり紹介すると、HTMLにhx-から始まる属性を追加することで任意の要素・任意のイベントでHTTPメソッド(GET, POST, PUT, PATCH, DELETE)を送信でき、レスポンスのHTMLを任意の要素に置き換えることができるライブラリ。つまり、直接JavaScriptのコードを書かなくても、HTMLに属性を追加するだけで、変更があった要素のみを変更するというSPAのWebアプリに近い動作を実現することができる。
詳細は公式サイトを確認ください。
htmxの詳細を知ったとき、この技術を使えば先の自動保存の機能を比較的シンプルに実装できるのではと思った。Vue.jsで実装しているWebアプリは既にリリース済みなので、今更htmxで作り直すことはしないつもりだけれど、Vue.jsとhtmxで同じ機能を作って比較できたら勉強になりそうと思ったので、簡単に作ってみることにした。
完成系
Vue.jsとhtmxを比較しながらhtmxのことを学ぶのが目的なので、あまり凝ったことはせずシンプルな画面で作成。input要素を5つ用意し、この項目がJSONとして1つのカラムに保存されることを想定。入力後に1秒間変更がなければ、データを保存し、レスポンスを表示する。
Vue.jsでの自動保存
まずはVue.jsを使って実装。HTMLはかなりシンプルですが、JavaScriptのコード量はそれなり。
※Composition APIの書き方の方が良かったかもですが、AIのサジェストに頼ったらOptions APIの書き方になったので、そのまま採用しました。Composition APIで書いたとしても、全体のコード量としては大きく差はないかと思われます。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Auto Save sample</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>
<main id="app">
<h1>Auto Save Sample</h1>
<p>{{ message }}</p>
<form action="#">
<input type="text" v-model="item.content1" placeholder="Content 1"><br>
<input type="text" v-model="item.content2" placeholder="Content 2"><br>
<input type="text" v-model="item.content3" placeholder="Content 3"><br>
<input type="text" v-model="item.content4" placeholder="Content 4"><br>
<input type="text" v-model="item.content5" placeholder="Content 5"><br>
</form>
</main>
<script>
const app = Vue.createApp({
data() {
return {
itemCode: '001', // 詳細のデータを特定するコード。本来は動的
version: 0,
timeout: null,
item: {
content1: '',
content2: '',
content3: '',
content4: '',
content5: '',
},
message: '',
}
},
watch: {
item: {
handler(newValue, oldValue) {
if (this.timeout) {
clearTimeout(this.timeout);
}
this.timeout = setTimeout(() => {
this.autoSave(this.reportId)
}, 1000);
},
deep: true,
flush: 'post'
}
},
methods: {
autoSave(value) {
fetch(`/api/autosave/itemCode/${this.itemCode}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ item: this.item })
})
.then(response => response.text())
.then(data => {
this.message = data
console.log('Auto-saved:', data);
})
.catch(error => {
console.error('Error during auto-save:', error);
});
}
}
});
app.mount('#app');
</script>
</body>
</html>
watchで入力を検知し、1秒入力がなければPUTリクエストを送信する。
以下はバックエンド。バックエンドは何でも良いですが、サンプルのコードはKotlin + SpringBootで作成したのでそのコードの抜粋。サービスクラスは割愛。リクエストで送られてきたbodyのデータでJSONデータをINSERT or UPDATEする処理を実装するイメージ。
@PutMapping("/api/autosave/itemCode/{itemCode}")
fun autosave(
@PathVariable itemCode: String,
@RequestBody body: String
): ResponseEntity<String> {
// bodyの値全体を保存
val version = itemService.itemSave(itemCode, body)
return ResponseEntity.ok("Data autosaved successfully with version $version")
}
htmxでの実装
続いては、同じ処理をhtmxで実装した場合。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Auto Save sample</title>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" crossorigin="anonymous"></script>
</head>
<body>
<main >
<h1>Auto Save Sample</h1>
<p id="message"></p>
<form action="#">
<input type="text"
id="content1"
name="content1"
hx-put="/autosave/itemCode/002/content1"
hx-trigger="keyup changed delay:1s"
hx-target="#message"
hx-include="this"
placeholder="Content 1"><br>
<input type="text"
id="content2"
name="content2"
hx-put="/autosave/itemCode/002/content2"
hx-trigger="keyup changed delay:1s"
hx-target="#message"
hx-include="this"
placeholder="Content 2"><br>
<input type="text"
id="content3"
name="content3"
hx-put="/autosave/itemCode/002/content3"
hx-trigger="keyup changed delay:1s"
hx-target="#message"
hx-include="this"
placeholder="Content 3"><br>
<input type="text"
id="content4"
name="content4"
hx-put="/autosave/itemCode/002/content4"
hx-trigger="keyup changed delay:1s"
hx-target="#message"
hx-include="this"
placeholder="Content 4"><br>
<input type="text"
id="content5"
name="content5"
hx-put="/autosave/itemCode/002/content5"
hx-trigger="keyup changed delay:1s"
hx-target="#message"
hx-include="this"
placeholder="Content 5"><br>
</form>
</main>
</body>
</html>
htmx用の属性を追加する必要があるため、一つ一つのinput要素のコードは多くなったが、JavaScriptのコードを書く必要がなくなり、各要素の属性だけを実装すればよいので、全体としてはシンプルになったように感じる。
-
hx-putでPUTリクエストの送信先を指定 -
hx-triggerで、リクエストの発行タイミングを指定。ここでは入力して1秒後にリクエストが送信されるように設定 -
hx-includeで自分自身のinput要素がパラメータとして送信されるように設定 -
hx-targetで、レスポンスを差し替える要素を指定
htmx版のバックエンドは以下。
特定の項目のみを更新し、メッセージだけをレスポンスとして返すようにする。
@PutMapping("/autosave/itemCode/{itemCode}/{target}")
@ResponseBody
fun autosave(
@PathVariable itemCode: String,
@PathVariable target: String,
@RequestParam params: Map<String, String>
): String {
val value = params[target] ?: ""
// 特定のフィールドのみを更新
val version = itemService.updateField(itemCode, target, value)
return "Data autosaved successfully with version $version"
}
比較
バックエンドに関しては、コントローラの複雑さはあまり変わらない。
DB更新用ロジックを含むサービスクラスは、特定の項目だけを更新する方が若干複雑になったので、htmx版の方が若干複雑にはなった。
フロントエンドに関しては、この段階ではhtmxの方がかなりシンプル。
実際には、既存のデータが存在する場合は取得して表示したり、同時編集を防ぐためにバージョンの整合性チェックを挟む必要があるので、どちらの場合ももう少し複雑になる。
htmx版でも、多少はJavaScriptのコードを書く必要は出てきそう。
ただ、それを加味しても、全体としてはhtmx版の方がシンプルになりそう。
htmx版ではJavaScriptのコード量が格段に減るので、IntelliJ IDEAで見てもコードが読みづらくならない点も個人的にはありがたい。
もう少し改善する
先に紹介したハイパーメディアシステムという書籍の中で、Alpine.jsというライブラリも紹介されていた。
htmxと相性が良く、htmx同様にCDN経由で簡単に導入できるので、せっかくなのでalpine.jsでhtmx側のコードを改善してみる。alpine.jsを使うと、x-data属性でプロパティを定義し、その要素内でx-から始まる属性で使用することができる。
Vue.jsのディレクティブとほとんど同じ使い方で使うことができる。
先のhtmx版のHTMLは、属性がほとんど似ているinput要素が5つ並んでいるので、もっと効率的に書けると嬉しい。Vue.jsの場合はv-forで繰り返しの要素を簡単に実現できるが、Alpine.jsではx-forという属性で似たようなことができる。
x-forを使って改良したのが以下。
<form action="#" x-data="{
contents: ['content1', 'content2', 'content3', 'content4', 'content5']
}">
<template x-for="content in contents" :key="content">
<div>
<input type="text"
:id="content"
:name="content"
:hx-put="`/autosave/itemCode/002/${content}`"
hx-trigger="keyup changed delay:1s"
hx-target="#message"
hx-include="this"
:placeholder="content">
</div>
</template>
</form>
自動保存の機能はそのままに、かなりコードがスッキリしました。
サーバーとの通信を必要としない、クライアント側だけの動的なUI制御(例えば、チェックボックスの有無による表示非表示の制御や入力可否制御など)を実現しようと思うと、htmxで実現できることはほとんどなく、Vue.jsの方が簡単に実現できる。しかし、Alpine.jsを使えば、そのようなUI制御も簡単に実現できるので、そういう点でもhtmx + Alpine.jsの組み合わせは相性が良い。
まとめ
実際にhtmxとAlpine.jsを触ってみて、MPAのWebアプリでも、htmx + Alpine.js を導入することで、比較的シンプルなコードでそれなりにリッチなUIを実現できるイメージがかなり湧きました。
個人的にはMPA構成のWebアプリに携わる機会はまだまだ多そうなので、htmxとApline.jsについてはもう少し深掘りして色々使えるようにしておきたい。
