MPAのrailsの中にvueで部分的にSPAを作るwith Docker

# なぜそんなことをしたのか
その昔、業務委託でジョインした副業先の既存プロダクトにメッセージ機能の新規機能追加の担当して
当時本業でvueをよく触っていたため

# 技術選定で結構迷った
当時、本業の方は完全なSPAだったのでrailsから分離させてvuecliで開発していたが
副業先はMPAだったため

## WebpackでやるかWebpackerでやるか
答え: Webpacker

理由: 既存のslimのテンプレートエンジンのlayoutを流用するため あと、なんだかんだWebpackerにしてよかった気がする

# やっていく
※mysqlなど入れず最小構成でやります
※dockerの設定やrails、vueの文法や扱いなどはある程度わかっている前提として、細かい解説は省略します

# 1. vue入りのrailsプロジェクトを作る
## 適当なディレクトリを作りDockerで準備していく

```dockerfile:Dockerfile
FROM ruby:2.6.1

ENV LANG C.UTF-8
ENV TZ=Asia/Tokyo

WORKDIR /usr/src/app

SHELL ["/bin/bash", "-o", "pipefail", "-c"]

RUN curl -sL https://deb.nodesource.com/setup_10.x | bash - &&\
    curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - &&\
    echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list &&\
    apt-get update -qq &&\
    apt-get install -y --no-install-recommends nodejs yarn
```

```yml:docker-compose.yml
version: '3'
volumes:
  bundle:
services:
  app:
    build: .
    ports:
      - 3000:3000
    volumes:
      - bundle:/usr/local/bundle:cached
      - .:/usr/src/app:delegated
      - /usr/src/app/node_modules
    stdin_open: true
    tty: true
    command: /bin/bash
```

```shell-session:ホスト
$ docker-compose build
$ docker-compose up -d
$ docker-compose exec app bash
```

```shell-session:docker上
# gem install rails
# rails new . --webpack=vue
```

で、まずはrails newが終わる

# 2. 以降開発をスムーズに進めるためにDockerfileたちをちょっと手直し
```dockerfile:Dockerfile
FROM ruby:2.6.1

ENV LANG C.UTF-8
ENV TZ=Asia/Tokyo

WORKDIR /usr/src/app

SHELL ["/bin/bash", "-o", "pipefail", "-c"]

RUN curl -sL https://deb.nodesource.com/setup_10.x | bash - &&\
    curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - &&\
    echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list &&\
    apt-get update -qq &&\
    apt-get install -y --no-install-recommends nodejs yarn

# ここから下を追記した
COPY Gemfile .
COPY Gemfile.lock .
RUN bundle install

COPY package.json .
COPY yarn.lock .
RUN yarn install
```

```shell:scripts/dev_init.sh
#!/bin/bash

yarn check;
if [ ! $? = 0 ] ; then
  yarn install;
fi

bundle install;
rm -f /usr/src/app/tmp/pids/server.pid;
bin/webpack-dev-server &
rails s -b 0.0.0.0 -p 3000
```

docker-compose起動時に`scripts/dev_init.sh`を実行するように変更

```yml:docker-compose.yml
version: '3'
volumes:
  bundle:
services:
  app:
    build: .
    ports:
      - 3000:3000
      - 3035:3035
    volumes:
      - bundle:/usr/local/bundle:cached
      - .:/usr/src/app:delegated
      - /usr/src/app/node_modules
    stdin_open: true
    tty: true
    command: /bin/bash scripts/dev_init.sh
```
3035ポートはwebpack-dev-serverのホットリロードのために

```yml:config/webpacker.yml
# Note: You must restart bin/webpack-dev-server for changes to take effect

default: &default
  source_path: app/javascript
  source_entry_path: packs
  public_root_path: public
  public_output_path: packs
  cache_path: tmp/cache/webpacker
  check_yarn_integrity: false
  webpack_compile_output: true

  # Additional paths webpack should lookup modules
  # ['app/assets', 'engine/foo/app/assets']
  resolved_paths: []

  # Reload manifest.json on all requests so we reload latest compiled packs
  cache_manifest: false

  # Extract and emit a css file
  extract_css: false

  static_assets_extensions:
    - .jpg
    - .jpeg
    - .png
    - .gif
    - .tiff
    - .ico
    - .svg
    - .eot
    - .otf
    - .ttf
    - .woff
    - .woff2

  extensions:
    - .vue
    - .mjs
    - .js
    - .sass
    - .scss
    - .css
    - .module.sass
    - .module.scss
    - .module.css
    - .png
    - .svg
    - .gif
    - .jpeg
    - .jpg

development:
  <<: *default
  compile: true

  # Verifies that correct packages and versions are installed by inspecting package.json, yarn.lock, and node_modules
  check_yarn_integrity: true

  # Reference: https://webpack.js.org/configuration/dev-server/
  dev_server:
    https: false
    # ここをlocalhostから0.0.0.0に変更した
    host: 0.0.0.0
    port: 3035
    # ここをlocalhostから0.0.0.0に変更した
    public: 0.0.0.0:3035
    hmr: false
    # Inline should be set to true if using HMR
    inline: true
    overlay: true
    compress: true
    disable_host_check: true
    use_local_ip: false
    quiet: false
    pretty: false
    headers:
      'Access-Control-Allow-Origin': '*'
    watch_options:
      ignored: '**/node_modules/**'


test:
  <<: *default
  compile: true

  # Compile test packs to a separate directory
  public_output_path: packs-test

production:
  <<: *default

  # Production depends on precompilation of packs prior to booting for performance.
  compile: false

  # Extract and emit a css file
  extract_css: true

  # Cache manifest.json for performance
  cache_manifest: true

```

そしてdocker再起動

```shell-session
$ docker-compose down
$ docker-compose up
```

そして
http://localhost:3000/ をひらけば見慣れたあの画面が出る


<img width="1044" alt="スクリーンショット 2020-03-10 2.54.48.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236978/dcb6b103-2b35-6fe0-1427-6f3a165d87fb.png">

# 3. 適当なトップページを作る
```ruby:config/routes.rb
Rails.application.routes.draw do
  root 'home#index'
end
```

```ruby:app/controllers/home_controller.rb
class HomeController < ApplicationController
  def index
  end
end
```

```erb:app/views/home/index.html.erb
<h1>home</h1>
```

共通のレイアウトが使われていることがわかりやすいように `application.html.erb` を少しいじる

```erb:app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
  <head>
    <title>App</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
  </head>

  <body>
    <!-- ここに追記した -->
    <h1>layout</h1>
    <%= yield %>
  </body>
</html>
```

すると http://localhost:3000/ はこんな感じになってるはず
<img width="663" alt="スクリーンショット 2020-03-11 2.13.23.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236978/f26d0b4f-b407-1b64-7432-6997e7158339.png">



# 4. Vue.jsのSPA用のためのルーティングを作る
```ruby:config/routes.rb
Rails.application.routes.draw do
  root 'home#index'
  resources :spa, only: [:index]
end
```

```app/controllers/spa_controller.rb
class SpaController < ApplicationController
  def index
  end
end
```

```erb:app/views/spa/index.html.erb
<h1>spa</h1>
```

http://localhost:3000/spa
にアクセスするとこんな感じになってるはず

<img width="612" alt="スクリーンショット 2020-03-11 2.15.45.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236978/811e84af-e24f-91ef-fdba-10087a4dccfe.png">

# 5. まずはVueを画面に出していく
`app/javascript/packs/hello_vue.js`にサンプルが用意されているので
そのコメントにしたがってさっき作ったerbに埋め込んでみる

```erb:app/views/spa/index.html.erb
<h1>spa</h1>

<%= javascript_pack_tag 'hello_vue' %>
<%= stylesheet_pack_tag 'hello_vue' %>
```

すると http://localhost:3000/spa は
<img width="701" alt="スクリーンショット 2020-03-11 2.34.15.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236978/2a95cde9-10fa-234f-0402-e0b191769b5c.png">


となる

# 6. SPAを構築していく
## vue routerを入れる

```shell-session
$ docker-compose exec app yarn add vue-router
```

## まだページ作ってないけど先にvue routerでのルーティングを作っちゃう
```js:app/javascript/packs/router/index.js
import Vue from 'vue';
import Router from 'vue-router';

import TheIndex from '../components/Spa/TheIndex.vue';
import TheDetail from '../components/Spa/TheDetail.vue';

Vue.use(Router);

export default new Router({
  base: '/spa/',
  routes: [
    {
      path: '/',
      name: 'Index',
      component: TheIndex,
    },
    {
      path: '/detail/:id',
      name: 'Detail',
      component: TheDetail,
    },
  ],
});
```

## コンポーネントを作る
```vuejs:app/javascript/packs/components/Spa/TheIndex.vue
<template>
  <div>
    <div v-for="(item, index) in itemList" :key="index" >
      <router-link :to="{ name: 'Detail', params: { id: item.id } }" >
        <div> {{ item.title }} </div>
      </router-link>
    </div>
  </div>
</template>
<script>

export default {
  name: 'TheIndex',
  data() {
    return {
      itemList: [
        { id: 1, title: 'aaaaaaa' },
        { id: 2, title: 'bbbbbbb' },
        { id: 3, title: 'ccccccc' },
      ]
    };
  },
};
</script>
```

```vuejs:app/javascript/packs/components/Spa/TheDetail.vue
<template>
  <div>
    <div>
      <router-link :to="{ name: 'Index' }"> 戻る </router-link>
    </div>

    <div>
      id: {{ $route.params.id }}
    </div>
  </div>
</template>

<script>
export default {
  name: 'TheDetail',
  data() {
    return {};
  },
};
</script>
```

## ルーティングにしたがって表示がされるようにする
```js:app/javascript/packs/hello_vue.js
/* eslint no-console: 0 */
// Run this example by adding <%= javascript_pack_tag 'hello_vue' %> (and
// <%= stylesheet_pack_tag 'hello_vue' %> if you have styles in your component)
// to the head of your layout file,
// like app/views/layouts/application.html.erb.
// All it does is render <div>Hello Vue</div> at the bottom of the page.

import Vue from 'vue'
import App from '../app.vue'
import router from './router/index';

document.addEventListener('DOMContentLoaded', () => {
  const app = new Vue({
    router: router,
    render: h => h(App)
  }).$mount()
  document.body.appendChild(app.$el)

  console.log(app)
})


// The above code uses Vue without the compiler, which means you cannot
// use Vue to target elements in your existing html templates. You would
// need to always use single file components.
// To be able to target elements in your existing html/erb templates,
// comment out the above code and uncomment the below
// Add <%= javascript_pack_tag 'hello_vue' %> to your layout
// Then add this markup to your html template:
//
// <div id='hello'>
//   {{message}}
//   <app></app>
// </div>


// import Vue from 'vue/dist/vue.esm'
// import App from '../app.vue'
//
// document.addEventListener('DOMContentLoaded', () => {
//   const app = new Vue({
//     el: '#hello',
//     data: {
//       message: "Can you say hello?"
//     },
//     components: { App }
//   })
// })
//
//
//
// If the project is using turbolinks, install 'vue-turbolinks':
//
// yarn add vue-turbolinks
//
// Then uncomment the code block below:
//
// import TurbolinksAdapter from 'vue-turbolinks'
// import Vue from 'vue/dist/vue.esm'
// import App from '../app.vue'
//
// Vue.use(TurbolinksAdapter)
//
// document.addEventListener('turbolinks:load', () => {
//   const app = new Vue({
//     el: '#hello',
//     data: () => {
//       return {
//         message: "Can you say hello?"
//       }
//     },
//     components: { App }
//   })
// })

```

```vuejs:app/javascript/app.vue
<template>
  <div id="app">
    <router-view />
  </div>
</template>
```


するとこんな感じでSPAができあがり

### 一覧ページ(TheIndex.vue)
http://localhost:3000/spa/#/
<img width="574" alt="スクリーンショット 2020-03-11 2.43.59.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236978/688f3cc3-8568-ba68-a974-c42dc8f47092.png">


### 詳細ページ(TheDetail.vue)
http://localhost:3000/spa/#/detail/1
<img width="613" alt="スクリーンショット 2020-03-11 2.44.03.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236978/cd41d0d1-e9a9-e4a5-9a1e-eb77deeaa430.png">


あとはよしなにaxiosでAPIを叩いたりしていくだけ

# 7. turbolinksは切っておく
切らないとブラウザバックとかした時にvueの部分だけ表示されなかったりするので

```js:app/javascript/packs/application.js
// This file is automatically compiled by Webpack, along with any other files
// present in this directory. You're encouraged to place your actual application logic in
// a relevant structure within app/javascript and only use these pack files to reference
// that code so it'll be compiled.

require("@rails/ujs").start()
// require("turbolinks").start()
require("@rails/activestorage").start()
require("channels")


// Uncomment to copy all static images under ../images to the output folder and reference
// them with the image_pack_tag helper in views (e.g <%= image_pack_tag 'rails.png' %>)
// or the `imagePath` JavaScript helper below.
//
// const images = require.context('../images', true)
// const imagePath = (name) => images(name, true)
```

みたいな感じでコメントアウトしとく

# 8. デプロイについては
従来通り

```shell-session
$ bundle exec rails assets:precompile
```

で一緒にビルドされるのでとっても簡単

## ローカルで試してみるには
`docker-compose.yml`の`command`を`/bin/bash`に戻してdocker再起動

その後アセットプリコンパイルを実行してproductionでpumaを起動する

```shell-session
$ docker-compose exec -e RAILS_ENV=production app rails assets:precompile
$ docker-compose exec -e RAILS_SERVE_STATIC_FILES=true app rails s -b 0.0.0.0 -p 3000 -e production
```

ただし、このままだとエラーが表示される
なぜなら今回のサンプルコードだとcss使ってないのに`app/views/spa/index.html.erb`に`<%= stylesheet_pack_tag 'hello_vue' %>`を書いてるため(たぶん)

なのでどうするかっていうと

1. `<%= stylesheet_pack_tag 'hello_vue' %>`を消す
2. `config/webpacker.yml`の`production`の`extract_css`を`false`にする
3. vueコンポーネントで適当にcssを使う

のいずれかを対応する
そして再度puma起動すればちゃんと画面表示されるはず

まぁ現実的に開発していく上でcss使わないなんてありえないはずなので1と2は応急対応ですね


以上！！！

ちなみに今回の作業分はこちらに
https://github.com/rh-taro/spa_in_mpa_rails

# おまけ編
デフォルトの設定が若干気持ち悪いので扱いやすいようにリファクタリングする

## 今のままだとなぜ良くないのか

```erb:app/views/spa/index.html.erb
<h1>spa</h1>
上
<%= javascript_pack_tag 'hello_vue' %>
<%= stylesheet_pack_tag 'hello_vue' %>
下
```

みたいにvueを間に挟もうとしても以下のようになってしまう

<img width="494" alt="スクリーンショット 2020-03-12 11.55.32.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236978/32eb9078-0d20-a7b2-e877-6ec0613cbd57.png">

なぜかと言うと `app/javascript/packs/hello_vue.js` で
`document.body.appendChild(app.$el)` ってしていてbody要素に子要素を追加する形でDOM追加しているため

## どう直すか

```js:app/javascript/packs/hello_vue.js
import Vue from 'vue'
import App from '../app.vue'
import router from './router/index';

document.addEventListener('DOMContentLoaded', () => {
  new Vue({
    el: '#app',
    router: router,
    render: h => h(App)
  })
})
```

```vue:app/javascript/app.vue
<template>
  <router-view />
</template>
```

```erb:app/views/spa/index.html.erb
<h1>spa</h1>
上
<div id="app"></div>
下

<%= javascript_pack_tag 'hello_vue' %>
<%= stylesheet_pack_tag 'hello_vue' %>
```

ってすると以下のようになり
erb側でvueのSPAを差し込む場所をコントロールしやすくなりました

<img width="422" alt="スクリーンショット 2020-03-12 12.12.29.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236978/c8fc2a73-a47e-36da-41a0-b810f59e9176.png">

`.$mount()`より`new`の引数で`el`指定の方が見慣れているっていうのと `document.body.appendChild(app.$el)` っていう相対的なDOM追加に違和感を感じた次第でした