BLOG

Alpine.jsを使って診断コンテンツを作ってみた!

Written by naganoma

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を推しています。

  1. 容量が軽い
  2. 静的サイトに組み込みやすい
  3. 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つに分類されています。

  1. Directives(ディレクティブ)
  2. Magics(マジックプロパティ)
  3. Globals(グローバル)

全てを紹介するとかなりボリューミーになってしまうため、マジックプロパティは省略しつつ重要な部分をピックアップしてご紹介します⛰️

ディレクティブ

Alpine.jsが独自に定義しているHTML属性を指します。x-〇〇と言う属性名で使用されます。

リアクティブシステム上におけるデータの受け渡しや加工などは、全てこのディレクティブを介して実行されます。

<主なディレクティブ>

・x-data

リアクティブシステム上でやり取りするデータを定義します。Alpineを使う上でx-dataを定義することが全てのスタート地点となります。

・x-show

データの状態に合わせてDOM 要素の表示/非表示を切り替えることができます。

・x-text

データの値を要素のtextContentとして設定できます。

・x-on

clickmousemoveなどの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. フォームバリデーション
  2. 診断コンテンツ

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.datastepプロパティが表示切り替えの基準となるようにセッティングしたため、あとは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-bindimgタグの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はデフォルトではopacityscaleを変化させるため、変化させるプロパティやイージングなどを自由にカスタマイズしたい場合は、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ライブラリとも相性が良いと睨んでいるので、今後も研究を進めていくつもりです⛰️

他にもさまざまな機能やプラグインがあるので、ぜひ公式ドキュメントを覗いてみてください!

ご拝読ありがとうございました!

参考

Alpine.js

npm trends