Rails で Vue.js を配信する手法を試しで実装してみる

目的
- Vue で書かれた SPA のエントリポイントを Rails から配信するパターンについて、どういった設計になっているのかを理解する
- ついでにRailsのアセットパイプライン周りの仕組みやツールなどを理解したい

こちらを見てキャッチアップする

記載された技術スタックを見た感じ、自分が学びたいことにマッチしていそう。
今回はRubyは3.3.6、Nodeはv.20.14.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

まずは rails new する必要があるので、bundle init
してGemfile生成。
railsのバージョンを合わせて記述した。
# frozen_string_literal: true
source "https://rubygems.org"
gem "rails", '7.0.4'
その後 bundle install

bundler 経由で rails が使えるようになったので、bundle exec rails new
を実行。
参考サイトに従ってオプションを指定。
途中、Gemfileをオーバーライドしてよいか聞かれるので Y
$ 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

フロントエンドで必要として npm install したもの一覧
{
"dependencies": {
"axios": "^1.7.8",
"destyle.css": "^4.0.1",
"vue": "^3.5.13",
"vue-axios": "^3.5.2"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/preset-typescript": "^7.26.0",
"@types/webpack-env": "^1.18.5",
"assets-webpack-plugin": "^7.1.1",
"babel-loader": "^9.2.1",
"babel-preset-typescript-vue3": "^2.0.17",
"clean-webpack-plugin": "^4.0.0",
"css-loader": "^7.1.2",
"sass": "^1.81.1",
"sass-loader": "^16.0.3",
"style-loader": "^4.0.0",
"ts-loader": "^9.5.1",
"typescript": "^5.7.2",
"vue-loader": "^17.4.2",
"vue-style-loader": "^4.1.3",
"vue-template-compiler": "^2.7.16",
"webpack": "^5.97.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.1.0",
"webpack-merge": "^6.0.1"
}
}

フロントに必要なディレクトリ
mkdir -p app/frontend/{assets,components,styles,plugins}
- assets: 画像等を入れるところかな
- components: Vue コンポーネント配置場所
- styles: styleファイル置き場
- plugins: ここなんだろ

rails で空ページが表示できる状態にしておくみたい
routes.rb
Rails.application.routes.draw do
root 'pages#home'
get 'home', to: 'pages#home'
get 'user', to: 'pages#user'
end
pages_controller.rb
class PagesController < ApplicationController
def home
render 'empty'
end
def user
render 'empty'
end
end
render で指定した empty ファイルの作成
$ mkdir app/views/pages && touch app/views/pages/empty.html
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>

$ bundle exec rails s
で空のページが表示されることを確認

ここから frontend のファイルを作っていく
まずエントリポイントとなる 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')
reset cssの読み込み、vue と App.vue をimportして初期化、#appにマウントする。
どうでもいいけどマウントの指定でよくあるのって #id だよね

ここでちょっと詳しく調べたいファイルが登場した。
まあでも説明しれくれている通りで、TypeScript を vue ファイルで扱えるように、というイメージで良さそう。
ここでの目的「Vue で書かれた SPA のエントリポイントを Rails から配信するパターンについて知る」とはそれるので、その程度の理解で進める。
TypeScriptでVue.jsが使えるように以下のファイルも用意しておきます。
/* eslint-disable */
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

webpack.config.js も追加
ざっくり
- main.ts をエントリーファイルに指定
- バンドル後の出力先は public/dist
- 拡張子 ".js", ".ts", ".scss", ".vue" の省略
- 各種ローダーの設定
- 開発用・本番用ビルド毎の設定
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
}

package.json に以下追加
- dev: 開発用に webpack-dev-server からフロントエンドの成果物を配信して変更をWatchしてくれる
- build: フロントエンドのビルド
+ "scripts": {
+ "dev": "webpack-dev-server --mode=development",
+ "build": "webpack --mode=production"
+ },

ビルド成果物の出力先ディレクトリを作成
$ mkdir public/dist

ページコンポーネントの作成
Home.vue
と User.vue
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>
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>

- buildしたファイルを読み込めるようにする
さて、buildしたファイルを読み込めないといけません。
実はwebpackを設定したときにassets-webpack-pluginというものを設定していたのですが、気がついたでしょうか?
これはbuildやwatchをしたときにファイルの出力先をjsonで出力してくれるプラグインです。
デフォルトではファイル名はwebpack-assets.jsonになっています。
こっから大事なところだ

webpack-assets.jsonの中身は
- watchしたとき
{"main":{"js":"http://localhost:3001/build.js"}}
- buildしたとき
{"main":{"js":"dist/build-fb8c6bd492d5c8b2a4db.js"}}
このファイルをrailsで読み込んでファイルの出力先を取得すれば良さそうです
なるほど、確かにそうすれば、Railsから配信するHTMLにVueのエントリーファイルを埋め込むことが可能だね

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
受け取った bundle をキーとして、webpack-assets.json から対応するファイル名を取得するメソッド

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
キーが main になってるファイル名を @script_path に代入しておく
それを使って、配信するHTMLに script タグを埋め込む
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>

ここまでで、webpack-assets.json を参照して、webpackにビルドされた js ファイルを、Railsから配信する際に <scirpt src="">
で埋め込む処理ができている。
続きを見ていく。

6. ページごとに違うコンポーネントを使うようにしよう
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>
自分がやりたいことのイメージとは少し違うけど、とりあえず記事のまま進めてみる

action_name.camelize(:upper)
で、pages_controller のアクション名が入るみたい(Home
とかUser
とか)
それを data-name
属性として付与する

data-name
属性で受け取った値を元に、動的コンポーネントでページを分岐する想定みたい。
VueだとルーティングはVue Routerとか使うのが一般的だと思うけど、Railsから配信することをベースに考えると、こういうやり方もあるのか
<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>
data
で定義されているcomponentName: document.getElementById('app')?.dataset?.name
で、data-nameを取得し、<component :is="componentName" />
で動的にコンポーネントを分岐させる。

さてこれでようやく画面が表示できるようになりました。
railsとwebpack-dev-serverを起動して表示を確認してみましょう。
$ bundle exec rails s
$ npm run dev
localhost:3000 で「Hello Home」、localhost:3000/user で「Users」が表示されればOKです。
確かにちゃんとHomeとUserを表示できる状態になってるんだけど、ここにきてわかってない。
npm run dev
で webpack-dev-server
起動して、Railsと連携するのがなんで?
自分の想像では、npm run build
で、develop 用の成果物を吐き出して、そこにはwebpack-assets.json
も含まれていて、それをRailsが参照するんだと思っていたんだけど…

あ、npm run watch
でも webpack-assets.json
は生成されてるわ。で、中身よく見たら
{"main":{"js":"http://localhost:3001/build.js"}}
だから、<script src="http://localhost:3001/build.js"></script>
が埋め込まれることになって、webpack-dev-serverから bundle.js を受け取ってVue開始ってことね。

- fetchしよう
この構成の場合、CSRFトークンの話は大事そうなので、7章まではマネしてやっておく。

まずはAPIを作る
routesにAPIのルーティングを定義する。
namespace を 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
コントローラーを定義する。
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

jbuilderというのを使った json の返却、snake_case から camelCase への変換が解説されているが、一旦省略してrender json: @users
しておく。

次はフロント側
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、VueAxiosのセットアップ。
途中、まず csrfToken をエントリポイントのHTMLのmeta タグから取得している。
そして、その値をリクエスト時に X-CSRF-Token
として付与する設定で axios クライアントを作成。それを、app に登録している。

次は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>
今回 jBuilder の設定をしていないので、APIの返り値はsnake_caseになっている点は注意が必要。
以下のイメージでデータを詰め替える対応が必要。
function convertToCamelCase(user: UserResponse): User {
return {
firstName: user.first_name,
familyName: user.family_name,
}
}

これで、RailsからVueのフロントエンド配信 + APIという形が実現できた。