Open23

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>