ユニファ開発者ブログ

ユニファ株式会社プロダクトデベロップメント本部メンバーによるブログです。

Vue.jsで複数ページにまたがるフォームをVuexを使わずに実装してみる

滑り込みで東京オリンピックの抽選申し込みを済ませました、Webエンジニアの本間です。 どの日でもよいので抽選に当たって欲しいのですが、全部当たると困ってしまう、なかなか悩ましい気持ちになりました。

さて弊社では、現在開発中のプロジェクトのフロントエンドの実装において Vue.js を使っています。 今回、Vue.jsを使った実装をしている中で、複数ページにまたがるフォームの実装で調査した内容を紹介しようと思います。

想定する画面

今回想定するのは、「入力画面1」←→「入力画面2」←→「確認画面」→「完了画面」のような遷移でフォームをsubmitする画面一覧です。

f:id:ryu39:20190528113003g:plain

この手の画面、サーバーサイドのみで実装しようとすると大変だったのですが、モダンフロントエンドの機能を活用することでかなり楽に実装できるようになりました。 このような画面をVue.jsで実装することを考えます。

Vuexを使う場合

Vuexを使う場合、以下の流れで実装するのがスタンダードかなと考えています。

  1. Vuex storeにフォーム用データ構造を定義
  2. 各画面を1つずつVueコンポーネントとして実装
  3. 各画面でのデータの参照や更新は、1のstoreに対して実施
  4. vue-routerを使用し、 <router-link> 等を使ってページ間を移動

f:id:ryu39:20190527194925p:plain

この実装方法はシンプルで何も問題ないように見えます。 ただ実装を進めていく中で細かいのですが、以下の事項が気になってきました。

  • アプリケーション全体の中の数枚の画面のために、グローバルな保存領域であるVuex storeを使うのは違和感がある。
  • この調子でstoreにデータを保存しているとstoreのデータ構造がどんどん大きくなってしまい、Fat storeになってしまう可能性がある。
  • Vuex内のデータ初期化を適切なタイミングで行わないと前回操作したデータが残っている、といった不具合が発生する可能性がある。
  • (サーバーサイドエンジニアだからかもしれませんが)データとバリデーションをまとめたFormクラスを定義して実装を進めたいのだが、VuexにObjectを保存するとデータを変更してもDOMに反映されないことがあったため避けたい。

上記の理由からVuexを使わずに実装する方法がないか調査したのが、この記事の内容になっています。

Vuexを使わない場合

Vuexを使わない場合ですが、以下の流れで実装することができました。

  1. 1つの親コンポーネントを定義し、このコンポーネントのdataに画面間で保存したいデータ構造、初期値を定義する。
  2. 各画面は Dynamic component で切り替えることを前提に、1つずつVueコンポーネントとして実装する。
  3. 各画面がフォームのデータを参照、変更したい場合、親コンポーネントのdataに対して行う。
  4. Dynamic componentはURLのhash値を見て切り替えるようにする。
  5. vue-routerを通してURLのhashの値を変えることでDynamic componentの切り替えを行う。これで、1つのページ内での擬似的なページ遷移を実現する。

f:id:ryu39:20190528120552p:plain

上記を実現する上でキーとなる技術を2つ紹介します。

  • Dynamic componentを使った擬似ページ遷移
  • .syncを使った双方向バインディング

Dynamic componentを使った擬似ページ遷移

Vue.jsの公式ドキュメントの例では、Dynamic componentを使ってページ内で要素をtoggleする例が示されています。 このtoggleする要素をサブページにすることが基本方針です。

さらにtoggleのスイッチをURLのhashにして、vue-router経由で切り替えることで、history back/forwardにも対応できるようにしています。

Parent.vue

<template>
  <div>
    <transition :name="fade" mode="out-in">
      <!-- susPageの値に応じてコンポーネントを切り替えて、擬似的にページ遷移を表現 -->
      <component :is="subPage"></component>
    </transition>
  </div>
</template>

<script>
import Input1SubPage from './subPages/Input1.vue'
import Input2SubPage from './subPages/Input2.vue'
import ConfirmSubPage from './subPages/Confirm.vue'
import CompleteSubPage from './subPages/Complete.vue'

export default {
  computed: {
    subPage () {
      // URLのhashの値に基づいて、返すコンポーネントを切り替え
      switch (this.$route.hash) {
        case '#input2':
          return Input2SubPage
        case '#confirm':
          return ConfirmSubPage
        case '#complete':
          return CompleteSubPage
        default:
          return Input1SubPage
      }
    }
  }
}
</script>

Input1.vue(他のコンポーネントもほとんど同じ)

<template>
  <div>
    <h1>入力画面1</h1>

    <!-- vue-routerを使ってURLのhashを変更 -->
    <router-link :to="{ hash: '#input2' }">次へ</router-link>
  </div>
</template>

こんな感じに動作します。

f:id:ryu39:20190528114744g:plain

.syncを使った双方向バインディング

ページ遷移するだけでは親コンポーネントに保存されているフォームデータを参照、更新することができません。

これを実現するためにはいくつかやり方があると思うのですが、今回は .syncを使った双方向バインディング を使いました。 .sync 修飾子は v-model を複数の属性に対応したものになります。 この修飾子を使うことで、親コンポーネントに保存されている任意のデータを子コンポーネントから参照、更新できるようになります。

f:id:ryu39:20190528172732p:plain

Parent.vue

<template>
  <div>
    <child :email.sync="form.email" :name.sync="form.name" :age.sync="form.age">
      <!--
        :email.sync="form.email" は
        :email="form.email" と
        @update:email="form.email = $event" の2つを指定したのと同じ。
        そのため、子コンポーネントからemailの値を変更したい場合、
        $emit('update:email', 'newValue') をすれば更新できる。
      -->
    </child>
  </div>
</template>

<script>
// UserFormは、email, name, ageの3つの属性を持つオブジェクト
// 最終的には、バリデーションなどの機能も追加予定。
import UserForm from '../../forms/UserForm'

import Child from './components/Child'

export default {
  components: {
    Child
  },
  data () {
    return {
      form: new UserForm()
    }
  }
}
</script>

Child.vue

<template>
  <div>
    <div>
      メールアドレス:
      <input type="email" v-model="syncedEmail">
    </div>
    <div>
      名前:
      <input v-model="syncedName">
    </div>
    <div>
      年齢:
      <input type="number" v-model.number="syncedAge">
    </div>
  </div>
</template>

<script>
export default {
  props: {
    email: {
      type: String,
      required: true
    },
    name: {
      type: String,
      required: true
    },
    age: {
      type: Number,
      required: true
    }
  },
  computed: {
    // v-modelを使って update:email イベントを発生させたいので、
    // getter/setterを使ったトリックを使用。
    syncedEmail: {
      get () {
        return this.email
      },
      set (val) {
        this.$emit('update:email', val)
      }
    },
    syncedName: {
      get () {
        return this.name
      },
      set (val) {
        this.$emit('update:name', val)
      }
    },
    syncedAge: {
      get () {
        return this.age
      },
      set (val) {
        this.$emit('update:age', val)
      }
    }
  }
}
</script>

こんな感じで動きます。 子コンポーネントで変更した値が、親コンポーネントのFormデータに反映されていることがわかります。

f:id:ryu39:20190528125500g:plain

最終結果

今回紹介した機能を組み合わせると、Vuexなしでも複数ページにまたがるフォーム入力が実現できています。 (紹介した機能以外に transition を使ったアニメーションやFormクラスのバリデーションなんかも追加してあります)

f:id:ryu39:20190528164758g:plain

今回使ったソースコードは以下の場所に公開していますので、詳細が知りたい方は git clone して手元で動かしていただければと思います。

https://bitbucket.org/unifa-public/vue-multiple-pages-form-test/src/master/

まとめ

複数ページにまたがるフォーム入力画面をVuexを使わずに実装する方法を紹介いたしました。 筆者がフロントエンド専門ではないため、間違い等があるかもしれません。その場合、コメント等で指摘していただけるとありがたいです。

今回はstoreが肥大化することを懸念してVuexを使わずに実現できる方法を探していましたが、Vuexには モジュール という機能があり、storeを階層構造で細分化できるようになっています。 この機能を積極的に使って細分化していけば、storeに保存するデータがたくさんあったとしてもメンテナンスに困らないのかな、と考えるようにもなってきました。 Vuexを使うパターンを用いるのか、使わないパターンを用いるのかは、メリット・デメリットをメンバー間で話し合い、プロジェクトごとで決めていくのがよさそうです。

以上になります。参考になったら幸いです。 最後までご覧いただきありがとうございました。