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を書き足しておきましょう
...
+ # Ignore node modules
+ node_modules
...
あと、必要なディレクトリも準備します。
$ mkdir -p app/frontend/{assets,components,styles,plugins}
3. 空のページを表示させる
空のページを表示させるようにしましょう。
Rails.application.routes.draw do
root 'pages#home'
get 'home', to: 'pages#home'
get 'user', to: 'pages#user'
end
homeとuserの2つのページのルーティングを設定しました。
コントローラーも書きましょう。
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
もすこし書き換えます。
<!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
を書いていきます。
//
// Styles
//
import 'destyle.css'
//
// Scripts
//
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.mount('#app')
TypeScriptでVue.jsが使えるように以下のファイルも用意しておきます。
/* eslint-disable */
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
つぎにwebpackの設定をします。
以下のファイルを用意してください。
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
も書き足しましょう。
{
...
+ "scripts": {
+ "dev": "webpack-dev-server --mode=development",
+ "build": "webpack --mode=production"
+ },
...
}
buildしたときの出力先のディレクトリも用意しておきましょう。
$ mkdir public/dist
今後、このディレクトリにbuildしたファイルが吐き出されるので、このディレクトリ配下を.gitignore
に入れておきましょう。
...
# Ignore node modules
node_modules
...
+ # Ignore frontend files
+ /public/dist
...
ページコンポーネントも用意しておきましょう。
<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>
<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
になっています。
ファイルの中身はこんな感じです。
{"main":{"js":"http://localhost:3001/build.js"}}
{"main":{"js":"dist/build-fb8c6bd492d5c8b2a4db.js"}}
このファイルをrailsで読み込んでファイルの出力先を取得すれば良さそうです。
application_controller.rb
とpages_controller.rb
に手を加えていきます。
class ApplicationController < ActionController::Base
+ def script_for(bundle)
+ JSON.load(File.open(Rails.root.join('webpack-assets.json')))[bundle]['js']
+ end
end
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
で読み込みましょう。
<!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
を入れておきましょう。
...
# Ignore frontend files
/public/dist
+ webpack-assets.json
...
6. ページごとに違うコンポーネントを使うようにしよう
そういえばApp.vue
ファイルをまだ書いてませんでした。
App.vue
ファイルでやりたいことはページごとに違うページコンポーネントを使うようにすることです。
そのためにApp.vueにどのページを表示させるかを渡すようにします。
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
は以下のようになります。
<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用のルーティングを書きます。
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からデータを取ってくるのですが、本質ではないのでここでは定数を返すことにします。
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も書きます。
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サーバーを再起動するだけです。
Jbuilder.key_format camelize: :lower
では、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してその値を表示させてみましょう。
以下のように書き換えます。
<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から書き換えていきます。
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に保存などするのですが、割愛して受け取った値の表示だけするようにします。
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つ問題があることがわかります。
- キーがcamelCaseになっている
-
"user"=>{"firstName"=>"", "familyName"=>""}
ってなんだ?入れた覚えないぞ。
1については偉大なるstackoverflowからコードをお借りして以下のファイルを置けば解決します。
# 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のおせっかい機能で、以下のファイルを置くことで無効化できます。
ActiveSupport.on_load(:action_controller) do
wrap_parameters format: []
end
これで無事にpostできるようになりました。
9. system specを書こう
せっかくなのでテストもsystem specも書いていきましょう。
いろいろGemfileに加えます。
...
group :test do
gem "capybara"
gem "rspec-rails"
gem "selenium-webdriver"
gem "webdrivers", require: false
end
bundle install
した後はマニュアルに従ってrspecをインストールします。
$ rails generate rspec:install
できたファイルはほとんどいじる必要はないですが、以下の行だけ、コメントインします。
...
Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f }
...
そうしたらcapybaraの設定をしていきます。
今回はheadless chromeを使いたいのでそのための設定ですね。
以下のファイルを置きます。
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。
例えばこんな感じ。
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ライフを送れますように。
今年もお疲れさまでした!