Rails 6 + Vue(単一コンポーネント) + vue-router + Vuetify だけどMPA(SPAではない) - Multi SPA

# 構成
ひとつのRailsアプリケーション上に、単一コンポーネントで構成されたSPAを配置します。
まずはひとつのSPAをおくことを目標にしますが、同様に複数のSPAを置くことを意図しています。（よって完全なSPAではありません。またMulti-SPAとなります。）

構成のイメージ：

- /shared
- /private
- /private/episode/:id/schedule
    - ここをSPAにする


# Rails 
Rails は、MVCなMPAアプリケーションをつくります。
Rails 6 webpacker を利用して vue を用います。webpacker と Vue の導入については説明を省きます。

## Layout
SPA 用の layout を設けて、コントローラ（あるいはアクション）で切り替えます。ここでは `application_spa` とします。

application_spa は後述します。コントローラ#アクション schedule で layout を指定すると下記のようになります。これで episode_controller の schedule だけ application_spa を利用することになりました。

```ruby:episode_controller.rb
  def schedule
    respond_to do |format|
      format.html { render :schedule, :layout => 'application_spa' }
    end
  end
```

のちに指定する vue-router と、ここで SPA対象となるRails controller#actionは、同じURLで同じSPAを使う必要があります。（そうでない場合、ひとつのURLで別のものが表示されてしまいます）

# Vue 周りの構成
Vue を SFC（単一ファイルコンポーネント）で利用します。SFCなVueからは axios で API を利用します。Vuetify, vue-router, axios については説明を省略します。

この先の手順は Vue のSingle File Components 利用における Vuetify, vue-router, axios と同じです。

用意するもの：
- Vuejs
- vue-router 
- Vuetify
- axios 

使わないもの：
- Nuxt.js

## Rails View 

### action view
View への routing は、Railsの routes.rb でaction schedule に向けられているとします。それにより `schedule.html.haml` が用いられるため、layout と schedule.html.haml をSPA用にします。

`schedule.html.haml` は、content_for を利用して、 layout で使用する javascript_pack_tag と stylesheet_pack_tag を切り替えられるようにします。切り替えられるようにする理由は、複数のSPAを作成する予定のためです（つまりMulti-SPAです）。

```ruby:view/../schedule.html.haml
- content_for :pack, 'schedules'
#schedules
```

`#schedules` の指定は、のちほどJavaScriptで利用します。

### layout
layoutは、 view で指定されたcontent_forによってJSファイルを読み込むようにします。stylesheet_pack_tag も指定しないと Vuetify のデザインが当たらないので注意しましょう。

```ruby:layout/application_spa.html.haml
!!! 5
%html
  %head
    %title= ""
    = csrf_meta_tags
    = yield :meta
    = stylesheet_pack_tag "#{yield :pack}"
    = javascript_pack_tag "#{yield :pack}"
  %body
    = yield
```

Rails側のViewは以上です。大変シンプルですね。

## Vue の呼び出し
javascript_pack_tag が呼び出す schedule.js が起点（エントリーポイント）となります。（よく見かける  application.js を呼び出す部分に相当します。）

## schedule.js

Rails View で指定した ID #schedules を使います。

```js:packs/schedule.js
// 様々なimport や Vue.use の記述は省略

document.addEventListener('DOMContentLoaded', () => {
  const app = new Vue({
    el: '#schedules',  // schedule.html.haml で指定したもの
    vuetify, // import した vuetify 
    router, // import した vue-router
    render: h => h(App)
  }).$mount()
})
```

## app.vue

schedule.js が `render: h => h(App)` で指定した app.vue は、 `router-view` を呼びます。記述には pug を用いています。

```vue:packs/app.vue
<template lang="pug">
  v-app
    v-app-bar( dark )
      v-toolbar-title Service Name is Unknown
    v-main
      v-container
        v-layout( wrap )
          router-view
</template>
```

router-view に割り当てられたコンポーネントは、次の router の設定で行っています。

## vue router

vue 側の router の設定です。
アプリケーションが `/` から始まらないため、`mode: 'history'` を忘れないようにします。
この先、複数のSPAを作成するため、router も複数にします。そのため、 pack 配下に router/ を作成します。 router を schedule.js へ書かずに別ファイルとし packs/router/schedule.js として管理します。

```js:packs/router/schedule.js
import VueRouter from 'vue-router';
import Schedule from '@/../components/schedule.vue';

const routes = [{
  path: '/episode/:id/schedule', component: Schedule,
  // 増えた path は、Rails 側も対応
}];

export default new VueRouter({
  base: '/private/', 
  mode: 'history',
  routes
});
```

`base: '/private/'` を指定して、アプリケーションの階層を /private 配下にしています。指定すると $route.path に含まれません。 router-link で :to を用いる際にも含まれなくなります。

この router/schedule.js は schedule.js から import されています。

## axios 
SFC 内で axios を用いてAPIにアクセスします。[CSRF_TOKENはこちらを参照しました。](https://tech.medpeer.co.jp/entry/2018/02/26/080000)

.vue の中では、methods で axios を用います。
遷移を伴う一覧を `routers-list` で記述しました。しかし、同じpathに一致する更新は、APIで取得した値を取り直してくれません。 そこで watch により変更を監視します。 [vue-router公式](https://router.vuejs.org/ja/guide/essentials/dynamic-matching.html#%E3%83%91%E3%83%A9%E3%83%A1%E3%83%BC%E3%82%BF%E3%83%BC%E5%A4%89%E6%9B%B4%E3%81%AE%E6%A4%9C%E7%9F%A5)では `beforeRouteUpdate` も使えると記載があります。

今回は`beforeRouteUpdate`を利用しました。


```js:@/../components/schedule.vue
// template は省略
// props や data は省略

methods: {
  getEpisode: function (event) {
    this.axios
      .get('/api/v1/episodes/' + this.$route.params.id + '')
      .then(response => (this.episode = response.data))
      // catch error 省略
  }
},

beforeRouteUpdate(to, from, next){
  this.getEpisode()
  next()
},
// watch を使う場合1
// watch: {
//  $route (to, from) {
//    this.getEpisode()
//  }
// },
//
// watch を使う場合2
// watch: {
//  '$route': 'getEpisode'
// },
// 

mounted () {
  this.getEpisode()
},
```

Vue側は以上です。これで`/private/episode/:id/schedule`を起点としたSPAができました。 

# まとめ
Rails の layout をSPA用に分け、Railsで完結するアプリケーションと、Vue によるSPAを並存させました。Railsアプリケーション全体からみると、部分的にSPAを提供できます。


### 複数SPAを目的にした理由
ここでは、SPAを複数置くことを考えました。どの単位に分けると良いのかは、まだ明確な指針を持っていません。そのため、複数SPAを意図した理由についても簡単に記載します。

Vueのroutingで対応ができる範囲で複数のSPAに分けるのは、あまり意味はないだろうと考えています。エントリーポイントが schedule.js になるのかどうかの違いしかないためです。

一方で、起点ごとに異なるコンポーネントを利用する場合や、vue-router の path が遷移で繋がらない場合は、エントリーポイントを分ける意味があると考えました。

今回、構築したRailsアプリケーションのうち、スケジュール管理部分をVueのSPAにしたいと思いました。それだけでひとつのアプリケーションになり得る単位です。
また、同じアプリケーションを Episode 以外のモデルでも利用するため、再利用性の高いアプリケーションにしたいと考えました。そのためSPAによってModelを横断して取り扱えるようにしようと考えました。

今後、schedule 以外のエントリーポイントが増えることを想定しました。schedule 同様に、単独でひとつのアプリケーションになり得る単位です。たとえばログ管理を考えています。

その際に、SPAを統合するイメージが湧かず、別々に切りたいと考えました。Railsが管理する単位では１つのサービスですが、クライアント側に期待する単位では、それぞれ完結しています。たとえばスケジュール管理とログ管理はまったく別の操作性を期待しています。ログとスケジュールを同時に管理したいこともありません。

以上が、複数SPAを考えた理由です。

### エントリーポイント間の差分
仮に `schedule` 以外に `log` を増やしたいとしましょう。

Rails側の作業はわずかです。
SPAにしたい箇所を、layout `application_spa`指定することと、log.html.haml view の中で #logs を使うこと、です。

Vue は、packs/schedule.js と同等のエントリーポイントとして `packs/logs.js` を用意し、 `el: '#logs'` とします。（もちろん他のelementでも構いません。）
また packs/logs.js は、log 用の router `packs/router/logs.js` を読み込みます。

axios や app.vue, Vuetify は作業が不要です。

つまり、複数のエントリーポイント間の差分は、対応する router/*.js の path と、利用するコンポーネント群の違いになります。

### おわり
以上、Rails の layout をSPA用に分け、エントリーポイントを複数にすることで、Vue によるSPAを複数作成することができました。