Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

Alpine.jsと管理画面での利用例

サーバーサイドエンジニアの田実です。

顧客管理サービスである Yappli CRM の社内用管理画面ではjQueryが使われています。jQueryは簡易的なDOM操作やインタラクティブなデザイン、アニメーションを実装するには便利なツールである一方で、要件が複雑になると可読性が悪くなり保守性が下がる傾向にあります。 かといって、ReactやVue.jsを導入するのは記述量や手軽さ、学習コストの観点でtoo matchなケースもあります。 そこで軽量フロントエンドライブラリであるAlpine.jsを使ってみたところ、導入の手軽さ、開発者体験、可読性、保守性が良かったため、管理画面のjQueryをAlpine.jsで置き換え始めています。 本記事では管理画面のユースケースを軸にAlpine.jsの使い方や特徴を紹介していきます。

Alpine.jsとは

Alpine.js はVue.jsライクな軽量フロントエンドライブラリです。

  • 宣言的UI
  • ビルドやマウントなどの事前処理が不要で、jQueryのようにCDNから読み込んでサクッと使える
  • 簡単な処理はインラインで書けて、保守性・可読性に優れている
  • Vue.jsライクで学習コストが低い( 15 attributes, 6 properties, and 2 methods.
  • ファイル容量が軽い

といった特徴があり 現代のjQuery とか Tailwind CSSのJavaScript版 *1 と言われています。

どれぐらい手軽で簡単かと言うと、表示非表示を切り替える処理がこれだけの記述で動いてしまうくらいには簡単です。

<script src="//unpkg.com/alpinejs" defer></script>
<div x-data="{ open: false }">
  <button @click="open = !open">トグル</button>
  <p x-show="open">コンテンツ</p>
</div>

管理画面のユースケースにおけるAlpine.jsの実装方法

インタラクティブなイベントに対して表示内容を変更する

Alpine.jsを使うと、特定のinput値に応じてフォーム項目を表示させたり、開閉ボタン、モーダルが簡単に実装できます。

以下は x-if 属性でフォーム項目の表示/非表示を制御する例です。

<!-- 選択肢に `hoge` を入力すると テキストが表示される -->
<div x-data="{ select: '' }">
  <label>選択肢: 
    <select x-model="select1">
      <option value="">-</option>
      <option value="hoge">hoge</option>
    </select>
  </label>
  <template x-if="select == 'hoge'" >
    <label>テキスト: <input type="text"></label>
  </template>
</div>

HTMLのフォーム検証ではできない複雑なバリデーション

inputタグのrequired属性など、HTMLのフォーム検証だけではできないようなバリデーションや振る舞いを実装したい場合、x-on(@) 属性を使って任意のイベントにJavaScript関数を割り当てます。

<form x-data="{ field: '' }" @submit="if (field !== 'hoge') { alert('hogeではありません'); $event.preventDefault(); }">
  <input type="text" x-model="field" >
  <input type="submit">
</form>

JavaScript関数ではバインドした値(x-data の各オブジェクトのプロパティ値)の取得・変更ができます。

明細行などのinputタグが動的に増減するフォーム

一括登録のようなinputタグを動的に増減させる仕様はよくある要件ですが、jQueryやVanillaJSで記述するとカオスなコードになりがちです。 Alpine.jsではx-onを使ってボタン押下時に配列フィールドに行データを追加し、x-forで配列のレンダリングをすることで簡単に実現できます。

<form x-data="{ rows: [] }">
  <template x-for="(row, index) in rows">
    <div>
     <label><span x-text="index"></span>番目のフィールド: <input type="text" x-model="row.field" />
    </div>
  </template>
  <button value="行の追加" @click.prevent="rows.push({ field: 'hoge' });" >行の追加</button>
  <input type="submit">
</form>

jQueryとの比較

簡易的なバリデーション、エラー表示、動的テキストフィールドなフォームを例に比較していきます。

まずはjQueryの場合

<form id="form">
  <div id="errors">
    エラー
    <ul style="color: red;" >
    </ul>
    <template id="error_template">
      <li></li>
    </template>
  </div>
  <ul id="rows">
    <template id="row_template">
      <li>
        <input type="text" class="rows_field">
        <button class="delete">行の削除</button>
      </li>
    </template>
  </ul>
  <button id="add" >行の追加</button>
  <input type="submit" id="submit" >
</form>

<script>
$("#add").click(function() {
  const $rows = $("#rows");
  const $row = $("#row_template");
  $rows.append($row.html());
  return false;
});
$(document).on('click', '.delete', function () { 
  $(this).parent().remove();
  return false;
});

$("#form").submit(function() {
  // バリデーション
  const errors = [];
  $(".rows_field").each((index, row) => {
    if ($(row).val() !== 'hoge') {
      errors.push(`${index}番目のテキストがhogeではありません`);
    }
  });

  // バリデーション結果の画面反映
  const $errors = $("#errors > ul");
  const errorTemplate = $("#error_template").html();
  $errors.empty();
  for (const error of errors) {
    const $error = $(errorTemplate);
    $error.text(error);
    $errors.append($error);
  }
  if (errors.length > 0) return false;
});
</script>

jQueryの方でもtemplateタグを使ったり、関数内でデータとレンダリングを分けることで比較的きれいに書けるのですが、HTMLの構造に強く依存しているためビューの変更に弱く、JavaScript関数にDOM操作のロジックが入るため可読性も悪いです。レンダリングのロジックがJavaScript側にあることから、HTML定義だけ見ても最終的にレンダリングされるHTMLがわかりません。jQueryでも比較的きれいに書けると言ったものの、実装する人によって品質にかなりバラつきが生じます。セレクタのためだけにidやclassなどの属性を定義する必要もあります。ロジックのテストもできません。

続いてAlpine.jsはこんな感じで実装できます。

<form x-data="form" @submit="submitForm">
  <div x-show="errors.length > 0">
    エラー
    <ul style="color: red;" >
      <template x-for="error in errors">
        <li x-text="error"></li>
      </template>
    </ul>
  </div>
  <ul>
    <template x-for="(row, index) in rows">
      <li>
        <input type="text" x-model="row.field">
        <button @click.prevent="deleteRow(index)">行の削除</button>
      </li>
    </template>
  </ul>
  <button @click.prevent="addRow" >行の追加</button>
  <input type="submit" >
</form>
<script>
document.addEventListener('alpine:init', () => {
  Alpine.data('form', () => ({
    errors: [],
    rows: [{ field: '' }],
    addRow() {
      this.rows.push({ field: '' });
    },
    deleteRow(index) {
      this.rows.splice(index, 1);
    },
    submitForm(event) {
      this.errors = [];
      this.rows.forEach((row, index) => {
        if (row.field !== 'hoge') {
          this.errors.push(`${index}番目のテキストがhogeではありません`);
        }
      });
      if (this.errors.length > 0) {
        event.preventDefault();
      }
    },
  }));
});
</script>

処理が複雑なため、これまでの例と異なり Alpine.data() を使ってデータとロジックを別で定義しています。Alpine.jsの方はデータとロジックはJavaScript内で完結しており、中身もrowsとerrorsを操作していることが一目瞭然です。HTML構造を変更してもJavaScript側を変更する必要なく動きますし、HTML定義を見るだけで最終的に表示されるHTMLが容易に把握できます。セレクタのケアも不要で直接DOM操作を行わないことから、jQueryと比較すると実装者による差異も出にくいのではないかと思います。また、JSファイルを分けてデータ関数を定義すればロジックのテストもできます。

ちなみにVue.jsのOptions APIだとこんな感じで書けます。

<div id="form">
  <form @submit="submitForm">
    <div v-show="errors.length > 0">
      エラー
      <ul style="color: red;" >
        <li v-for="error in errors">{{ error }}</li>
      </ul>
    </div>
    <ul>
      <li v-for="(row, index) in rows">
        <input type="text" v-model="row.field">
        <button @click.prevent="deleteRow(index)">削除</button>
      </li>
    </ul>
    <button @click.prevent="addRow" >追加</button>
    <input type="submit" >
  </form>
</div>
<script>
const { createApp } = Vue;

createApp({
  data() {
    return {
      errors: [],
      rows: [{ field: '' }],
    };
  },
  methods: {
    addRow() {
      this.rows.push({ field: '' });
    },
    deleteRow(index) {
      this.rows.splice(index, 1);
    },
    submitForm(event) {
      this.errors = [];
      this.rows.forEach((row, index) => {
        if (row.field !== 'hoge') {
          this.errors.push(`${index}番目のテキストがhogeではありません`);
        }
      });
      if (this.errors.length > 0) {
        event.preventDefault();
      }
    },
  },
}).mount('#form');
</script>

Alpine.jsとほとんど記述が変わりませんね…!それならばいっそVue.js使ったほうが大は小を兼ねる意味でも良いのでは?という気もしますが、createApp()などの記述が不要でインラインで完結できて軽量、といったあたりにAlpine.jsの優位性があると思います。例えば、以下のような簡単な画面表示制御をしたいだけであればVue.jsはtoo matchです。

Alpine.jsバージョン

<div x-data="{ open: false }">
  <button @click="open = !open">トグル</button>
  <p x-show="open">コンテンツ</p>
</div>

Vue.jsバージョン

<div id="app">
  <button @click="open = !open">トグル</button>
  <p v-show="open">コンテンツ</p>
</div>
<script>
const { createApp } = Vue;

createApp({
  data() {
    return {
      open: false,
    };
  },
}).mount('#app');
</script>

このように、社内用管理画面はMPA+小〜中規模程度のJavaScriptの構成が取られることが多いため、Alpine.jsがフィットするケースは多いように思います。

その他

テスト

上で述べた通り、Alpine.jsだとデータ関数を分けて定義してファイル分割することでテストができます。

例えば以下のような形でデータ関数を定義しておき、通常実行用としてwindowのグローバルに、テスト実行用としてmodule.exportsに設定します。

// /path/to/hoge.js
// Alpine.data('form', FormData); といった感じで利用する
const FormData = () => {
  return {
    rows: [],
    addRow() { this.rows.push({ field: 'hoge' }); },
    deleteRow(index) { this.rows.splice(index, 1); },
  };
};
if (window) window.FormData = FormData; // ブラウザから使うときはこちらを通る
if (module) module.exports = FormData; // テストで使うときはこちらを通る

jestを使う場合は以下のようにしてテストができます。

const FormData = require('/path/to/hoge');

test('addRow()', () => {
  const data = FormData();
  data.addRow();
  data.addRow();

  expect(data.rows.length).toBe(2);
  expect(data.rows[0]).toEqual({ field: 'hoge' });
});

テスト対象はただのJavaScriptオブジェクトなので、インストールするものはjestだけでOKです。

Laravelとの連携

MPAの文脈で最初にサーバーサイドで取得したデータをJavaScriptで初期値として利用したいケースがあると思います。 従来ですとinputタグのhiddenに入れてJSから読み取ったり、JavaScriptの変数定義に自前で突っ込んだりしていましたが、 Laravelの場合はBladeの @js ディレクティブを使って、PHPの変数のデータを安全にJavaScriptに連携することができます。

const user = @js($user);

エラー( @error )や入力値 ( old() )は以下のようにして利用することができます。

const errors = @js((object)$errors->jsonSerialize());
const oldInput = @js((object)old());

objectでキャストしているのは空の連想配列が通常の配列として扱われてしまわないようにするためです。

コンポーネント化

Alpine.jsはコンポーネントの機能を持っていませんが、Webコンポーネントを使ってコンポーネント化を実現できます。

<!-- 定義 -->
<template x-component="toggle">
  <div x-data="{ content: 'default_content', ...$el.getRootNode().host.data() }">
    <p x-text="content"></p>
    <slot>default slot</slot>
  </div>
</template>

<!-- 利用方法 -->
<x-toggle></x-toggle>
<x-toggle content="some_content"></x-toggle>
<x-toggle content="some_content">some_slot</x-toggle>

<!-- 共通設定 -->
<script>
document.addEventListener('alpine:init', () => {
  document.querySelectorAll('template[x-component]').forEach(component => {
    const componentName = `x-${component.getAttribute('x-component')}`
    class Component extends HTMLElement {
      connectedCallback() {
        const shadowRoot = this.attachShadow({ mode: "open" });
        shadowRoot.innerHTML = component.innerHTML;
        Alpine.initTree(shadowRoot);
      }
      data() {
        const attributes = this.getAttributeNames();
        const data = {};
        attributes.forEach(attribute => {
          data[attribute] = this.getAttribute(attribute);
        });
        return data;
      }
    }
    customElements.define(componentName, Component);
  });
});
</script>

まとめ

Alpine.jsと管理画面での利用例を紹介しました。 jQueryより保守性が高く、管理画面に必要な機能要件を満たすだけの最小限の機能を持っており、それでいてVue.jsよりも軽量で手軽に扱えることがAlpine.jsの特徴です。 JavaScriptを使って少しリッチな画面を実装したい場合に今回の記事がお役に立てれば幸いです!

参考URL

dev.to

stackoverflow.com

github.com

*1:今見たらREADMEでこの記述がなくなっていたけど…w