Vue.js in RoR の最新サンプルを作ったよ

Vus.jsをRoRに入れ込む形の実装サンプルを作りました。
**sprockets/webpackerなし**なのがポイントです。

出来上がったものは[こちら](https://github.com/isuke/rails-vue-sample)から見ることができます。

**2022/02/15 追記**: [webpackの代わりにviteを使うパターン](https://github.com/isuke/rails-vue-sample/tree/vite)も作りました。内容は大体同じ。

自分用の備忘録を兼ねて実装内容をここに残しておこうと思います。

## 0. 技術スタック

- Ruby 2.7.1
- Rails 7.0.4
- rspec
- Node.js 16.18.1
- Vue.js 3.2.45
- webpack 5.75.0
- TypeScript
- headless chrome

## 1. rails new

なにはともあれ`rails new`から始めましょう。
いろいろskipしていますが、「sprockets/webpackerなしなのでいらないもの」と「サンプルなのでいらないもの」があります。
「sprockets/webpackerなしなのでいらないもの」は必ずスキップしてください。
「サンプルなのでいらないもの」は必要に応じて入れてください。

```sh
$ rails new \
  --skip-action-mailer \   # サンプルなのでいらないもの
  --skip-action-mailbox \  # サンプルなのでいらないもの
  --skip-action-text \     # サンプルなのでいらないもの
  --skip-active-job \      # サンプルなのでいらないもの
  --skip-active-storage \  # サンプルなのでいらないもの
  --skip-action-cable \    # sprockets/webpackerなしなのでいらないもの
  --skip-asset-pipeline \  # sprockets/webpackerなしなのでいらないもの
  --skip-javascript \      # sprockets/webpackerなしなのでいらないもの
  --skip-hotwire \         # sprockets/webpackerなしなのでいらないもの
  --skip-test \            # rspecを使うため
  --skip-bundle \
  .
```

次に不要なディレクトリを削除します。

```sh
$ rm -rf app/assets app/helpers
```

これで準備完了。

## 2. frontendの準備

次にfrontendに必要なものをまるっとインストールしてしまいましょう。

```sh
$ npm i vue axios vue-axios destyle.css
$ npm i -D \
  webpack webpack-cli webpack-dev-server \
  webpack-merge clean-webpack-plugin assets-webpack-plugin @types/webpack-env \
  vue-loader vue-style-loader vue-template-compiler \
  typescript ts-loader \
  sass sass-loader css-loader style-loader \
  babel-loader babel-preset-typescript-vue3 \
  @babel/core @babel/preset-env @babel/preset-typescript
```

`.gitignore`にnode_modulesを書き足しておきましょう

```diff:.gitignore
  ...
+ # Ignore node modules
+ node_modules
  ...
```

あと、必要なディレクトリも準備します。

```sh
$ mkdir -p app/frontend/{assets,components,styles,plugins}
```

## 3. 空のページを表示させる

空のページを表示させるようにしましょう。

```rb:config/routes.rb
Rails.application.routes.draw do
  root 'pages#home'

  get 'home', to: 'pages#home'
  get 'user', to: 'pages#user'
end
```

homeとuserの２つのページのルーティングを設定しました。

コントローラーも書きましょう。

```rb:app/controllers/pages_controller.rb
class PagesController < ApplicationController
  def home
    render 'empty'
  end

  def user
    render 'empty'
  end
end
```

`empty`ファイルをレンダーするようにしたので`empty`ファイルを用意します。これは空ファイルでいいです。

```sh
$ mkdir app/views/pages && touch app/views/pages/empty.html
```

`application.html.erb`もすこし書き換えます。

```erb:app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
  <head>
    <title>RailsVueSample</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
  </head>

  <body>
  </body>
</html>
```

これで空のページが表示できたと思います。

## 4. frontendを実装する

frontendの実装していきます。

まず、エントリーポイントとなる`main.ts`を書いていきます。

```ts:app/frontend/main.ts
//
// Styles
//
import 'destyle.css'

//
// Scripts
//
import { createApp } from 'vue'

import App from './App.vue'

const app = createApp(App)
app.mount('#app')
```

TypeScriptでVue.jsが使えるように以下のファイルも用意しておきます。

```ts:app/frontend/shims-vue.d.ts
/* eslint-disable */
declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}
```

つぎにwebpackの設定をします。
以下のファイルを用意してください。

```js:webpack.config.js
const path = require('path')

const AssetsPlugin = require("assets-webpack-plugin")
const { CleanWebpackPlugin } = require("clean-webpack-plugin")
const { merge } = require("webpack-merge");
const { VueLoaderPlugin } = require("vue-loader")

let config = {
  entry: "./app/frontend/main.ts",
  output: {
    path: path.resolve(__dirname, "public", "dist"),
  },
  resolve: {
    extensions: [".js", ".ts", ".scss", ".vue"],
    alias: {
      vue: "@vue/runtime-dom",
      vue$: "vue/dist/vue.esm.js",
    },
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: "vue-loader",
      },
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            presets: [
              "@babel/preset-env"
            ]
          }
        },
      },
      {
        test: /\.ts$/,
        use: [
          {
            loader: "babel-loader",
            options: {
              presets: [
                "@babel/preset-env",
                "babel-preset-typescript-vue3",
                "@babel/preset-typescript",
              ],
            },
          },
        ],
      },
      {
        test: /\.css$/,
        use: ["vue-style-loader", "css-loader"],
      },
      {
        test: /\.scss$/,
        use: ["vue-style-loader", "css-loader", "sass-loader"],
      },
    ],
  },
  plugins: [
    new AssetsPlugin({ removeFullPathAutoPrefix: true }),
    new VueLoaderPlugin(),
  ],
};

module.exports = (env, argv) => {
  if (argv.mode === "development") {
    config = merge(config, {
      output: {
        filename: "build.js",
        publicPath: "http://localhost:3001/",
      },
      devtool: "eval",
      devServer: {
        port: "3001",
        hot: true,
        headers: {
          "Access-Control-Allow-Origin": "*",
          "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
          "Access-Control-Allow-Headers": "X-Requested-With, content-type, Authorization",
        },
      },
    });
  } else if (argv.mode === "production") {
    config = merge(config, {
      output: {
        filename: "build-[fullhash].js",
        publicPath: "dist/",
      },
      plugins: [
        new CleanWebpackPlugin()
      ],
    })
  }

  return config
}
```

buildとwatchができるように`package.json`も書き足しましょう。

```diff:package.json
  {
    ...
+   "scripts": {
+     "dev": "webpack-dev-server --mode=development",
+     "build": "webpack --mode=production"
+   },
    ...
  }
```

buildしたときの出力先のディレクトリも用意しておきましょう。

```sh
$ mkdir public/dist
```

今後、このディレクトリにbuildしたファイルが吐き出されるので、このディレクトリ配下を`.gitignore`に入れておきましょう。

```diff:.gitignore
  ...
  # Ignore node modules
  node_modules
  ...
+ # Ignore frontend files
+ /public/dist
  ...
```

ページコンポーネントも用意しておきましょう。

```vue:app/frontend/pages/Home.vue
<script setup lang="ts">
const msg = "Hello Home"
</script>

<template>
  <div class="home">
    <h1 class="title">{{ msg }}</h1>
  </div>
</template>

<style lang="scss" scoped>
.home {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100vh;

  background: #c1c1ff;

  > .title {
    font-size: 2rem;
    font-weight: bold;
  }
}
</style>
```

```vue:app/frontend/pages/User.vue
<script setup lang="ts">
</script>

<template>
  <div class="user">
    <h1 class="title">Users</h1>
  </div>
</template>

<style lang="scss" scoped>
.user {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;

  width: 100%;
  height: 100vh;

  background: #ffd0d0;

  > .title {
    font-size: 2rem;
    font-weight: bold;
  }
}
</style>
```

なんの変哲もないvueファイルです。

## 5. buildしたファイルを読み込めるようにする

さて、buildしたファイルを読み込めないといけません。
実はwebpackを設定したときに[assets-webpack-plugin](https://www.npmjs.com/package/assets-webpack-plugin)というものを設定していたのですが、気がついたでしょうか？

これはbuildやwatchをしたときにファイルの出力先をjsonで出力してくれるプラグインです。
デフォルトではファイル名は`webpack-assets.json`になっています。
ファイルの中身はこんな感じです。

```json:watchしたとき
{"main":{"js":"http://localhost:3001/build.js"}}
```

```json:buildしたとき
{"main":{"js":"dist/build-fb8c6bd492d5c8b2a4db.js"}}
```

このファイルをrailsで読み込んでファイルの出力先を取得すれば良さそうです。

`application_controller.rb`と`pages_controller.rb`に手を加えていきます。

```diff:app/controllers/application_controller.rb
  class ApplicationController < ActionController::Base
+   def script_for(bundle)
+     JSON.load(File.open(Rails.root.join('webpack-assets.json')))[bundle]['js']
+   end
  end
```

```diff:app/controllers/pages_controller.rb
  class PagesController < ApplicationController
+   before_action :set_script_path
  
    def home
      render 'empty'
    end
  
    def user
      render 'empty'
    end
  
+ private
+ 
+   def set_script_path
+     @script_path = script_for('main')
+   end
  end
```

これで`@script_path`にjsファイルの出力先が入るようになりました。
さっそく`application.html.erb`で読み込みましょう。

```diff:app/views/layouts/application.html.erb
  <!DOCTYPE html>
  <html>
    <head>
      <title>RailsVueSample</title>
      <meta name="viewport" content="width=device-width,initial-scale=1">
      <%= csrf_meta_tags %>
      <%= csp_meta_tag %>
    </head>

    <body>
+     <script src="<%= @script_path %>"></script>
    </body>
  </html>
```

headタグではなくbodyタグ（の最後）に入れないと動作しないので気をつけましょう。
地味なはまりポイントです。

あとは忘れずに`.gitignore`に`webpack-assets.json`を入れておきましょう。

```diff:.gitignore
  ...
  # Ignore frontend files
  /public/dist
+ webpack-assets.json
  ...
```

## 6. ページごとに違うコンポーネントを使うようにしよう

そういえば`App.vue`ファイルをまだ書いてませんでした。
`App.vue`ファイルでやりたいことはページごとに違うページコンポーネントを使うようにすることです。
そのためにApp.vueにどのページを表示させるかを渡すようにします。

`application.html.erb`を以下のように書き足します。

```diff:app/views/layouts/application.html.erb
  <!DOCTYPE html>
  <html>
    <head>
      <title>RailsVueSample</title>
      <meta name="viewport" content="width=device-width,initial-scale=1">
      <%= csrf_meta_tags %>
      <%= csp_meta_tag %>
    </head>
  
    <body>
+     <div id="app" data-name="<%= action_name.camelize(:upper) %>"></div>
  
      <script src="<%= @script_path %>"></script>
    </body>
  </html>
```

`data-name="<%= action_name.camelize(:upper) %>"` のところがポイントです。
`action_name`にはpages_controllerのアクション名が入っているのでここの値は
- homeのとき: `Home`
- userのとき: `User`
になります。

これをVue.jsで受け取って動的コンポーネントを使ってコンポーネントを使い分けるようにすればいいわけですね。

`App.vue`は以下のようになります。

```vue:app/frontend/App.vue
<script lang="ts">
import { defineComponent } from 'vue'

import Home from './pages/Home.vue'
import User from './pages/User.vue'

export default defineComponent({
  components: {
    Home,
    User,
  },
  data() {
    return {
      componentName: document.getElementById('app')?.dataset?.name
    }
  },
})
</script>

<template>
  <component :is="componentName" />
</template>

<style lang="scss" scoped>
</style>
```

ここで`<script setup>`を使うと何故か動的コンポーネントが動作しなかったので`<script setup>`は使っていません。
ここもはまりポイント。

さてこれでようやく画面が表示できるようになりました。
railsとwebpack-dev-serverを起動して表示を確認してみましょう。

```sh
$ bundle exec rails s
```

```sh
$ npm run dev
```

localhost:3000 で「Hello Home」、localhost:3000/user で「Users」が表示されればOKです。

## 7. fetchしよう

フロントからRailsにデータを取得できるようにしましょう。
ここではaxiosを使います。

まずはAPIを作りましょう。
`routes.rb`にAPI用のルーティングを書きます。

```diff:config/routes.rb
  Rails.application.routes.draw do
    root 'pages#home'
  
    get 'home', to: 'pages#home'
    get 'user', to: 'pages#user'
  
+   namespace :api do
+     defaults format: :json do
+       resources :users, only: %i[index]
+     end
+   end
  end
```

コントローラーも作成しましょう。
本来ならここでDBからデータを取ってくるのですが、本質ではないのでここでは定数を返すことにします。

```rb:app/controllers/api/users_controller.rb
class Api::UsersController < ApplicationController
  def index
    @users = [
      { first_name: 'Ciar'    , family_name: 'Gethsemane' },
      { first_name: 'Sundara' , family_name: 'Josefa' },
      { first_name: 'Zisel'   , family_name: 'Itzel' },
      { first_name: 'Hadriana', family_name: 'Daniele' },
    ]
  end
end
```

jbulderも書きます。

```rb:app/views/api/users/index.json.jbuilder
json.array! @users do |user|
  json.first_name  user[:first_name]
  json.family_name user[:family_name]
end
```

さて、ここで問題があります。
それはjsonのキーは「Railsはsnake_caseで渡したいが、js(ts)はcamelCaseで受け取りたい」ということです。
幸いなことにこれは簡単に解決できます。
以下のファイルを作成してrailsサーバーを再起動するだけです。

```rb:config/initializers/jbuilder.rb
Jbuilder.key_format camelize: :lower
```

では、frontend側を実装していきましょう。
まず`main.ts`を書きます。

```diff:app/frontend/main.ts
  ...
  //
  // Scripts
  //
  import { createApp } from 'vue'
  
  import App from './App.vue'
  
+ import axios from 'axios'
+ import VueAxios from 'vue-axios'
  
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
+ const $axios = axios.create({
+   headers: {
+     'X-CSRF-Token': csrfToken
+   }
+ })
  
  const app = createApp(App)
+ app.use(VueAxios, $axios)
+ app.provide('$axios', app.config.globalProperties.axios)
  app.mount('#app')
```

これでどのコンポーネントからもaxiosが使えるようになります。
csrfTokenの設定を忘れずに。これがないとpostできません。

準備ができたのでUser.vueでfetchしてその値を表示させてみましょう。
以下のように書き換えます。

```diff:app/frontend/pages/User.vue
  <script setup lang="ts">
+ import { ref, inject } from 'vue'
  
+ type User = {
+   firstName: string;
+   familyName: string;
+ }
  
+ const $axios: any = inject('$axios')
  
+ const users = ref<User[]>([])
  
+ $axios.get('/api/users')
+   .then((response: { data: User[] }) => {
+     users.value = response.data
+   })
  </script>
  
  <template>
    <div class="user">
      <h1 class="title">Users</h1>
  
+     <ul class="list">
+       <li class="item" v-for="user in users" :key="user.firstName">
+         {{ user.firstName }} {{ user.familyName }}
+       </li>
+     </ul>
    </div>
  </template>
  
  <style lang="scss" scoped>
  .user {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
  
    width: 100%;
    height: 100vh;
  
    background: #ffd0d0;
  
    > .title {
      font-size: 2rem;
      font-weight: bold;
    }
  
+   >.list {
+     margin-top: 1rem;
+   }
  }
  </style>
```

これでコントローラーで定義したユーザー名が表示されればOKです。

## 8. postしよう

fetchができたので今度はpostをしましょう。

routesから書き換えていきます。

```diff:config/routes.rb
  Rails.application.routes.draw do
    root 'pages#home'
  
    get 'home', to: 'pages#home'
    get 'user', to: 'pages#user'
  
    namespace :api do
      defaults format: :json do
-       resources :users, only: %i[index]
+       resources :users, only: %i[index create]
      end
    end
  end
```

コントローラーも書きます。
本来ならここで受け取った値をDBに保存などするのですが、割愛して受け取った値の表示だけするようにします。

```diff:app/controllers/api/users_controller.rb
  class Api::UsersController < ApplicationController
    def index
      @users = [
        { first_name: 'Ciar'    , family_name: 'Gethsemane' },
        { first_name: 'Sundara' , family_name: 'Josefa' },
        { first_name: 'Zisel'   , family_name: 'Itzel' },
        { first_name: 'Hadriana', family_name: 'Daniele' },
      ]
    end
  
+   def create
+     puts "hello #{params[:first_name]} #{params[:family_name]}"
+   end
  end
```

`User.vue` でデータをpostするようにしましょう。

```diff
  <script setup lang="ts">
  import { ref, inject } from 'vue'

  type User = {
    firstName: string;
    familyName: string;
  }

  const $axios: any = inject('$axios')

  const users = ref<User[]>([])
+ const newFirstName = ref<string>('')
+ const newFamilyName = ref<string>('')

+ const postUser = async (): Promise<void> => {
+   await $axios.post('/api/users', {
+     firstName: newFirstName.value,
+     familyName: newFamilyName.value
+   })
+ }

  $axios.get('/api/users')
    .then((response: { data: User[] }) => {
      users.value = response.data
    })
  </script>

  <template>
    <div class="user">
      <h1 class="title">Users</h1>

      <ul class="list">
        <li class="item" v-for="user in users" :key="user.firstName">
          {{ user.firstName }} {{ user.familyName }}
        </li>
      </ul>

+     <form class="form">
+       <label class="label">First Name<input type="text" class="input" v-model="newFirstName"/></label>
+       <label class="label">Family Name<input type="text" class="input" v-model="newFamilyName"/></label>
+       <input type="submit" value="Submit" class="submit" @click.prevent="postUser"/>
+     </form>

    </div>
  </template>

  <style lang="scss" scoped>
  .user {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;

    width: 100%;
    height: 100vh;

    background: #ffd0d0;

    > .title {
      font-size: 2rem;
      font-weight: bold;
    }

    >.list {
      margin-top: 1rem;
    }

+   > .form {
+     margin-top: 1rem;

+     > .label {
+       &:not(:first-child) { margin-left: 1rem; }

+       > .input {
+         background-color: white;
+         border-radius: 0.5rem;
+         margin-left: 0.5rem;
+         padding: 0.25rem 0.5rem;
+       }
+     }

+     > .submit {
+       background-color: #fc8585;
+       border-radius: 0.5rem;
+       margin-left: 0.5rem;
+       padding: 0.25rem 0.5rem;
+     }
+   }
  }
</style>
```

さて、ここで「Submit」ボタンを押しても期待した動作にはなりません。
railsのログを見てみましょう。

```
Started POST "/api/users" for ::1 at 2022-12-23 02:08:56 +0900
Processing by Api::UsersController#create as JSON
  Parameters: {"firstName"=>"", "familyName"=>"", "user"=>{"firstName"=>"", "familyName"=>""}}
```

2つ問題があることがわかります。
1. キーがcamelCaseになっている
2. `"user"=>{"firstName"=>"", "familyName"=>""}` ってなんだ？入れた覚えないぞ。

1については[偉大なるstackoverflowからコードをお借りして](https://stackoverflow.com/questions/17240106/what-is-the-best-way-to-convert-all-controller-params-from-camelcase-to-snake-ca)以下のファイルを置けば解決します。

```rb:config/initializers/json_param_key_transform.rb
# Transform JSON request param keys from JSON-conventional camelCase to
# Rails-conventional snake_case:
ActionDispatch::Request.parameter_parsers[:json] = lambda { |raw_post|
  data = ActiveSupport::JSON.decode(raw_post)

  if data.is_a?(Array)
    data.map { |item| item.deep_transform_keys!(&:underscore) }
  else
    data.deep_transform_keys!(&:underscore)
  end

  data.is_a?(Hash) ? data : { '_json': data }
}
```

2について。これはwrap_parametersというRailsのおせっかい機能で、以下のファイルを置くことで無効化できます。

```rb:config/initializers/wrap_parameters.rb
ActiveSupport.on_load(:action_controller) do
  wrap_parameters format: []
end
```

これで無事にpostできるようになりました。

## 9. system specを書こう

せっかくなのでテストもsystem specも書いていきましょう。

いろいろGemfileに加えます。

```rb:Gemfile
...
group :test do
  gem "capybara"
  gem "rspec-rails"
  gem "selenium-webdriver"
  gem "webdrivers", require: false
end
```

`bundle install`した後はマニュアルに従ってrspecをインストールします。

```sh
$ rails generate rspec:install
```

できたファイルはほとんどいじる必要はないですが、以下の行だけ、コメントインします。

```rb:spec/rails_helper.rb
...
Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f }
...
```

そうしたらcapybaraの設定をしていきます。
今回はheadless chromeを使いたいのでそのための設定ですね。

以下のファイルを置きます。

```rb:spec/support/capybara.rb
Capybara.register_driver :headless_chrome do |app|
  options = Selenium::WebDriver::Chrome::Options.new
  options.args << '--headless'
  options.args << '--disable-gpu'
  options.args << '--no-sandbox'
  options.args << '--disable-dev-shm-usage'
  options.args << '--lang=ja-JP'
  options.args << '--window-size=1280,720'
  options.args << '--disable-dev-shm-usage'

  Capybara::Selenium::Driver.new(
    app,
    browser: :chrome,
    capabilities: options
  )
end

Capybara.configure do |config|
  config.default_driver    = :headless_chrome
  config.javascript_driver = :headless_chrome
  config.raise_server_errors = false
  config.default_max_wait_time = 5
end
```

後は普通にsystem specを書けばOK。

例えばこんな感じ。

```rb:spec/system/home_spec.rb
require 'rails_helper'

RSpec.describe 'Home' do
  before { driven_by(:headless_chrome) } # 重要！

  before { visit home_path }

  subject { page }

  it 'exists contens' do
    is_expected.to have_content 'Hello Home'
  end
end
```

ここで重要なのは`before { driven_by(:headless_chrome) }`の部分。
これがないと何故かheadless chromeを使ってくれません。はまりポイント3。

---

以上で終わりです。
駆け足でしたが、いかがだったでしょうか？

皆さんがよいRails & Vueライフを送れますように。

今年もお疲れさまでした！