Vueでフォームのコンポーネント化に立ち向かう


はじめに

どのWebサービスにも「フォーム」は必要になるものだと思います。
「問い合わせフォーム」や「新規会員登録フォーム」や「退会フォーム」など…
その他にもマーケティングの施策で、LPからの飛び先として会員獲得のためのフォームを色々作ることもあるかもしれません。

私もWeb業界に入って多くのフォームを作ってきました。
そして、ある時ふと、こう思ったのです。
「これ、フォームをコンポーネント化してしまえば楽になるんじゃなかろうか…」

そう思ってしまえば、もうエンジニアの性として実行せずにいられません。
やってやるです。

まずは、下記のような簡単な問い合わせフォームのコンポーネント化を考えていきます。
フレームワークは軽量なVue + TypeScriptを選択しました。

このフォームの仕様を簡単に説明すると、

  • メールアドレス、電話番号にはバリデーションを設定する
  • バリデーションに引っかかったらエラーメッセージを表示する
  • バリデーションを通過したら「必須」とかかれたラベルを「OK」に変更する

といったところです。

コンポーネントの粒度どうする?

コンポーネントを作ろうとしたときに悩んでしまうのが、コンポーネントの粒度。
どうまとめるのがイケてるのか…とか考えていてもしょうがないので、ここは既存のシステムに乗っかります。
いまだとやはり、利用するのは「Atomic Design」でしょうか。
Atomic Designについて簡単に説明すると、粒度毎に「Atoms」「Molecules」「Organisms」といったように分類するやつですが、詳しくは下記リンク等を参考にしてください。
Atomic Designを分かったつもりになる

今回コンポーネント化するフォームを分類すると下記のようになるかと思います。

これをソースに落とし込んでいきます。

プログラムを書く

まずはさっくりアプリケーションの起動に必要なコードを書いていきます。
なお、これから先のソースは、都合上色々簡略化しながら書いているので、そのままコピっても動きません。
雰囲気を感じ取ってください。

application.ts
import Vue from 'vue';
import App from './App.vue';
import router from './_router';
import store from './_store';

Vue.config.productionTip = false;

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app');

storeは後で使うのでしれっとimportしておきます。

App.vue
<template>
  <div id="app">
    <Form />
  </div>
</template>

<script lang="ts">
  import { Component, Prop, Vue } from 'vue-property-decorator';
  import Form from './components/organisms/Form.vue';

  @Component({
    components: {
      Form
    }
  })

  export default class App extends Vue {}
</script>

ここでついにOrgamismsが登場します。
また、vue-property-decoratorというプラグインを使っているので、そちらに関しては下記をご参照ください。
はじめてのvue-property-decorator (nuxtにも対応)

form.vue
<template>
  <div>
    <div v-for="order in this.$store.state.config.order" :key="order.type">
      <FormBox :config="order" />
    </div>
  </div>
</template>

<script lang="ts">
  import { Component, Vue } from 'vue-property-decorator';
  import FormBox from '../../components/molecules/FormBox.vue';

  @Component({
    components: {
      FormBox
    }
  })

  export default class Form extends Vue {};
</script>

ここで突然storeとか出てきてるんですが、placeholderとかinputのタイプとか一箇所にまとめておいたほうが楽そうなのでそうしてます。

_store.ts
import Vue from 'vue';
import Vuex, { MutationTree } from 'vuex';

Vue.use(Vuex);

type Order = {
  title: string, //FormTitleに入れる文字列
  type: string,  //inputのタイプ
  note: string,  //FormNoteに入れる文字列
  placeholder: string,
  name: string,
  id: string,
  maxlength: number,
  required: boolean
}

type State = {
  order: order[];
}

この情報をMoleculesのフォームの行を生成している「FormBox」に渡しているので、あとはそれに沿ってAtomsを描画していくだけです。
Atomsの例↓

FormTitle.vue
<template>
  <label>
    <slot></slot>
  </label>
</template>

<script lang="ts">
  import { Component, Vue } from 'vue-property-decorator';

  @Component
  export default class FormLabel extends Vue {}
</script>
TextInput.vue
<template>
  <div>
    <input :placeholder="placeholder" :name="name" :id="id" :maxlength="maxlength" @input=onInput>
    <p>{{ error }}</p>
  </div>
</template>

<script lang="ts">
  import { Component, Prop, Vue } from 'vue-property-decorator';

  export default class TextInput extends Vue {
    @Prop() type!: string;
    @Prop() placeholder!: string;
    @Prop() name!: string;
    @Prop() id!: string;
    @Prop() maxlength!: number;
    @Prop() isRequired!: boolean;

    private onInput(event: InputEvent) {
      const type: string = this.type;
      const inputText: string = event.target.value;

      this.inputValid(type, inputText);
    }

    private inputValid(type: string, text: string) {
      switch(type) {
        case 'name':
          this.nameValidate(text);
          break;
        case 'phoneticName':
          this.phoneticNameValidate(text);
          break;
        case 'email':
          this.emailValidate(text);
          break;
        case 'tel':
          this.telValidate(text);
          break;
        default:
          break;
      }
    }
    省略
  }
</script>

まとめ

全体像は追えるように書いたつもりですが、いかがでしょうか。
コンポーネント化を進めるにあたっては下記が重要かなと思います。

  • どのデザインシステムに乗っかるかちゃちゃっと決める
  • コンポーネントの粒度はそのデザインシステムからぶれないようにする

以上です。
引き続きVueの知見で共有できるものがあれば、投稿していく予定です。