5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Ruby on RailsAdvent Calendar 2022

Day 21

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

Last updated at Posted at 2022-12-22

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

出来上がったものはこちらから見ることができます。

2022/02/15 追記: webpackの代わりに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なしなのでいらないもの」は必ずスキップしてください。
「サンプルなのでいらないもの」は必要に応じて入れてください。

$ 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 \
  .

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

$ rm -rf app/assets app/helpers

これで準備完了。

2. frontendの準備

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

$ 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を書き足しておきましょう

.gitignore
  ...
+ # Ignore node modules
+ node_modules
  ...

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

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

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

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

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

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

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

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

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

  def user
    render 'empty'
  end
end

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

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

application.html.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を書いていきます。

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が使えるように以下のファイルも用意しておきます。

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

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

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も書き足しましょう。

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

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

$ mkdir public/dist

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

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

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

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>
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というものを設定していたのですが、気がついたでしょうか?

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

watchしたとき
{"main":{"js":"http://localhost:3001/build.js"}}
buildしたとき
{"main":{"js":"dist/build-fb8c6bd492d5c8b2a4db.js"}}

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

application_controller.rbpages_controller.rbに手を加えていきます。

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
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で読み込みましょう。

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タグ(の最後)に入れないと動作しないので気をつけましょう。
地味なはまりポイントです。

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

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

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

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

application.html.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>
+     <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は以下のようになります。

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を起動して表示を確認してみましょう。

$ bundle exec rails s
$ npm run dev

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

7. fetchしよう

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

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

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からデータを取ってくるのですが、本質ではないのでここでは定数を返すことにします。

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も書きます。

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サーバーを再起動するだけです。

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

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

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してその値を表示させてみましょう。
以下のように書き換えます。

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から書き換えていきます。

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に保存などするのですが、割愛して受け取った値の表示だけするようにします。

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するようにしましょう。

  <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からコードをお借りして以下のファイルを置けば解決します。

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のおせっかい機能で、以下のファイルを置くことで無効化できます。

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

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

9. system specを書こう

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

いろいろGemfileに加えます。

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

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

$ rails generate rspec:install

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

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

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

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

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。

例えばこんな感じ。

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ライフを送れますように。

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

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?