この記事はruby on railsとvue.js(特にvuetify)でウェブサイトを作る例を説明します。railsの中でvue.jsを使うにはvite_railsというgemを使います。
前置き
まずはこの記事を書くことになったきっかけについてです。(飛ばしても大丈夫です)
数年前からrailsによって書いたウェブサイトを更新しようとしています。そのウェブサイトはrailsの中でvue.jsを入れる為にwebpackerを使っていたのですが、webpackerはもうオワコンになっているので、更新するなら移行するべきだことになりました。webpackerの代わりの選択肢は色々ありますが、今回選択したのviteです。vite_railsというgemでrailsと一緒にvitを使うことができます。
viteはvue.jsの作者である尤雨溪によって作られた開発用サーバーなので、現在vue.jsを使う時に一番おすすめの手段でもあります。勿論react.jsなどを使う場合も同様にviteを使うことができます。
ということで私も使う為にvite_railsの使い方を勉強しました。そして折角だから、勉強したことを記事に書くことにしました。
私はrailsに関する記事を書くのは今回始めてですが、ずっと前から使っていました。今回記事を書くことで私もrails関連の理解の整理ができました。
ちょっと余談ですが、丁度最近deepseekが話題になっているので、今回のvite_railsの勉強は主にdeepseekで行いました。viteをおすすめしたのもdeepseekです。今まで本を読んだりgoogleで検索したりすることで勉強してきたのですが、このようにチャットボットで勉強するのは初めてです。
この記事で使う実装コードも主にdeepseekが生成してくれたものをベースにしています。ただし私なりにだいぶ整理しました。それにdeepseekの書いたことは完璧であるわけではなく、そのまま実行してエラーになることもあります。結局聞き直したり、googleで検索したりして完成させる必要があります。それでもgoogleばかり頼る時代より勉強が速いと思います。
これは意外といい勉強し方かもしれません。deepseekがもっと早く現れたらよかったのに、と思うくらい。
こんなものが人気になっていくと、qiitaなどの記事を読む人や書く人が少なくなるのではないか……とつい思ってしまいますが、とりあえず今はそこまでではないでしょう。
余談はここまで。次は本題に入ります。
概要
今回作ったウェブサイトはただ新幹線駅データを閲覧したり編集したりする簡単で小さなウェブサイトです。これを選んだ理由はただ何かデータベースを使う例をしたくて、そして最近新幹線のことを勉強しているところでもあって、先月丁度この記事を書きました。
今回使うデータはこの記事から取ったものです。ただし全部使うのは多すぎるので、東京駅から鹿児島中央駅までの東海道山陽新幹線と九州新幹線の全部47駅だけ使うことにしました。
こうやってできたウェブサイトはこれです。
これのテーブルはvuetifyコンポネントによるもので、ページングも自動で作られています。
「編集」ボタンを押したらその駅データの編集ページに入ります。
下にある「新しいデータを追加」ボタンを押したら追加ページに入ります。
全部3ページあります。これだけです。チュートリアルらしく簡単に作れるものですね。
実装
環境
railsもvue.jsもバージョンによって大きく違う場合が多いので、今回で使ったライブラリのバージョンも記述しておきましょう。
ruby | 3.4.1 |
---|---|
rails | 8.0.1 |
vite_rails | 3.0 |
vite | 5.4.14 |
vite-plugin-ruby | 5.1.1 |
vite-plugin-vuetify | 2.1.0 |
vue | 3.5.13 |
vuetify | 3.7.11 |
axios | 1.7.9 |
プロジェクト作成とパッケージのインストール
まずはプロジェクトの作成です。rails+vite+vue.jsなので、名前は適当に合わせてravivu
にします。
rails new ravivu -J -S -T -M -G -C --skip-docker --skip-kamal --skip-solid
cd ravivu
ここで-J
というオプションはturbo-railsなど基本のjavascript関連の機能を入れないようにする為です。代わりにviteを使うから。
他のオプションはただ必要ないものを作らないようにする為だけです。入れなくても普通に使えます。作りたいプロジェクトや好みにもよります。詳しくは本題と関係なくてそこまで気にしなくてもいいので割愛します。railsはデフォルトで色んないいものを準備してくれて便利ですが、そこまで要らない場合も多くて逆に取り除く手間がかかりますね。
次はviteを追加します。
bundle add vite_rails
bundle exec vite install
そしてyarnでvue.js関連のパッケージを追加します。
yarn add vue @vitejs/plugin-vue
今回vuetifyも使うのでその為に必要なパッケージも追加します。
yarn add vuetify vite-plugin-vuetify @mdi/font sass-embedded
ウェブサイトの中でAPIとウェブページの間のデータやり取りを行う為にaxiosを使うのでこれも入れておきます。
yarn add axios
基本的な設定の調整
次は色んな設定ファイルを修正します。まずはviteの設定です。
import { defineConfig } from 'vite'
import RubyPlugin from 'vite-plugin-ruby'
import vue from '@vitejs/plugin-vue'
import vuetify from 'vite-plugin-vuetify'
export default defineConfig({
plugins: [
RubyPlugin(),
vue(),
vuetify({ autoImport: true }),
],
})
次はウェブサイトのルーティングです。今回はメインページ、データ追加ページ、個別データ編集ページ、全部3ページを作ります。又、データベースと接続為のAPIも必要です。
Rails.application.routes.draw {
root "home#index"
get "add" => "home#add"
get "edit/:id" => "home#edit"
resources "api", only: [:create, :update, :destroy]
}
これで6つのルーティングができます。
パス | メソッド | コントローラー | |
---|---|---|---|
メインページ | / | get | home#index |
データ追加ページ | /add | get | home#add |
データ編集ページ | /edit/ {id} | get | home#edit |
データ追加API | /api | post | api#create |
データ編集API | /api/ {id} | patch | api#update |
データ削除API | /api/ {id} | delete | api#destroy |
尚、CORS問題を防ぐ為にconfig
フォルダの中のapplication.rb
にこれを入れる必要があります。
config.action_controller.forgery_protection_origin_check = false
これを設定しないとproductionの時にaxiosを使うところにこのようなエラーが出ます。
[9b52a8e7-e50c-4c18-843e-f462af1086f7] HTTP Origin header (http://127.0.0.1:3000) didn't match request.base_url (https://127.0.0.1:3000)
[9b52a8e7-e50c-4c18-843e-f462af1086f7] Completed 422 Unprocessable Content in 1ms (ActiveRecord: 0.0ms (0 query, 0 cached) | GC: 0.0ms)
実は私は最初はこの問題にぶつかって解決する為に数時間もかけたので、特に注意して欲しいです。
これはrack-cors
を使っても解決できません。尚、一般公開するAPIを作る場合rack-cors
を使う必要があるはずですが、今回はウェブサイトの中でデータのやり取りをするだけなのでrack-cors
とは関係ありません。
その他にaxios.defaults.headers
の設定も必要です。これについては後述のapplication.ts
に書きます。
又、これは必須ではないが、個人的おすすめ。ファイル名や変数名にs
が勝手に追加されることを防ぐ為のおまじないです。
ActiveSupport::Inflector.inflections { |inflect|
inflect.plural /^.*\S$/, '\0'
inflect.singular /^.*\S$/, '\0'
}
データベース
駅のテーブルのデータを保存するデータベースの設定です。実際にproductionの時はpostgresやmysqlを使う場合が多いが、今回は簡単な為にdevelopmentもproductionもデフォルトのsqliteを使うことにします。
まずはyamlでのの設定です。
default: &default
adapter: sqlite3
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
timeout: 5000
development:
<<: *default
database: storage/development.sqlite3
production:
<<: *default
database: storage/production.sqlite3
次はこのようにデータテーブルを作りたいです。
列名 | データ型 | |
---|---|---|
駅名 | namae | text |
読み方 | yomi | text |
都道府県 | ken | text |
開業日時 | kaigyou | date |
これを実行することでその通りのテーブルを定義します。ただしidというプライマリキーと、追加と更新の日付の列も、自動的に追加されます。
rails g model eki namae yomi ken kaigyou:date
そうしたらdb/migrate/
フォルダの中にデータベースのテーブルを作成するrubyコードが生成されます。特に変更する必要なくこのままmigrateを実行します。
rails db:migrate
今回デフォルトであるsqliteを使うので、storage
フォルダの中にdevelopment.sqlite3
ファイルができます。
次はデータの準備です。データを入れる為のseeds.rb
を使います。
ekidata = %w[
東京,とうきょう,東京都,1964-10-01
品川,しながわ,東京都,1964-10-01
新横浜,しんよこはま,神奈川県,1964-10-01
小田原,おだわら,神奈川県,1964-10-01
熱海,あたみ,静岡県,1964-10-01
三島,みしま,静岡県,1964-10-01
新富士,しんふじ,静岡県,1988-03-13
静岡,しずおか,静岡県,1964-10-01
掛川,かけがわ,静岡県,1988-03-13
浜松,はままつ,静岡県,1964-10-01
豊橋,とよはし,愛知県,1964-10-01
三河安城,みかわあんじょう,愛知県,1988-03-13
名古屋,なごや,愛知県,1964-10-01
岐阜羽島,ぎふはしま,岐阜県,1964-10-01
米原,まいばら,滋賀県,1964-10-01
京都,きょうと,京都府,1964-10-01
新大阪,しんおおさか,大阪府,1964-10-01
新神戸,しんこうべ,兵庫県,1972-03-15
西明石,にしあかし,兵庫県,1972-03-15
姫路,ひめじ,兵庫県,1972-03-15
相生,あいおい,兵庫県,1972-03-15
岡山,おかやま,岡山県,1972-03-15
新倉敷,しんくらしき,岡山県,1975-03-10
福山,ふくやま,広島県,1975-03-10
新尾道,しんおのみち,広島県,1988-03-13
三原,みはら,広島県,1975-03-10
東広島,ひがしひろしま,広島県,1988-03-13
広島,ひろしま,広島県,1975-03-10
新岩国,しんいわくに,山口県,1975-03-10
徳山,とくやま,山口県,1975-03-10
新山口,しんやまぐち,山口県,1975-03-10
厚狭,あさ,山口県,1999-03-13
新下関,しんしものせき,山口県,1975-03-10
小倉,こくら,福岡県,1975-03-10
博多,はかた,福岡県,1975-03-10
博多南,はかたみなみ,福岡県,1990-04-01
新鳥栖,しんとす,佐賀県,2011-03-12
久留米,くるめ,福岡県,2011-03-12
筑後船小屋,ちくごふなごや,福岡県,2011-03-12
新大牟田,しんおおむた,福岡県,2011-03-12
新玉名,しんたまな,熊本県,2011-03-12
熊本,くまもと,熊本県,2011-03-12
新八代,しんやつしろ,熊本県,2004-03-13
新水俣,しんみなまた,熊本県,2004-03-13
出水,いずみ,鹿児島県,2004-03-13
川内,せんだい,鹿児島県,2004-03-13
鹿児島中央,かごしまちゅうおう,鹿児島県,2004-03-13
]
ekidata = ekidata.map{|eki|
eki = eki.split(",")
{
namae: eki[0],
yomi: eki[1],
ken: eki[2],
kaigyou: eki[3]
}
}
Eki.create!(ekidata)
そしてseedの実行。
rails db:seed
これでデータが整いました。
コントローラー
次はルーターで定義した通りコントローラーを作ります。
class ApiController < ApplicationController
def create
konoeki = Eki.create(params[:ekidata].permit(:id, :namae, :yomi, :ken, :kaigyou))
render(json: konoeki)
end
def update
konoeki = Eki.find(params[:id])
ekidata = params[:ekidata].permit(:id, :namae, :yomi, :ken, :kaigyou)
konoeki.update(ekidata)
render(json: konoeki)
end
def destroy
konoeki = Eki.find(params[:id])
konoeki.destroy
render(json: konoeki[:namae]+"駅を削除完了")
end
end
class HomeController < ApplicationController
def index
@taitoru = "新幹線駅一覧"
@jsondata = Eki.all.to_json
end
def add
@taitoru = "駅追加"
@jsondata = ""
end
def edit
@jsondata = Eki.find(params[:id])
@taitoru = @jsondata.namae+"駅編集"
@jsondata = @jsondata.to_json
end
end
apiの方は応答のデータをそのままjsonとしてrenderしますが、homeはjsonを@jsondata
という変数に入れておいてその後vueから使います。
@taitoru
はページのtitleで、erbレイアウトの中で使われます。
erb
ウェブサイトの各ページはvueで定義するので、erbはただレイアウトページ1つだけでいいです。
<!DOCTYPE html>
<html>
<head>
<title><%= @taitoru %></title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= vite_client_tag %>
<%= vite_typescript_tag "application" %>
<%= vite_stylesheet_tag "application.scss" %>
</head>
<body>
<div id="jsondata" hidden><%= @jsondata.html_safe %></div>
<div id="app"></div>
</body>
</html>
ここで<div id="app"></div>
はvueで形を作る部分です。
vite_typescript_tag
はTypeScriptを使う場合であり、JavaScriptを使う場合はvite_javascript_tag
となります。
そして各ページのerbです。ただしvueでページを作るつもりだから実は使わないが、railsのルールでは各ページに合わせるerbがないとエラーになるから、わざわざ空っぽなerbを作る必要があります。今回3あるのでこの通りに作ります。
entrypoint
railsプロジェクトにviteを追加した時にapp/frontend/entrypoints
の中にapplication.js
は自動的に作成されているはずです(ただしもしプロジェクト作成の時に-J
オプションを付けていなかった場合、場所はapp/javascript/entrypoints
になる)。今回TypeScriptを使うので、application.ts
に書き換えます。
import { createApp } from "vue";
import { createVuetify } from "vuetify";
import { ja } from "vuetify/locale";
const vuetify = createVuetify({
locale: {
locale: "ja",
messages: { ja },
},
theme: {
defaultTheme: 'darkTheme',
themes: {
darkTheme: {
dark: true,
colors: {
qiita: "#55c500",
pixiv: "##0097f7",
yahoo: "#f70131",
deepseek: "#4d6bfe",
}
},
},
},
});
import indexPage from "../index-page.vue";
import addPage from "../add-page.vue";
import editPage from "../edit-page.vue";
document.addEventListener("DOMContentLoaded", () => {
if(document.URL.includes("edit")){
var app = createApp(editPage);
}
else if(document.URL.includes("add")) {
var app = createApp(addPage);
}
else {
var app = createApp(indexPage);
}
app.use(vuetify);
app.mount("#app");
});
import axios from "axios";
axios.defaults.headers["X-CSRF-TOKEN"] = document.getElementsByName("csrf-token")[0].getAttribute("content");
ここでURLによって違うvueページが使われます。ページを分ける手段としてはvue-routeなどがありますが、今回は単純にif
で分岐することにします。あまり簡潔でないかもしれないけど、わかりやすくて直感的だから。
theme
は色のテーマの設定するのです。特徴を持つウェブサイトの名前を色の名前に使います。
locale
は表示する言語の設定です。これはvuetifyのv-data-table
などの表示に反映します。
尚、vuetifyのv-data-table
とテーマを使う例は以前の記事に書いたことがあります。今回は実はその記事を参考にした部分が多いです。
しかし4年も経ってvueもvuetifyも使い方が大分変わったので、その記事の使い方はほぼもう使い物にならなく、ただ参考程度にしかない状態になっていますね。
その他にスタイルシートファイルもこのように追加します。
@use 'vuetify/styles';
@use '@mdi/font/css/materialdesignicons.css';
vue
最後にページの形を作る.vueファイルです。
メインページ。
<template>
<v-app>
<v-main>
<v-container>
<div class="text-h4 px-4 py-3">新幹線駅一覧</div>
<v-data-table :items="eki_list" :headers="header_list" :items-per-page="8"
:items-per-page-options="[8, 20, -1]">
<template #item.namae="{ item: { namae } }">
<a :href="'https://ja.wikipedia.org/wiki/' + namae + '駅'" target="_blank" style="font-size: 13pt;">{{ namae
}}</a>
</template>
<template #item.kaigyou="{ item: { kaigyou } }">
<v-sheet v-if="kaigyou">{{ (kaigyou as string).replace("-", "年").replace("-", "月") + "日" }}</v-sheet>
</template>
<template #item.henshuu="{ item: { id } }">
<v-btn @click="henshuu(id)" color="pixiv">編集</v-btn>
</template>
<template #item.sakujo="{ item: { id } }">
<v-btn @click="sakujo(id)" color="yahoo">削除</v-btn>
</template>
</v-data-table>
<v-btn @click="tsuika" color="deepseek">新しいデータを追加</v-btn>
</v-container>
</v-main>
</v-app>
</template>
<script lang="ts">
import axios from "axios";
export default {
name: "indexpage",
data: () => ({
eki_list: [],
header_list: [
{ value: "id", },
{ title: "駅名", value: "namae", },
{ title: "読み方", value: "yomi", sortable: true },
{ title: "都道府県", value: "ken", sortable: true },
{ title: "開業", value: "kaigyou", sortable: true },
{ value: "henshuu" },
{ value: "sakujo" },
],
}),
methods: {
tsuika() {
location.href = "/add";
},
henshuu(id: number) {
location.href = "/edit/" + id;
},
sakujo(id: number) {
if (confirm("消して宜しいの?")) {
let url = "/api/" + id;
axios.delete(url)
.then((res) => {
if (res.status == 200) {
location.reload();
}
})
.catch((err) => {
alert(err);
});
}
},
},
mounted() {
this.eki_list = JSON.parse(document.getElementById("jsondata")!.textContent as string);
},
};
</script>
データを追加するページ。
<template>
<v-app>
<v-main>
<v-container>
<v-card color="deepseek" style="width: 300px;">
<v-card-title>
駅データ追加
</v-card-title>
<v-card-text>
<v-sheet v-for="col in Object.keys(ekidata)">
<v-text-field v-model="ekidata[col]" :label="col" style="margin: 5px" hide-details></v-text-field>
</v-sheet>
</v-card-text>
</v-card>
<v-btn @click="tsuika" class="mx-1" color="qiita" width="160">追加</v-btn>
<v-btn @click="modoru" class="my-1" color="yahoo" width="100">取消</v-btn>
</v-container>
</v-main>
</v-app>
</template>
<script lang="ts">
import axios from "axios";
export default {
name: "addpage",
data: () => ({
ekidata: { namae: "", yomi: "", ken: "", kaigyou: "" }
}),
methods: {
tsuika() {
axios.post("/api/", { ekidata: this.ekidata })
.then((res) => {
if (res.status == 200) {
this.modoru();
}
})
.catch((err) => {
alert(err);
});
},
modoru() {
location.href = "/";
},
},
};
</script>
データを更新するページ。
<template>
<v-app>
<v-main>
<v-container>
<v-card color="pixiv" style="width: 300px;">
<v-card-title>
駅データ編集
</v-card-title>
<v-card-text>
<v-sheet v-for="col in Object.keys(ekidata).slice(0, 5)">
<v-text-field v-model="ekidata[col]" :label="col" style="margin: 5px" hide-details></v-text-field>
</v-sheet>
</v-card-text>
</v-card>
<v-btn @click="hozon" class="mx-1" color="qiita" width="160">保存</v-btn>
<v-btn @click="modoru" class="my-1" color="yahoo" width="100">取消</v-btn>
</v-container>
</v-main>
</v-app>
</template>
<script lang="ts">
import axios from "axios";
export default {
name: "editpage",
data: () => ({
ekidata: {},
id: null,
}),
methods: {
hozon() {
axios.patch("/api/" + this.id, { ekidata: this.ekidata })
.then((res) => {
if (res.status == 200) {
this.modoru();
}
})
.catch((err) => {
alert(err);
});
},
modoru() {
location.href = "/";
},
},
mounted() {
this.ekidata = JSON.parse(document.getElementById("jsondata")!.textContent as string);
this.id = this.ekidata.id;
},
};
</script>
尚、ページに使うデータはaxiosを使うことでAPIから読み込むという方法もできますが、今回はコントローラーから準備しておいたjsondataを使うことにしています。axiosを使うのはデータの削除/編集/追加の時だけとなっています。
これでコードの準備は整いました。
developmentで起動
開発の時はdevelopmentモードで、2つのサーバーを同時に起動する必要があります。まずviteサーバーを起動します。
vite dev
そしてrailsサーバーの起動。
rails s
そうしたらhttp://127.0.0.1:3000/
からウェブサイトにアクセスできるようになります。
productionに向かう
開発が終わったら次は本番に向かいます。まずデータベースの準備ですが、普段developmentとproductionでデータベースが違うので、個別で準備する必要があります。migrateとseedもproductionの環境でもう一度やる必要があります。
rails db:migrate RAILS_ENV=production
rails db:seed RAILS_ENV=production
そしてJavaScriptをプリコンパイルします。これでもう個別で本番ではサーバーを立てる必要がなくなります。
vite build
最後にサーバーを起動します。
rails s -e production
以上これで完成です。
纏め
色んなファイルを編集したり作成したりしたので、見落とさないようにここで纏めておきます。
vite.config.ts |
config/routes.rb |
config/application.rb |
config/database.yml |
config/initializers/inflections.rb |
db/seeds.rb |
app/controllers/api_controller.rb |
app/controllers/home_controller.rb |
app/views/layouts/application.html.erb |
app/views/home/index.html.erb |
app/views/home/add.html.erb |
app/views/home/edit.html.erb |
app/frontend/index-page.vue |
app/frontend/add-page.vue |
app/frontend/edit-page.vue |
app/frontend/entrypoints/application.ts |
app/frontend/entrypoints/application.scss |
参考
vite_rails
- Vite Ruby で 爆速で Rails + Vue3 + TypeScript 環境を作成する!
- Vue.jsにvuetifyとbootstrap-vueを共存させる
- 【備忘録】Vue.js3の導入設定・正常表示確認
- Rails のフロントエンドツールに vite_rails を使う
- RailsアプリにVue.jsをモノリシックに導入するための準備
- Rails7系にviteを使ってVue3を入れてみた
- rails+VueでCRUDをやってみた(spa)
- Rails 7 + Vue.js 3 でaxiosを使ったAPI通信で遭遇したエラーとその解決
- railsとvue.jsを使用してchatアプリを作ってみた