Zenn
Closed35

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

kazskazs

目的

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

記載された技術スタックを見た感じ、自分が学びたいことにマッチしていそう。
今回はRubyは3.3.6、Nodeはv.20.14.0を使おうと思う。

  1. 技術スタック
    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
kazskazs

まずは rails new する必要があるので、bundle initしてGemfile生成。
railsのバージョンを合わせて記述した。

# frozen_string_literal: true

source "https://rubygems.org"

gem "rails", '7.0.4'

その後 bundle install

kazskazs

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

以下のディレクトリもいらないらしいので消しておく。

rm -rf app/assets app/helpers
kazskazs

フロントエンドで必要として 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"
  }
}
kazskazs
  • destyle.css: reset css
  • vue-axios: vue で axios 使いやすくするやつかな?
kazskazs

devDependencies はまあ要りそうだなって雰囲気のやつらだ

kazskazs

フロントに必要なディレクトリ

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

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>
kazskazs
$ bundle exec rails s

で空のページが表示されることを確認

kazskazs

ここから 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 だよね

kazskazs

ここでちょっと詳しく調べたいファイルが登場した。
まあでも説明しれくれている通りで、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
}
kazskazs

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
}

kazskazs

package.json に以下追加

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

ビルド成果物の出力先ディレクトリを作成

$ mkdir public/dist
kazskazs

ページコンポーネントの作成
Home.vueUser.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>
kazskazs
  1. buildしたファイルを読み込めるようにする

さて、buildしたファイルを読み込めないといけません。
実はwebpackを設定したときにassets-webpack-pluginというものを設定していたのですが、気がついたでしょうか?

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

こっから大事なところだ

kazskazs

webpack-assets.jsonの中身は

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

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

なるほど、確かにそうすれば、Railsから配信するHTMLにVueのエントリーファイルを埋め込むことが可能だね

kazskazs

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 から対応するファイル名を取得するメソッド

kazskazs

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>
kazskazs

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

kazskazs

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

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>

自分がやりたいことのイメージとは少し違うけど、とりあえず記事のまま進めてみる

kazskazs

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

kazskazs

data-name 属性で受け取った値を元に、動的コンポーネントでページを分岐する想定みたい。
VueだとルーティングはVue Routerとか使うのが一般的だと思うけど、Railsから配信することをベースに考えると、こういうやり方もあるのか

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>

dataで定義されているcomponentName: document.getElementById('app')?.dataset?.nameで、data-nameを取得し、<component :is="componentName" />で動的にコンポーネントを分岐させる。

kazskazs

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

$ bundle exec rails s
$ npm run dev

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

確かにちゃんとHomeとUserを表示できる状態になってるんだけど、ここにきてわかってない。
npm run devwebpack-dev-server 起動して、Railsと連携するのがなんで?
自分の想像では、npm run buildで、develop 用の成果物を吐き出して、そこにはwebpack-assets.jsonも含まれていて、それをRailsが参照するんだと思っていたんだけど…

kazskazs

あ、npm run watchでも webpack-assets.jsonは生成されてるわ。で、中身よく見たら

webpack-assets.json
{"main":{"js":"http://localhost:3001/build.js"}}

だから、<script src="http://localhost:3001/build.js"></script>が埋め込まれることになって、webpack-dev-serverから bundle.js を受け取ってVue開始ってことね。

kazskazs
  1. fetchしよう

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

kazskazs

まずはAPIを作る

routesにAPIのルーティングを定義する。
namespace を 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

コントローラーを定義する。

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
kazskazs

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

kazskazs

次はフロント側

app/frontend/main.ts
  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 に登録している。

kazskazs

次はUser.vue

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>

今回 jBuilder の設定をしていないので、APIの返り値はsnake_caseになっている点は注意が必要。
以下のイメージでデータを詰め替える対応が必要。

function convertToCamelCase(user: UserResponse): User {
  return {
    firstName: user.first_name,
    familyName: user.family_name,
  }
}
kazskazs

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

このスクラップは4ヶ月前にクローズされました
ログインするとコメントできます