SPAじゃないVue.js〜Railsとともに〜

# はじめに
私はもともとRailsのエンジニアとしてWebエンジニアとしてのキャリアをスタートしました。しかし、Railsだけでできることは限りがあり、フロントエンドの挙動をリッチにするためには、JavaScriptの利用が欠かせません。私も最初の頃はKnockout.jsを使ったプロダクトをメンテナンスしていた時代もありましたが、最近はVue.jsを利用することがほとんどです。そこで問題となってくるのはRailsとVue.jsをいかに連携させるかということです。例えば、単純にフロントエンドをSPAとし、Railsは単なるAPIとしての役割のみを持たせるという方法もあります。しかし、開発効率を考慮するにあたって、Railsによるサーバーサイドレンダリングを完全に利用しないということは必ずしも得策ではないと思います。Railsで実現可能なことをわざわざVue.jsでやる必要はないからです。例えばサーバーサイドの処理であればクライアントに依存せずコントロールすることができますし、経験上、単純なページを作るにおいても、Vue.jsによる開発よりも、Railsによる開発の方が短時間に行うことができると思います。
私は、ここ数年、SPAじゃないVue.js開発を何度か行なってきました。そこで得られた知見を元に、Vue.jsをSPAではなく用いる際に実装した時のポイントを紹介したいと思います。

# テンプレートエンジンはerbを使うかslimを使う場合はフロントエンドはpugを使う
erbの他に、haml、slimなどのテンプレートエンジンがありますが、開発効率の向上という観点では、Vue.jsとRailsを両方使う方場合、圧倒的にerbを利用することをお勧めします。理由は、hamlやslimの記法はVue.jsのコンポーネントのテンプレートを記法と異なっており、開発時に行ったり来たりする場合、脳内での変換に時間がかかってしまうこと、もともとサーバーサイドのテンプレートにあったものをフロントエンドに持っていきたいと思った場合、いちいち記法を変換しなければならなく、非常に非効率になってしまうからです。
過去に、仕事やプライベートにおいて、hamlとVue.js、slimとVue.jsの組み合わせで開発をしていたことがありますが、最初は良いものの、開発をしていくと自分はなぜ毎回コードを変換しているのだろうという不思議な気持ちになりました。
また、slimライクな記法をvueファイルの中で記述できるpugというライブラリを使えば、サーバーサイドはslim、フロントエンドはpugというこというセットも良いと思います（私自身実案件で利用したことはありません）。ただ、erbと比較すると微妙な違いがあるのでそこは開発者が吸収してあげる必要がありそうです。

# サーバーサイドからフロントエンドへの値の受け渡し
サーバーサイドからフロントエンドの値の受け渡しには、
1. ユーザーの操作によって動的に変わるもの
2. レンダリング時に決まっていてそのページ内で変化しないもの
の２つが考えられます。１つ目は、例えば、郵便番号を入力して住所を取得するといった挙動です。データが少量であればレンダリング時に静的にフロントエンドに値を渡してあげることも可能ですが、郵便番号のような大量のデータの場合は、APIを呼ぶしか方法はありません。２つ目は、例えば設定値など、レンダリング時に決まっている値に関しては必ずしもAPIを呼ぶ必要性はありません。こうした場合の昔ながらの方法は、gonというgemを利用する方法です。JavaScriptのwindowオブジェクトにgonという属性を追加され、JavaScriptのファイルのどこからでもgonを通じてサーバーサイドから連携された値を利用することができます。
gonは、非常に便利なのですが、どこでも利用できるが故に値が変更されたり（普通そんなことはしないと思いますが）、必ずしもスマートな方法とは言えない場合があります。そこで、HTMLタグの属性として渡してあげるようにしています。

```erb:erb
<div
  id="app_<%= "#{controller.controller_name}_#{controller.action_name}" %>"
  data-server-side-values="<%= @serverSideValues.to_json %>"
  data-server-side-errors="<%= @serverSideErrors.to_json %>"
>
  <app>
</div>
```
```ruby:controller
  def new
    @serverSideValues = {
      authenticity_token: view_context.form_authenticity_token,
      front_provider: { name: 'てすと株式会社' },
    }.deep_stringify_keys.deep_transform_keys { |key| key.camelize(:lower) }
    # ruby側ではsnake_caseを、JavaScript側ではlowerCamelCaseを使うことを想定し、ruby側で変換。
  end
```

このとき、Vue側では、`beforeMount`で`store`にセットするようにします。`mounted`で同様の処理を行うこともできますが、`store`にセットされる前に`serverSideValues`が呼び出されることがあり、オブジェクトのネストが深いとエラーになってしまいます。[^1]

```javascript:js
import Vue from 'vue';
import { mapState, mapGetters, mapMutations } from 'vuex';
import store from '@/javascripts/pages/orders/new/store';

import App from '@/javascripts/pages/orders/new/app';

window.app = new Vue({
  el: '#app_orders_new',
  components: {
    App,
  },
  beforeMount () {
    const params = this.$el.attributes['data-server-side-values'].textContent;

    if (params !== '') {
      this.setServerSeideValues(JSON.parse(params));
    }
  },
  mounted () {
  },
  methods: {
    ...mapMutations([
      'setServerSeideValues'
    ])
  },
  store,
});
```
```js:store
import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

const state = {
  serverSideValues: {},
};

const mutations = {
  setServerSeideValues: (state, val) => {
    state.serverSideValues = val
  },
};

const getters = {
}

const actions = {
}

export default new Vuex.Store({
  state,
  mutations,
  getters,
  actions,
});
```

[^1]: その場合に独自にrubyでいう`dig`などの関数を実装して回避することも可能です。

# リロード時や確認画面からの戻り時に、フォームの値を保持する
これは必ずしもRailsとは関係のない挙動ですが、入力途中でリロードを行なった場合、値が保持されていてほしいです。また、申し込み画面（newアクション）から確認画面（confirmアクション）に行った後、確認画面上の「戻る」リンクなどで申し込み画面に戻った場合も値が保持されているべきでしょう。この時、サーバーサイドだけで行おうとすると、例えば`session`の中に値を保持してあげて戻った場合に復元してあげるなどの処理が必要です。申し込み画面が複数ページにまたがる場合はかなり厄介になります。Railsにおける申し込み画面が複数ページに渡る場合の実装についての詳細は別の記事に譲るとして、今回は、`window`オブジェクトの`sessionStorage`に入れる実装について紹介します。`sessionStorage`とは、Web Storageの１種で、ユーザーのローカル環境（ブラウザ）にデータを保存するための仕組みです。 データの保存・上書き・削除・全クリアなどの操作は、JavaScriptで行うことができる機能です。[^2]
Web Storageには`sessionStorage`と`localStorage`の２種類があり、それぞれ用途が異なります。`localStorage`はブラウザを閉じても値が保持され、`sessionStorage`はブラウザを閉じた場合、値が破棄されます。加えて、`sessionStorage`の場合、複数のタブで値を共有しません。申し込み画面には往々にして、複数タブ問題というのが存在します。ユーザーが複数のタブを同時に開いて異なる申し込みをした場合、実装によっては、意図しない申し込みが行われてしまうことがあるという問題です。私はこの問題を内包したままのプロダクトをメンテナンスした経験があり、新規に作る場合は複数タブ問題が生じないように実装するように務めています。その方法の一つが`sessionStorage`を使う方法です。
まず、`vue`ファイルの実装例は以下のようになります。ポイントは、inputイベント（`@input`）でstoreに入力された値を保持してあげます。（`setFailyName`は`store`の`state`に値をセットするMutationです）

```vue:appコンポーネント
<template>
  <div>
    <input
      class="input"
      type="text"
      placeholder="苗字"
      :value="familyName"
      @input="(e) => (setFamilyName(e.target.value))"
    >
  </div>
</template>

<script>
import { mapState, mapMutations } from 'vuex';

export default {
  data () {
    return {
    }
  },
  computed: {
    ...mapState([
      'serverSideValues',
      'familyName',
    ]),
  },
  methods: {
    ...mapMutations([
      'setFamilyName',
    ]),
  },
}
</script>
```
`store`側の実装は以下のようになります。ここでは、[vuex-persistedstate](https://www.npmjs.com/package/vuex-persistedstate)というモジュールを利用しています。Web Storageに`store`の`state`の値を保存するには、[vuejs-storage](https://www.npmjs.com/package/vuejs-storage)もありますが、現在は、`vuex-persistedstate`の方が人気が高いようです。実装としては`plugins`の中の`createPersistedState`の部分になります。`storage`に`window.sessionStorage`を指定し、`sessionStorage`に保存することを指定します。`key`の部分は`sessionStorage`を利用している他の部分と重複しないように設定してあげる必要があります。`paths`は`state`の値で、`store`に他のstoreモジュールを利用している場合は、`.`（ドット）で繋げます。

```js:store
import Vue from 'vue';
import Vuex from 'vuex';
import createPersistedState from 'vuex-persistedstate';

Vue.use(Vuex);

const state = {
  serverSideValues: {},
  familyName: '',
};

const mutations = {
  setServerSeideValues: (state, val) => {
    state.serverSideValues = val
  },
  setFamilyName: (state, val) => {
    state.familyName = val
  },
};

const getters = {
}

const actions = {
}

const plugins = [
  createPersistedState({
    storage: window.sessionStorage,
    key: 'orders',
    paths: [
      'familyName',
      // 'postcode.postcode1' // postcodeモジュールのpostcode1というstateを永続化する場合。
    ],
  }),
];

export default new Vuex.Store({
  modules: {
  },
  state,
  mutations,
  getters,
  actions,
  plugins: plugins,
});
```

[^2]: http://www.htmq.com/webstorage/

# サーバーサイドエラーの連携
サーバーサイドのエラーの形式は、次のようにモデルの属性の`Hash`を生成し、エラーメッセージを配列で持たせるようにしています。こうすることで、複数のエラーメッセージが含まれても対応できるようにしています。

```JSON
{
  "family_name" => [
    "Family name is too long (maximum is 10 characters)"
  ]
}
```
newページからconfirmへの遷移はpostで`invalid`の場合は、`new`アクションに`redirect`するようにしています。ここで、`flash`でエラーを渡しています。前述のエラーの形式に整える処理は、`order_errors`に実装しています。`invalid?`を実行すると、モデルの`errors`が取得できるのでこれを利用して処理をするのが便利です。

```ruby
class OrdersController < ApplicationController
  def new
    @serverSideValues = {} # 省略
    @serverSideErrors =
      flash[:error]&.deep_stringify_keys&.deep_transform_keys { |key| key.camelize(:lower) } || ''
  end

  def confirm
    @order = Order.new(order_params)

    if @order.invalid?
      flash[:error] = order_errors
      redirect_to action: :new
    end
  end

  private

  # order_paramsメソッドは省略

  def order_errors
    @order.errors.messages.keys.map do |key|
      [key.to_s, @order.errors.full_messages_for(key)]
    end.to_h
  end
end
```
サーバーサイドのエラーのフロントエンドへの受け渡しは`data-server-side-errors`を経由して取得します。これも`serverSideValues`と同様に`beforeMount`で行うと良いでしょう。

```js
  beforeMount () {
    // setServerSideValuesの処理は省略

    const errors = this.$el.attributes['data-server-side-errors'].textContent;

    if (errors !== '') {
      this.setServerSeideErrors(JSON.parse(errors));
    }
  }
```
フロントエンドでエラーを表示する場合は、`serverSideErrors`を利用します。例えば`serverSideErros.familyName`の配列が存在すれば表示といった処理を行います。この時、エラーがない場合は、`severSideErrors.familyName`の値が`undefined`になってしまうので、`ruby`の`dig`ライクな関数を自前で用意することでエラーが発生しないようにしています。あるいはES2020以降の場合は、オプショナルチェーン（`?.`）を利用すると良いでしょう。

```vue
<template>
  <div>
    <input
      class="input"
      type="text"
      placeholder="苗字"
      name="order[family_name]"
      :value="familyName"
      @input="(e) => (setFamilyName(e.target.value))"
    >
    <div
      v-show="dig(serverSideErrors, 'familyName', 'length') > 0"
    >
      <span v-for="error in serverSideErrors.familyName">
        {{ error }}
      </span>
    </div>
  </div>
</template>

<script>
import { mapState, mapMutations } from 'vuex';
import dig from '@/javascripts/modules/dig'; // digをimport

export default {
  data () {
  },
  computed: {
  },
  methods: {
    dig, // template内で利用できるようにmethodに追加。
  },
}
```

```js:javascripts/modules/dig.js
export default function dig (target, ...keys) {
  let digged = target;
  for (const key of keys) {
    if (typeof digged === 'undefined' || digged === null) {
      return undefined;
    }
    if (typeof key === 'function') {
      digged = key(digged);
    } else {
      digged = digged[key];
    }
  };
  return digged;
};
```

他にもRailsとVue.jsの連携方法について思いつき次第逐次追記していきます。