BLOG
Alpine.jsを使って診断コンテンツを作ってみた!
INDEX
こんにちは!アシスタントエンジニアの永野間です!
今回はJavaScriptフレームワーク、Alpine.js(アルパイン)について、ご紹介します!
Alpine.jsの概要
Alpine.jsはいわゆる「リアクティブシステム」を提供するフレームワークで、2019年に登場しました。
リアクティブとは、「反応できる」という意味で、ざっくり言うと、あるデータの変更を検知し、ページ上に変更内容を自動的に反映させる仕組みを指します。(Reactの語源だそうです)
以下に簡単な例を挙げます。
<div x-data="{ message: '' }">
<input type='text' x-model='message' />
<p x-text='message'></p>
</div>
この例ではinput type=”text”
のvalue
属性に入力されたデータをAlpine.js側が検知し、ページ上(ここでは<p>
要素)に表示させています。
これくらいの処理であれば、Vanilla JSでも十分ですが、例えばお問い合わせフォームなどのように要件が複雑になる場合、こういったリアクティブフレームワークを活用することで、よりスマートに書くことができます。
なぜAlpine.jsを推すのか
リアクティブシステムと言われると一般的に浮かぶのはVue.jsです。
先ほどの例はVue.jsでも簡単に実装できるので、Vueに精通している方であれば「Alpineなんていらん!」となるかもしれません。
かく言う僕もVue.jsは案件で使ったことがありますが、以下の理由からAlpine.jsを推しています。
- 容量が軽い
- 静的サイトに組み込みやすい
- Webに要求されるレベル感に見合っている
1. 容量が軽い
まずはなんと言っても軽いです。以下はnpm trendsで比較した例となります。
Alpine.js バージョン2→3へのアップデートにより、多少容量は増えたものの、それでもVueと比較すると約63%ほど軽量となっています。
2.静的サイトに組み込みやすい
Vue.jsを静的サイトに導入する際、リアクティブシステムを導入する箇所の参照、やり取りするデータの定義などは全てscript(.js
、.vue
など)で記述する必要があります。
<!-- 先ほどの処理をVueで置き換えた例 -->
<div id="app">
<input type='text' v-model='message' />
<p>{{ message }}</p>
</div>
<script>
Vue.createApp({
data() {
return {
message: ''
}
}
}).mount('#app');
</script>
Alpine.jsも同様にscript上で処理を書く場合がありますが、簡単な処理の場合はHTML上のみで完結させることもできます。
<!-- Alpine.jsで記述する例 -->
<div x-data="{ message: '' }">
<input type='text' x-model='message' />
<p x-text='message'></p>
</div>
この機能はAstroなどの静的サイトジェネレーターと互換性が良いです。
Astroはコードフェンス上で静的に出力する処理をJavaScriptの文法で記述できるため、処理をコードフェンスに書いておき、HTML部分ではその処理を参照するだけ、という書き方ができます。
ビルド時にHTML上へJavaScriptの処理が全て出力されてしまうため、もちろん要件によっては適さない場合もありますが、HTML上で完結させられるというのはTailwind.cssのJavaScript版とも言える大きな魅力だと感じています。
3.Webに要求されるレベル感に見合っている
2.と若干内容が被りますが、そもそもwebに見合ったリアクティブフレームワークだなと言うのも大きなポイントです。
vueやReactにも豊富な機能が備えられていますが、webサイトの実装だとややオーバースペック気味だったり、そもそもフレームワーク自体を扱い切れる人が少ない印象です。
また、サイト上でリアクティブシステムを求められるケースは、「静的で組んだものに診断コンテンツを入れたい、フォームバリデーションを実装したい」など、ワンポイントで必要....くらいのレベル感のため、そう言った面でもAlpine.jsはちょうどよいのかなと感じています。
Alpine.jsの用語
前置きが長くなりましたが、ここからAlpine.jsの用語を簡単に説明します。
ドキュメントにも書かれていますが、Alpineの機能は大きく3つに分類されています。
- Directives(ディレクティブ)
- Magics(マジックプロパティ)
- Globals(グローバル)
全てを紹介するとかなりボリューミーになってしまうため、マジックプロパティは省略しつつ重要な部分をピックアップしてご紹介します⛰️
ディレクティブ
Alpine.jsが独自に定義しているHTML属性を指します。x-〇〇
と言う属性名で使用されます。
リアクティブシステム上におけるデータの受け渡しや加工などは、全てこのディレクティブを介して実行されます。
<主なディレクティブ>
・x-data
リアクティブシステム上でやり取りするデータを定義します。Alpineを使う上でx-data
を定義することが全てのスタート地点となります。
・x-show
データの状態に合わせてDOM 要素の表示/非表示を切り替えることができます。
・x-text
データの値を要素のtextContent
として設定できます。
・x-on
click
、mousemove
などのDOMイベントにアクセスできます。x-on
には省略記法があり、例えばx-on:click
は@click
と書くこともできます。
・x-bind
HTMLの属性(class
など)の値を動的に指定できます。x-bind
は省略記法があり、例えば、x-bind:class
は:class
と書くこともできます。
・x-model
input
要素などの入力フォームにおいて、入力した値をページ上とAlpine.js側で同期させることができます。
グローバル
ページを跨いで共通で使用したいグローバルな処理がある場合などは、script上でAlpine.data
を定義することで、グローバルにまとめることができます。
<!-- ドロップダウンコンポーネントの例 -->
<div x-data="dropdown">
<button @click="toggle">ドロップダウン1</button>
<div x-show="open">1</div>
</div>
<!-- 複数配置しても処理はコンポーネント内でスコープされる -->
<div x-data="dropdown">
<button @click="toggle">ドロップダウン2</button>
<div x-show="open">2</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('dropdown', () => ({
open: false,
toggle() {
this.open = !this.open
},
}))
})
</script>
なお、Alpineの処理はコンポーネント単位でスコープ化されるため、上記の例でドロップダウン1をクリックするとドロップダウン2も開いてしまう、ということはありません。
Alpine.jsを使用したデモ
Alpine.jsを使って以下の簡単なデモを作成してみます。
- フォームバリデーション
- 診断コンテンツ
1.フォームバリデーション
※今回はCDNでAlpine.jsを読み込んでいますが、npmなどモジュールとしても読み込むことも可能です。(詳細はドキュメントをご参照ください)
実装のポイントについてざっくり解説します。
1. x-dataの指定
ディレクティブでも触れた通り、まずはx-data
の定義からです。
form要素をwrapするdiv
要素にformValidation
というデータ名を定義します。
これにより、このdiv
内に含まれる要素はformValidation
に対してデータの受け渡しが可能になります。
<!-- データの定義 -->
<div x-data='formValidation'>
<form>
...
</form>
</div>
2. Alpine側でデータを定義
今の状態だと、HTML上ではx-data
にデータ名を渡しているだけなので、中身の定義は別途行う必要があります。
今回はscript上でformValidation
にデータの中身を登録していきます。
alpine:init
イベントはAlpine.jsが発行する独自のイベントで、Alpine.jsがロードされた後、ページ上で初期化される前に登録したいデータがある場合にこのイベントを使用します。
document.addEventListener('alpine:init', () => {
Alpine.data('formValidation', () => ({
...
}))
})
3.入力フォームに入力された値を格納する入れ物を作る
バリデーションを実行するためには、まず値を格納する入れ物を準備しておき、HTML側に入力された内容がその入れ物を入るようにしておく必要があります。
入れ物の準備はscript上で行います。
document.addEventListener('alpine:init', () => {
Alpine.data('formValidation', () => ({
name: '', // お名前
phone: '', // 電話番号
email: '', // メールアドレス
inquiry: '', // お問い合わせ内容
}))
})
HTML側で入力された値が上記の入れ物に格納されるようにするには、HTML側でx-model
(入力データを共有するディレクティブ)を指定します。
<input type='text' x-model='name'/>
<input type='text' x-model='phone'/>
<input type='email' x-model='email'/>
<textarea x-model='inquiry'></textarea>
4.エラーメッセージを表示する
バリデーションを実装する事前準備として、エラーメッセージをHTMLに出力する処理を追加しておきます。
まずはエラーメッセージを格納する空のオブジェクトを定義します。
document.addEventListener('alpine:init', () => {
Alpine.data('formValidation', () => ({
name: '', // お名前
phone: '', // 電話番号
email: '', // メールアドレス
inquiry: '', // お問い合わせ内容
errors: {}, // エラーメッセージ
}))
})
次にerrors
オブジェクトにエラーメッセージを追加する処理を記述します。ここではお名前が入力されていない場合に、「name: '※お名前は必須です'
」というプロパティをerrors
オブジェクトに追加する処理を記述しています。
document.addEventListener('alpine:init', () => {
Alpine.data('formValidation', () => ({
name: '', // お名前
phone: '', // 電話番号
email: '', // メールアドレス
inquiry: '', // お問い合わせ内容
errors: {}, // エラーメッセージ
// お名前
validateName() {
if (!this.name) {
this.errors.name = '※お名前は必須です。'
} else {
delete this.errors.name
}
},
}))
})
最後にHTML上でエラーメッセージを表示する処理を追加します。Alpine.data
内で定義されたerrors
オブジェクトのname
プロパティにアクセスし、その値をテキストとして出力すれば良いので、ここではx-text
を使用してHTML上にエラーメッセージを出力させています。
また、フォーム未入力の際はエラーメッセージを非表示にするため、x-show
も合わせて使用しています。
<input type='text' id='name' x-model='name' />
<p x-show='errors.name' x-text='errors.name' class="error">
5.バリデーションを実装する
上記の入れ物に格納された値を、特定のフィルターにかけることでバリデーションを実装できます。
document.addEventListener('alpine:init', () => {
Alpine.data('formValidation', () => ({
name: '', // お名前
phone: '', // 電話番号
email: '', // メールアドレス
inquiry: '', // お問い合わせ内容
// ...
// 電話番号のバリデーション
validatePhone() {
const phonePattern = /^\d{2,4}-?\d{2,4}-?\d{4}$/ // 正規表現
if (!this.phone) {
this.errors.phone = '※電話番号は必須です。'
} else if (!phonePattern.test(this.phone)) {
this.errors.phone = '有効な電話番号を入力してください。'
} else {
delete this.errors.phone
}
},
// ...略
}))
})
これだけではバリデーションは呼び出されないので、どういった状況下で呼び出されるのかを定義する必要があります。ここで使用するのがDOMイベントを検知するx-on
ディレクティブです。
今回は入力フォームからフォーカスが外れた時にバリデーションを実行したいので、DOMイベントのblur
を検知する必要があります。Alpineではx-on:イベント名
の形式で対象のイベントを検知できるため、x-on:blur
(省略記法で@blur
)に対してバリデーション実行用のメソッドを登録します。
<input type='text' x-model='name' @blur='validateName' />
<input type='text' x-model='phone' @blur='validatePhone' />
<input type='email' x-model='email' @blur='validateEmail' />
<textarea x-model='inquiry' @blur='validateInquiry'></textarea>
これで大枠は完成です!
一見複雑に見えますが、順を追ってみていくと案外シンプルな作りなので、Alpineの文法にさえ慣れれば簡単にバリデーションを実装できます🎉
2.診断コンテンツ
実装のポイントについてざっくり解説します。
1.x-dataの定義
先ほどと同じくまずはx-data
をHTML上に記述し、データの登録をscript上で行います。
<div x-data='diagnosis' class='diagnosis-container'> ... </div>
// js
document.addEventListener("alpine:init", () => {
Alpine.data("diagnosis", () => ({
...
}))
})
2.コンテンツの配置
HTML上にコンテンツを記述します。
今回の診断コンテンツはスタート画面→回答画面×3→診断中→リザルト画面
といった構成で作成しています。
<div x-data='diagnosis' class='diagnosis-container'>
<div class='diagnosis-start'>
...
</div>
<div class='diagnosis-question'>
...
</div>
<!-- 略 -->
<div class='diagnosis-processing'>
...
</div>
<div class='diagnosis-result'>
...
</div>
</div>
3.コンテンツの表示を切り替える
回答フェーズに応じてコンテンツの表示/非表示を切り替えます。Alpine.jsのx-show
ディレクティブを使用することで対応できます。
<div x-data='diagnosis' class='diagnosis-container'>
<div class='diagnosis-start' x-cloak x-show="step === 'start'">
...
</div>
<div class='diagnosis-question' x-cloak x-show="step === 'q1'">
...
</div>
<!-- 略 -->
<div class='diagnosis-processing' x-cloak x-show="step === 'processing'">
...
</div>
<div class='diagnosis-result' x-cloak x-show="step === 'result'">
...
</div>
</div>
なお、x-show
の前についているx-cloak
はAlpineのロード後に削除されるディレクティブで、ページ読み込み時のちらつき(一瞬x-show
が適用される前の要素が表示される現象)を防止するために使用します。
x-cloak
はAlpineのロード後に削除されるため、cssで以下のようにしておくことでちらつきを防止できます。
[x-cloak] {
display: none !important;
}
4.回答フェーズをコントロールする
Alpine.data
のstep
プロパティが表示切り替えの基準となるようにセッティングしたため、あとはstep
の値を切り替える処理をscript上で実装します。
document.addEventListener('alpine:init', () => {
Alpine.data('diagnosis', () => ({
step: 'start',
// 診断開始
startDiagnosis() {
this.step = 'q1'
},
// スタート画面に戻る
restartDiagnosis() {
this.answers = []
this.handleStep('start')
},
// stepの切り替え
handleStep(toStep) {
this.step = toStep
},
// 前の回答に戻る
previousStep() {
if (this.answers.length > 0) {
this.answers.pop()
}
if (this.step === 'q2') {
this.handleStep('q1')
} else if (this.step === 'q3') {
this.handleStep('q2')
} else if (this.step === 'q1') {
this.handleStep('start')
}
},
}))
})
5. 回答結果を格納する
回答結果に応じてどのようなリザルトを出すか定義しておきます。まずはanswers
と言う空の配列を作成しておき、回答をクリックすると回答内容がanswers
に格納される処理を作成しておきます。
document.addEventListener('alpine:init', () => {
Alpine.data('diagnosis', () => ({
//回答結果を格納する配列
answers: [],
// 診断中フェーズを挟んだあと、リザルトフェーズに移行
showResult() {
this.step = 'processing'
setTimeout(() => {
this.result = this.calculateResult()
this.handleStep('result')
}, 2000)
},
// 回答結果を格納しつつ、回答フェーズを切り替える
answerQuestion(answer) {
this.answers.push(answer)
if (this.step === 'q1') {
this.handleStep('q2')
} else if (this.step === 'q2') {
this.handleStep('q3')
} else if (this.step === 'q3') {
this.showResult()
}
},
}))
})
HTML上でanswerQuestion
を実行します。回答クリック時に実行する必要があるため、x-on
ディレクティブを使ってx-on:click(@click)
で実行します。
<div x-data='diagnosis' class='diagnosis-container'>
<div class='diagnosis-question' x-cloak x-show="step === 'q1'">
...
<button type='button' @click="answerQuestion('A')"></button>
<button type='button' @click="answerQuestion('B')"></button>
</div>
<div class='diagnosis-question' x-cloak x-show="step === 'q2'">
...
<button type='button' @click="answerQuestion('A')"></button>
<button type='button' @click="answerQuestion('B')"></button>
</div>
</div>
6.回答結果をもとに表示するリザルトをセッティングする
最後にscript側でanswers
配列に格納された値を取り出し、回答結果をもとにリザルト表示のパターンを定義しておきます。
document.addEventListener('alpine:init', () => {
Alpine.data('diagnosis', () => ({
answerQuestion(answer) {
this.answers.push(answer)
if (this.step === 'q1') {
this.handleStep('q2')
} else if (this.step === 'q2') {
this.handleStep('q3')
} else if (this.step === 'q3') {
this.showResult()
}
},
// リザルトのパターンを定義
calculateResult() {
const imgPath =
'https://www.evoworx.co.jp/wp2024/wp-content/uploads/2023/04/engunner_naganoma-1.png'
const pattern = this.answers.join('→')
switch (pattern) {
// A→A→Aの順で回答した場合
case 'A→A→A':
return {
type: '活発元気系な',
name: 'naganoma',
img: imgPath,
}
// A→B→Bの順で回答した場合
case 'A→B→B':
return {
type: '現実主義者',
name: 'naganoma',
img: imgPath,
}
// ...略
}
},
}))
})
回答結果を表示する処理はanswerQuestion
メソッドが最後の回答フェーズに到達した場合に実行されるようにしているため、あとはHTML側で回答結果を表示するのみとなります。
今回はテキストをx-text
、画像はx-bind
でimg
タグのsrc
属性に動的に値を設定することができます。
<div x-data='diagnosis' class='diagnosis-container'>
...
<div class='diagnosis-result' x-cloak x-show="step === 'result'">
<h2 class='result-title'>診断結果🎉</h2>
<p class='result-desc'>あなたの運命の相手は...</p>
<figure class='result-thumb'>
<img :src='result.img' alt='' />
</figure>
<p class='result-caption'>
<span class='caption-type' x-text='result.type'></span>
<span class='caption-name'>
<span x-text='result.name'></span>さん
</span>
</p>
</div>
</div>
補足:x-transitionについて
ここまでの説明では省いてきましたが、表示/非表示にトランジションをつける際はx-transition
ディレクティブを使用することで実装できます。
x-transition
はデフォルトではopacity
とscale
を変化させるため、変化させるプロパティやイージングなどを自由にカスタマイズしたい場合は、transitionのライフサイクルに合わせて、cssのクラス名を当てることで対応できます。
<div
class='diagnosis-question'
x-cloak
x-show="step === 'q1'"
x-transition:enter='transitionEnter'
x-transition:enter-start='enterStart'
x-transition:enter-end='enterEnd'
x-transition:leave='transitionLeave'
x-transition:leave-start='leaveStart'
x-transition:leave-end='leaveEnd'>
...
</div>
// css
.transitionEnter {
transition:
opacity 0.5s ease 0.5s,
translate 0.5s ease 0.5s;
}
.transitionLeave {
transition:
opacity 0.5s ease,
translate 0.5s ease;
}
.enterStart {
opacity: 0;
translate: 0 10%;
}
.enterEnd {
opacity: 1;
translate: 0;
}
.leaveStart {
opacity: 1;
translate: 0;
}
.leaveEnd {
opacity: 0;
translate: 0 10%;
}
おわりに
Alpine.jsについて熱く解説しました。
ここでは深く触れませんでしたが、個人的にSwup.jsやBarba.jsなどのSPAライブラリとも相性が良いと睨んでいるので、今後も研究を進めていくつもりです⛰️
他にもさまざまな機能やプラグインがあるので、ぜひ公式ドキュメントを覗いてみてください!
ご拝読ありがとうございました!