Laravel と Vue と Docker でシンプルな SPA を作る ①【フロントエンド】

# はじめに
学生時代にプログラミングを始めて間もないときに、こんな言葉を目にしたことがあります。
「世の中のWebサービスのほとんどは、掲示板サービスをちょっと変えたくらいのものだ。」

要は、CRUD機能のあるシンプルなアプリが一つ作れれば、あとはそれを応用して組み合わせたりするだけだということですね。あれから数年が経ち業務でサービス開発に携わる中でも、本当にその通りだなと思います。

今回、LaravelとVueの基礎をどうやったら効率良く習得できるかなと考え、やはりシンプルなCRUDアプリを作ってみることにしました。タスク管理サービスです。

基本的には、下記一連の4本の記事を参考にしています。大変わかりやすくて、筆者の方に感謝です。

https://qiita.com/minato-naka/items/2d2def4d66ec88dc3ca2

ただ、数年前の記事なので、LaravelもVueも記法が変わったりしています。
その辺りを自分で調べながら色々ドキュメント見たりして調べながらやってみました。

この記事は、3本立ての1本目です。
- Laravel と Vue と Docker でシンプルな SPA を作る ①【フロントエンド】__←今ここ__
- [Laravel と Vue と Docker でシンプルな SPA を作る ②【バックエンド】](https://qiita.com/yutotakakura/items/280c804d836e6e7d47db)
- [Laravel と Vue と Docker でシンプルな SPA を作る ③【フロントエンドとバックエンドの通信】](https://qiita.com/yutotakakura/items/99831bc42dc7bc41f33a)

今回の目的は、LaravelとVueでSPAを作ってみて挙動を理解することなので、UIやバリデーション、エラーハンドリングなど細かい部分の作り込みは行っていません。

今回は、Vueのフロントの部分を作ります。あくまで、各コンポーネントの表示や遷移部分の実装です。今回画面に表示するデータは、仮で固定値を用います。次回以降でAPIを実装した後、通信によって動的に値が書き換わることを確認します。

# 全体像
## 画面遷移図
![画面遷移図 (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1447035/950b8c38-4df6-2b34-f346-14588afea1f5.png)

## クライアントとAPIの構成図
![全体像.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1447035/f1a9a8bb-d8d0-bbd0-0a97-f23b6ffa3bc1.png)

## コンポーネントの構成図
コンポーネントとは、アプリのUIを構築する上での部品の単位です。
この先、適宜この構成図を見返していただくと、今どの部分の実装をしているのかがわかりやすくなるかと思います。
### タスク追加画面
![タスク追加画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1447035/4ac70dd4-3e7e-d7f9-add9-c1d3bf856051.png)
### タスク一覧画面
![タスク一覧画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1447035/fa1b6937-fa40-4226-22ce-1d5908f00399.png)
### タスク詳細画面
![タスク詳細画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1447035/6d1aadbd-db63-df9f-b142-2ead7f46c1c1.png)
### タスク編集画面
![タスク編集画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1447035/43f18d31-3f49-4798-b320-59f31f278314.png)

## ソースコード
https://github.com/yutotakakura/laravel-vue-spa

# 環境構築
下記の記事で作った環境をそのまま使います。

https://qiita.com/yutotakakura/items/d1cc909897ac8277baea

今回のフロントの実装は、Viteの開発サーバーを立てて進めるのがホットリロードが動くので楽でいいと思います。
```
$ docker-compose exec app npm run dev
```
もちろん、好きなタイミングで静的ファイルにビルドして進めるのでも大丈夫です。
```
$ docker-compose exec app npm run build
```
どちらかを行わないと挙動が確認できませんので、ご注意ください。

# ベースを実装
まずは、UIを気にしないでフロントのベースの動きだけ作っていきます。
完成系は下記のような感じです。

https://twitter.com/yutotakakura/status/1632223952889352192?s=20

コードだと、[このコミット](https://github.com/yutotakakura/laravel-vue-spa/commit/c49e2ff8c51b2682bc28a1bd5d17878f5ca462b8)の実装内容です。

## Laravelのビュー側のルーティングを変更
SPAは従来のWebアプリケーションと動きが異なります。まず初回のみページ全体(HTML/CSS/JavaScript)をロードし、2回目以降はブラウザ側のJavaScriptを介して必要なデータのみを取得します。
今回は、Laravelのビュー側のルーティングを、どんなURLでアクセスが来ても初回は```src/resources/views/app.blade.php```を返すようにします。
```src/routes/web.php```を、下記のように修正します。
```diff_php
- Route::get('/', function () {
+ Route::get('/{any}', function() {
+     return view('app');
- });
+ })->where('any', '.*');
```

## ヘッダー
ヘッダーコンポーネントを作ります。
アプリを構成するためにコンポーネントをcomponentsディレクトリに格納しています。
```src/resources/js/components/Header.vue
<template>
  <router-link to="/tasks" >タスク一覧</router-link>
  <router-link to="/tasks/create">タスク追加</router-link>
</template>
```
[```router-link```タグ](https://router.vuejs.org/guide/#router-link)は、後に導入する[Vue Router](https://router.vuejs.org/)でURLを変更するためのタグです。```a```タグに変換されます。
作成したヘッダーコンポーネントを、ルートコンポーネントである```App.vue```で、読み込みます。
```src/resources/js/App.vue
<template>
  <Header />
</template>
```
## タスク追加画面
```TaskCreate.vue```コンポーネントを作成します。
```src/resources/js/components/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```コンポーネントを作成します。
```src/resources/js/components/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```コンポーネントを作成します。
```src/resources/js/components/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>
```
ポイントは、[プロパティ](https://ja.vuejs.org/guide/components/props.html)を宣言しているところです。
```vue
<script setup>
  const props = defineProps({
    taskId: String
  })
</script>
```
これにより、タスク一覧画面から遷移してきた際（コンポーネントが切り替わった際）、```props```として```taskId```を受け取ることができます。後の[Vue Router](https://router.vuejs.org/)の導入でも、タスク詳細コンポーネントに```props```で```taskId```を渡すように記載します。

## タスク編集画面
```TaskEdit.vue```コンポーネントを作成します。
```src/resources/js/components/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>
```
[プロパティ](https://ja.vuejs.org/guide/components/props.html)の宣言は、タスク詳細コンポーネントと同様です。

## Vue Router を導入
作成したコンポーネントをURLによって出し分けるため、[Vue Router](https://router.vuejs.org/)を導入します。
```
$ docker-compose exec app npm install vue-router@4
```
Vue Router の制御を記載する```route.js```を作成します。```app.js```内に記載してもいいのですが、量が少し多いので分けました。
```src/resources/js/router.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とそれに対応するコンポーネントを指定しています。
```javascript
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を生成しています。（[ドキュメント](https://router.vuejs.org/guide/essentials/dynamic-matching.html)）```props: true```として```taskId```が```props```としてコンポーネントに渡るようにしています。
作成したファイルを読み込み、有効化させるために```src/resources/js/app.js```を修正します。
```diff_javascript
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によってレンダリングされるようにします。
```diff_vue
<template>
  <Header />
+ <router-view></router-view>
</template>
```
```<router-view></router-view>```の部分に、```router.js```の```routes```で指定したコンポーネントがレンダリングされます。これで、ヘッダーは固定表示して、それ以外を出しわけるという制御が可能になりました。

ここまでで、フロントのベースは実装できました。

# UIを整える
SPAの挙動を理解するだけであれば、ここまででももちろんOKなのですが、今回はせっかくなので簡単にUIを整えてみました。
完成系としては、下記のような感じです。

https://twitter.com/yutotakakura/status/1632225465489563649

コードでは、[このコミット](https://github.com/yutotakakura/laravel-vue-spa/commit/4117ba02ef51734e2f324523b8460c5ebee46776)の内容です。
## Vuetifyを導入
UIのライブラリやフレームワークは、いろいろな選択肢があります。最近は、長らく有名な[Bootstrap](https://getbootstrap.jp/)のように記載量は少ないがCSSの自由度が低いものではなく、[Tailwind CSS](https://tailwindcss.com/)と[Headless UI](https://headlessui.com/)を組み合わせるなどして記載量は増えるがCSSの自由度高く組み上げていくのが主流のような気がします。

今回は、サクッとUIをいい感じにしたいだけなので、できるだけ記載量が少ないものを選びます。そこで色々調べたところ、Vueでは[Vuetify](https://vuetifyjs.com/en/)というUIフレームワークが王道ということで、これを選定しました。

ドキュメントの[Manual steps](https://vuetifyjs.com/en/getting-started/installation/#manual-steps)に従って、入れていきます。
```
$ docker-compose exec app npm install vuetify@^3.1.6
```
```vuetify.js```を作成します。これも分けずに```app.js```に記載しても問題ありません。
```src/resources/js/vuetify.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```で読み込みます。
```diff_javascript
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](https://vuetifyjs.com/en/components/buttons/)を使って、ボタンを共通化しました。
```src/resources/js/components/Button.vue
<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```は、作成したボタンコンポーネントを使用するように変更します。
```diff_javascript
<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](https://vuetifyjs.com/en/components/forms/)を使って、下記のように修正します。画面は差分が多いので差分表示はしていませんので、詳しく差分が見たい方は[コミット](https://github.com/yutotakakura/laravel-vue-spa/commit/4117ba02ef51734e2f324523b8460c5ebee46776)の確認をお願いします。
```src/resources/js/components/TaskCreate.vue
<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](https://vuetifyjs.com/en/components/tables/)を使って、下記のように修正します。
```src/resources/js/components/TaskList.vue
<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](https://ja.vuejs.org/api/built-in-directives.html#v-for)でデータの個数分(上記コードだと3回)回してレンダリングしています。詳細ボタンと編集ボタンに設定しているリンクも、回ってきた```number```で動的に生成されるようになっています。
## タスク詳細画面
Vuetifyの[Forms](https://vuetifyjs.com/en/components/forms/)を使って、下記のように修正します。
```src/resources/js/components/TaskShow.vue
<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](https://vuetifyjs.com/en/components/forms/)を使って、下記のように修正します。
```src/resources/js/components/TaskEdit.vue
<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](https://laravel.com/docs/10.x)でバックエンドのAPIを作ります。
余談ですが、業務ではjQueryしか触っていなかったので、モダンフロントエンドのいい練習になりました。とはいえ、Reactを学生時代に個人で触っていたこともあり、Vueも意外とすんなりいけた気がします。基本的なところは変わらないなと思いました。