はじめに
学生時代にプログラミングを始めて間もないときに、こんな言葉を目にしたことがあります。
「世の中のWebサービスのほとんどは、掲示板サービスをちょっと変えたくらいのものだ。」
要は、CRUD機能のあるシンプルなアプリが一つ作れれば、あとはそれを応用して組み合わせたりするだけだということですね。あれから数年が経ち業務でサービス開発に携わる中でも、本当にその通りだなと思います。
今回、LaravelとVueの基礎をどうやったら効率良く習得できるかなと考え、やはりシンプルなCRUDアプリを作ってみることにしました。タスク管理サービスです。
基本的には、下記一連の4本の記事を参考にしています。大変わかりやすくて、筆者の方に感謝です。
ただ、数年前の記事なので、LaravelもVueも記法が変わったりしています。
その辺りを自分で調べながら色々ドキュメント見たりして調べながらやってみました。
この記事は、3本立ての1本目です。
- Laravel と Vue と Docker でシンプルな SPA を作る ①【フロントエンド】←今ここ
- Laravel と Vue と Docker でシンプルな SPA を作る ②【バックエンド】
- Laravel と Vue と Docker でシンプルな SPA を作る ③【フロントエンドとバックエンドの通信】
今回の目的は、LaravelとVueでSPAを作ってみて挙動を理解することなので、UIやバリデーション、エラーハンドリングなど細かい部分の作り込みは行っていません。
今回は、Vueのフロントの部分を作ります。あくまで、各コンポーネントの表示や遷移部分の実装です。今回画面に表示するデータは、仮で固定値を用います。次回以降でAPIを実装した後、通信によって動的に値が書き換わることを確認します。
全体像
画面遷移図
クライアントとAPIの構成図
コンポーネントの構成図
コンポーネントとは、アプリのUIを構築する上での部品の単位です。
この先、適宜この構成図を見返していただくと、今どの部分の実装をしているのかがわかりやすくなるかと思います。
タスク追加画面
タスク一覧画面
タスク詳細画面
タスク編集画面
ソースコード
環境構築
下記の記事で作った環境をそのまま使います。
今回のフロントの実装は、Viteの開発サーバーを立てて進めるのがホットリロードが動くので楽でいいと思います。
$ docker-compose exec app npm run dev
もちろん、好きなタイミングで静的ファイルにビルドして進めるのでも大丈夫です。
$ docker-compose exec app npm run build
どちらかを行わないと挙動が確認できませんので、ご注意ください。
ベースを実装
まずは、UIを気にしないでフロントのベースの動きだけ作っていきます。
完成系は下記のような感じです。
コードだと、このコミットの実装内容です。
Laravelのビュー側のルーティングを変更
SPAは従来のWebアプリケーションと動きが異なります。まず初回のみページ全体(HTML/CSS/JavaScript)をロードし、2回目以降はブラウザ側のJavaScriptを介して必要なデータのみを取得します。
今回は、Laravelのビュー側のルーティングを、どんなURLでアクセスが来ても初回はsrc/resources/views/app.blade.php
を返すようにします。
src/routes/web.php
を、下記のように修正します。
- Route::get('/', function () {
+ Route::get('/{any}', function() {
+ return view('app');
- });
+ })->where('any', '.*');
ヘッダー
ヘッダーコンポーネントを作ります。
アプリを構成するためにコンポーネントをcomponentsディレクトリに格納しています。
<template>
<router-link to="/tasks" >タスク一覧</router-link>
<router-link to="/tasks/create">タスク追加</router-link>
</template>
router-link
タグは、後に導入するVue RouterでURLを変更するためのタグです。a
タグに変換されます。
作成したヘッダーコンポーネントを、ルートコンポーネントであるApp.vue
で、読み込みます。
<template>
<Header />
</template>
タスク追加画面
TaskCreate.vue
コンポーネントを作成します。
<template>
<form>
<div>
<label for="title">タイトル</label>
<input type="text" id="title">
</div>
<div>
<label for="content">内容</label>
<input type="text" id="content">
</div>
<div>
<label for="person-in-charge">担当者</label>
<input type="text" id="person-in-charge">
</div>
<button type="submit">作成</button>
</form>
</template>
タスク一覧画面
TaskList.vue
コンポーネントを作成します。
<template>
<table>
<thead>
<tr>
<th>番号</th>
<th>タイトル</th>
<th>内容</th>
<th>担当者</th>
<th>詳細</th>
<th>編集</th>
<th>削除</th>
</tr>
</thead>
<tbody>
<tr>
<th>1</th>
<td>ゴミ捨て</td>
<td>明日の朝までに燃えるゴミを出す。</td>
<td>田中太郎</td>
<td>
<router-link to="/tasks/1">詳細</router-link>
</td>
<td>
<router-link to="/tasks/1/edit">編集</router-link>
</td>
<td>
<button>削除</button>
</td>
</tr>
<tr>
<th>2</th>
<td>読書</td>
<td>三四郎を100ページまで読む。</td>
<td>鈴木次郎</td>
<td>
<router-link to="/tasks/2">詳細</router-link>
</td>
<td>
<router-link to="/tasks/2/edit">編集</router-link>
</td>
<td>
<button>削除</button>
</td>
</tr>
<tr>
<th>3</th>
<td>勉強</td>
<td>数学の問題集を50ページまで解く。</td>
<td>佐藤花子</td>
<td>
<router-link to="/tasks/3">詳細</router-link>
</td>
<td>
<router-link to="/tasks/3/edit">編集</router-link>
</td>
<td>
<button>削除</button>
</td>
</tr>
</tbody>
</table>
</template>
タスク詳細画面
TaskShow.vue
コンポーネントを作成します。
<script setup>
const props = defineProps({
taskId: String
})
</script>
<template>
<form>
<div>
<label for="id">番号</label>
<input type="text" readonly id="id" :value="taskId">
</div>
<div>
<label for="title">タイトル</label>
<input type="text" readonly id="title" value="仮のタイトルだよ">
</div>
<div>
<label for="content">内容</label>
<input type="text" readonly id="content" value="仮の内容だよ">
</div>
<div>
<label for="person-in-charge">担当者</label>
<input type="text" readonly id="person-in-charge" value="仮の担当者だよ">
</div>
</form>
</template>
ポイントは、プロパティを宣言しているところです。
<script setup>
const props = defineProps({
taskId: String
})
</script>
これにより、タスク一覧画面から遷移してきた際(コンポーネントが切り替わった際)、props
としてtaskId
を受け取ることができます。後のVue Routerの導入でも、タスク詳細コンポーネントにprops
でtaskId
を渡すように記載します。
タスク編集画面
TaskEdit.vue
コンポーネントを作成します。
<script setup>
const props = defineProps({
taskId: String
})
</script>
<template>
<form>
<div>
<label for="id">番号</label>
<input type="text" readonly id="id" :value="taskId">
</div>
<div>
<label for="title">タイトル</label>
<input type="text" id="title">
</div>
<div>
<label for="content">内容</label>
<input type="text" id="content">
</div>
<div>
<label for="person-in-charge">担当者</label>
<input type="text" id="person-in-charge">
</div>
<button type="submit">保存</button>
</form>
</template>
プロパティの宣言は、タスク詳細コンポーネントと同様です。
Vue Router を導入
作成したコンポーネントをURLによって出し分けるため、Vue Routerを導入します。
$ docker-compose exec app npm install vue-router@4
Vue Router の制御を記載するroute.js
を作成します。app.js
内に記載してもいいのですが、量が少し多いので分けました。
import { createRouter, createWebHashHistory } from 'vue-router'
import TaskList from './components/TaskList.vue'
import TaskShow from './components/TaskShow.vue'
import TaskCreate from './components/TaskCreate.vue'
import TaskEdit from './components/TaskEdit.vue'
const routes = [
{ path: '/tasks', component: TaskList },
{ path: '/tasks/:taskId', component: TaskShow, props: true },
{ path: '/tasks/create', component: TaskCreate },
{ path: '/tasks/:taskId/edit', component: TaskEdit, props: true },
]
const router = createRouter({
history: createWebHashHistory(),
routes,
})
export default router
routes
で、URLとそれに対応するコンポーネントを指定しています。
const routes = [
{ path: '/tasks', component: TaskList },
{ path: '/tasks/:taskId', component: TaskShow, props: true },
{ path: '/tasks/create', component: TaskCreate },
{ path: '/tasks/:taskId/edit', component: TaskEdit, props: true },
]
タスク詳細コンポーネントと、タスク編集コンポーネントは、で動的にURLを生成しています。(ドキュメント)props: true
としてtaskId
がprops
としてコンポーネントに渡るようにしています。
作成したファイルを読み込み、有効化させるためにsrc/resources/js/app.js
を修正します。
import { createApp } from "vue";
import App from "./App.vue";
+ import router from "./router";
const app = createApp(App);
app.mount("#app");
+ app.use(router);
app.mount("#app");
ルートコンポーネントであるApp.vue
で、Vue Routerによってレンダリングされるようにします。
<template>
<Header />
+ <router-view></router-view>
</template>
<router-view></router-view>
の部分に、router.js
のroutes
で指定したコンポーネントがレンダリングされます。これで、ヘッダーは固定表示して、それ以外を出しわけるという制御が可能になりました。
ここまでで、フロントのベースは実装できました。
UIを整える
SPAの挙動を理解するだけであれば、ここまででももちろんOKなのですが、今回はせっかくなので簡単にUIを整えてみました。
完成系としては、下記のような感じです。
コードでは、このコミットの内容です。
Vuetifyを導入
UIのライブラリやフレームワークは、いろいろな選択肢があります。最近は、長らく有名なBootstrapのように記載量は少ないがCSSの自由度が低いものではなく、Tailwind CSSとHeadless UIを組み合わせるなどして記載量は増えるがCSSの自由度高く組み上げていくのが主流のような気がします。
今回は、サクッとUIをいい感じにしたいだけなので、できるだけ記載量が少ないものを選びます。そこで色々調べたところ、VueではVuetifyというUIフレームワークが王道ということで、これを選定しました。
ドキュメントのManual stepsに従って、入れていきます。
$ docker-compose exec app npm install vuetify@^3.1.6
vuetify.js
を作成します。これも分けずにapp.js
に記載しても問題ありません。
import 'vuetify/styles'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
const vuetify = createVuetify({
components,
directives,
})
export default vuetify
src/resources/js/app.js
で読み込みます。
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
+ import vuetify from "./vuetify";
const app = createApp(App);
app.use(router)
+ app.use(vuetify)
app.mount("#app");
Vuetifyを使用する準備が整ったので、各コンポーネントに反映していきます。
ボタンコンポーネントの追加
VuetifyのButtonsを使って、ボタンを共通化しました。
<script setup>
const props = defineProps({
link: String,
name: String,
})
</script>
<template>
<v-btn variant="outlined" :to="link">{{ name }}</v-btn>
</template>
ボタンを押下した際に変更するURLのlink
とボタン名name
を親コンポーネントからprops
で受け取ります。
ヘッダー
src/resources/js/components/Header.vue
は、作成したボタンコンポーネントを使用するように変更します。
<script setup>
+ import Button from './Button.vue'
</script>
<template>
- <router-link to="/tasks" >タスク一覧</router-link>
- <router-link to="/tasks/create">タスク追加</router-link>
+ <Button link="/tasks" name="タスク一覧" class="mr-5"/>
+ <Button link="/tasks/create" name="タスク追加" />
</template>
タスク追加画面
VuetifyのFormsを使って、下記のように修正します。画面は差分が多いので差分表示はしていませんので、詳しく差分が見たい方はコミットの確認をお願いします。
<script setup>
import Button from './Button.vue'
</script>
<template>
<v-sheet width="300" class="mx-auto">
<v-form>
<v-text-field
label="タイトル"
></v-text-field>
<v-text-field
label="内容"
></v-text-field>
<v-text-field
label="担当者"
></v-text-field>
<Button name="追加" block class="mt-2"/>
</v-form>
</v-sheet>
</template>
タスク一覧画面
VuetifyのTablesを使って、下記のように修正します。
<script setup>
import Button from './Button.vue'
const tasks = [
{number: 1, title: '仮のタイトル1', content: '仮の内容1', personInCharge: '仮の担当者1'},
{number: 2, title: '仮のタイトル2', content: '仮の内容2', personInCharge: '仮の担当者2'},
{number: 3, title: '仮のタイトル3', content: '仮の内容3', personInCharge: '仮の担当者3'},
]
</script>
<template>
<v-table>
<thead>
<tr>
<th>番号</th>
<th>タイトル</th>
<th>内容</th>
<th>担当者</th>
<th>詳細</th>
<th>編集</th>
<th>削除</th>
</tr>
</thead>
<tbody>
<tr v-for="task in tasks" key="task.number">
<th>{{ task.number }}</th>
<td>{{ task.title }}</td>
<td>{{ task.content }}</td>
<td>{{ task.personInCharge }}</td>
<td><Button :link="'/tasks/' + task.number" name="詳細" /></td>
<td><Button :link="'/tasks/' + task.number + '/edit'" name="編集" /></td>
<td><Button name="削除" /></td>
</tr>
</tbody>
</v-table>
</template>
ダミーデータを宣言して、v-forでデータの個数分(上記コードだと3回)回してレンダリングしています。詳細ボタンと編集ボタンに設定しているリンクも、回ってきたnumber
で動的に生成されるようになっています。
タスク詳細画面
VuetifyのFormsを使って、下記のように修正します。
<script setup>
import Button from './Button.vue'
const props = defineProps({
taskId: String
})
</script>
<template>
<v-sheet width="300" class="mx-auto">
<v-form disabled>
<v-text-field
label="番号"
:model-value="taskId"
></v-text-field>
<v-text-field
label="タイトル"
></v-text-field>
<v-text-field
label="内容"
></v-text-field>
<v-text-field
label="担当者"
></v-text-field>
<Button link="/tasks" block name="戻る" />
</v-form>
</v-sheet>
</template>
タスク編集画面
VuetifyのFormsを使って、下記のように修正します。
<script setup>
import Button from './Button.vue'
const props = defineProps({
taskId: String
})
</script>
<template>
<v-sheet width="300" class="mx-auto">
<v-form>
<v-text-field
label="番号"
:model-value="taskId"
readonly
>
</v-text-field>
<v-text-field
label="タイトル"
></v-text-field>
<v-text-field
label="内容"
></v-text-field>
<v-text-field
label="担当者"
></v-text-field>
<Button block name="保存" />
</v-form>
</v-sheet>
</template>
これで、Vuetifyで見た目を整えることができました。
終わりに
次回、LaravelでバックエンドのAPIを作ります。
余談ですが、業務ではjQueryしか触っていなかったので、モダンフロントエンドのいい練習になりました。とはいえ、Reactを学生時代に個人で触っていたこともあり、Vueも意外とすんなりいけた気がします。基本的なところは変わらないなと思いました。