SpringBootで包み込むVue.js開発環境 カンタン構築 SPA編

# はじめに

以前、Spring Boot に Vue.js を統合した開発環境の構築手順を説明する記事を書いたのですが、できることをひとつずつ増やそうの精神でほぼ同じ構成の SPA バージョンにも挑戦したので、こちらの環境構築手順も紹介しようと思います。

https://qiita.com/hiroe-mkk/items/f065a27e924f4d21a715

記事中のソースコードは以下のリポジトリから参照できます。

https://github.com/hiroe-mkk/springboot-vue-spa-tips

誤りなどがありましたら、ご指摘いただけますと幸いです。

# 環境

- Java 17
- Spring Boot 3.2.2
- gradle-node-plugin 7.0.1
- vue 3.4.21
- vite: 5.2.0
- vue-router: 4.3.0
- Node.js 21.6.1
- IntelliJ IDEA 2023.3.3 (Community Edition)
- Windows 11 Pro

# プロジェクト構成概要

**前回と同じ点**

- バックエンドには `Spring Boot` を使用し、`Gradle` プロジェクトとして管理する
- フロントエンドには `Vue.js 3` を使用し、`Node.js` プロジェクトとして管理する
- `Node.js` プロジェクトは `Gradle` プロジェクトのサブプロジェクトとして扱う
- [gradle-node-plugin](https://github.com/node-gradle/gradle-node-plugin) を使って、バックエンドとフロントエンドのビルドサイクルを統合する
- フロントエンドのビルド成果物は `jar` ファイルに同梱し、`Spring Boot` の静的リソース配信機能を利用して配信する

**前回と異なる点**

- バックエンドでは、テンプレートエンジン (Thymeleaf) を使用しない
- ビルドツールを `Webpack` から `Vite` に変更する
- フロントエンドに `Vue Router` を追加する

Spring Boot アプリケーションは、主に `RESTful API` を構築するのに使用するため、今回はテンプレートエンジンは不要です。

また、ビルドツールは `TypeScript` の導入がスムーズに行えるよう、[公式が推奨](https://ja.vuejs.org/guide/typescript/overview#note-on-vue-cli-and-ts-loader)する `Vite` に変更しました。

フロントエンドに使用するライブラリについては、SPA の基本的な機能を実装できる最小限の構成をとるために、`Vue Router` のみを導入しています。

# 環境構築

以下のような手順で環境を構築していきます。

**1. Spring Boot プロジェクトを作成する**
**2. Vite ベースの Vue.js プロジェクトを作成する**
**3. フロントエンドのビルド成果物をバックエンドに統合する**
**4. Vue Router を追加する**

## 1. Spring Boot プロジェクトを作成する

まずは、バックエンド用の Spring Boot プロジェクトを作成します。

作成手順は前回と同様ですので、詳細はそちらを参照してください。
ただ、今回は `Thymeleaf` を使用しない構成なので、依存関係には最低限 `spring-boot-starter-web` を追加するだけで大丈夫です。

https://qiita.com/hiroe-mkk/items/f065a27e924f4d21a715#1-spring-boot-%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B

## 2. Vite ベースの Vue.js プロジェクトを作成する

続いて、フロントエンド用のプロジェクトを作成しましょう。

基本的には、前回の「 [2. Node.js プロジェクトを作成する](https://qiita.com/hiroe-mkk/items/f065a27e924f4d21a715#2-nodejs-%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B) 」と同じ内容ですが、今回はビルドツールに `Vite` を採用するため、少々手順が変わります。

### 2.1 create-vite で Vue.js プロジェクトを初期化する

`Vite` の [Create Vite](https://github.com/vitejs/vite/tree/main/packages/create-vite) を使ってフロントエンド用プロジェクトを作成します。
`Create Vite` は、Vite ベースの新規プロジェクトを手早く始められるスターターキットです。

まずは、プロジェクトルートにおいて以下のコマンドを実行して、`Vite` ベースの `Vue.js` プロジェクトを作成してください。

```bash
$ npm create vite@latest frontend -- --template vue
```

ここでは frontend というディレクトリ名にしていますが、お好きな名前にしていただいて構いません (その場合は以降の手順で適宜読み替えてください)。

初期化が完了すると、以下のような構造のディレクトリが生成されているかと思います。

```
frontend
├── public/    静的ファイル(画像、フォント等)を格納するディレクトリ
│ └── vite.svg
├── src/
│ ├── assets/    アセット(画像など)を格納するディレクトリ
│ │ └── vue.svg
│ ├── components/    Vue.jsコンポーネントを格納するディレクトリ
│ │ └── HelloWorld.vue
│ ├── App.vue    メインのVue.jsコンポーネント
│ ├── main.js    アプリのエントリーポイントとなるJavaScriptファイル
│ └── style.css
├── .gitignore
├── index.html    アプリのエントリーポイントとなるHTMLファイル
├── package.json    プロジェクトの依存関係や設定情報が記載されたファイル
└── vite.config.js    Viteの設定ファイル
```

これらは、`Vite` と `Vue.js` のベストプラクティスに基づいた標準的なディレクトリ構造になっています。
中核となるファイルは、以下の 3 つです。

- **index.html**: アプリのエントリーポイントとなる `HTML` ファイル
- **src/App.vue**: `Vue.js` アプリ全体の共通レイアウトを定義するコンポーネント
- **src/main.js**: `Vue.js` アプリの起動処理や設定を行う `JavaScript` ファイル

通常、これらのファイルは名前やディレクトリ配置を変更することなく、必要に応じて内容を編集していきます。

また、以下も不可欠な設定ファイルのため、名前やディレクトリ配置は変更しません。

- **package.json**: プロジェクトの依存関係や設定情報を記載したファイルで、`npm/yarn` などのパッケージマネージャーで管理される
- **vite.config.js**: `Vite` の設定ファイルで、ビルド設定やプラグインの設定などを行う

その他のファイルは単なるサンプルなので、開発中に削除しても構いません (ただし、今回はこちらを利用して動作確認を行うため、削除しないでください)。

### 2.2 Vue.js プロジェクトをサブプロジェクト化する

つぎに、生成された frontend ディレクトリを `Gradle` にサブプロジェクトとして認識させるための設定を行います。

プロジェクトルートにある `settings.gradle` ファイルに以下を追記してください。

```diff_gradle:setting.gradle
rootProject.name = 'demo'
+ include 'frontend'
```

### 2.3 gradle-node-plugin を追加する

frontend ディレクトリをサブプロジェクト化できたので、次はこのフロントエンドプロジェクトを `Gradle` と連携させる手順に移りましょう。

連携には、前回と同じく `gradle-node-plugin` を使用します。
今回は手順のみを簡潔に示しますので、詳細は前回の記事を参考にしてください。

frontend ディレクトリ直下に、以下のような内容の `build.gradle` ファイルを作成します。

```gradle:build.gradle (フロントエンド用)
plugins {
    id("com.github.node-gradle.node") version "7.0.1"
}

// プロジェクトローカルに Node.js をダウンロードする
node {
    download.set(true)
    version.set("21.6.1")
}
```

**追記**

frontend サブプロジェクトの build.gradle には、以下も記述するように書いていましたが、実際には `npmInstall` タスクが `npm_run_build` よりも先に実行されるため、この設定は不要でした。

```gradle:build.gradle (frontend サブプロジェクト)
// npm run build の前に npm install を実行する
tasks.getByName("npm_run_build") {
    dependsOn("npm_install")
}
```

実行順序の確認には、次のコマンドを利用しました。

```bash
$ ./gradlew npm_run_build --dry-run

:frontend:nodeSetup SKIPPED
:frontend:npmSetup SKIPPED
:frontend:npmInstall SKIPPED
:frontend:npm_run_build SKIPPED
```

**追記ここまで**

ルートプロジェクトの `build.gradle` ファイルに以下を追記してください。

```gradle:build.gradle (ルートプロジェクト用)
tasks.named('processResources') {
    dependsOn(":frontend:npm_run_build")
}
```

これで、`Gradle` によるルートプロジェクトのビルド実行時に、サブプロジェクトであるフロントエンドのビルドが実行されるようになります。

## 3. フロントエンドのビルド成果物をバックエンドに統合する

### 3.1 Vite ビルドの成果物の出力先を変更する

`Vite` のデフォルト設定では、ビルド成果物がプロジェクトの `dist` ディレクトリに出力されますが、この設定ではバックエンドとの連携が難しくなってしまいます。

そのため、`Spring Boot` アプリの静的リソース用ディレクトリにビルド成果物を直接出力するよう設定を変更しましょう。

手順 2.1 において `Create Vite` で自動生成された `frontend/vite.config.js` ファイルに、`build` セクションを追加してください。

```diff_javascript:vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
+   build: {
+     outDir: '../src/main/resources/static'
+   },
})
```

なお、ビルド成果物は必要に応じて `.gitignore` に以下を追記して、Git 管理から除外してください。

```.gitignore:.gitignore (ルートプロジェクト)
src/main/resources/static
```

### 3.2 SPA のための Controller を作成する

今回の構成では、フロントエンドのビルド成果物は `Spring Boot` アプリの静的コンテンツとして提供するため、ルートパスへのアクセス時に、ビルド済みの `index.html` ファイルを返すコントローラが必要になります。

具体的には、ルートプロジェクトの `src/main/java/` ディレクトリ配下の適切なパッケージに、以下のようなコントローラクラスを定義してください。

```java:IndexController.java
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class IndexController {

    @GetMapping(path = "/")
    public String forward() {
        return "forward:/index.html";
    }
}
```

このメソッドでは、ルートパス (/) への `GET` リクエストに対して、`forward:index.html` を返しています。
`forward:`キーワードを使うことで、`Spring` が `index.html` ファイルをレンダリングするのではなく、静的リソースとして返すことができます。

### 3.3 動作確認

このあと `Vue Router` も追加しますが、ひとまずここでサンプルコードを追加して、ここまでの手順に問題がないかを確認してみましょう。

フロントエンドから `axios` を使ってバックエンドにリクエストを送信し、返されたデータを表示する方法を示します。

まずはバックエンド側で、以下のようなコントローラクラスを作成してください。
先ほどと同様、ルートプロジェクトの `src/main/java/` 配下の適切なパッケージに置いてください。

```java:HelloController.java
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api")
public class HelloController {

    @GetMapping("/hello")
    @ResponseBody
    public String getMsg() {
        return "Hello, World!";
    }
}
```

つぎに frontend ディレクトリに移動して、以下のコマンドを実行し、`axios` をインストールしてください。

```bash
$ npm install axios@latest
```

続いて、`frountend/src/components/HelloWorld.vue` に、以下のような変更を加えてください (※ 一部省略)。

```diff_vue:HelloWorld.vue
<script setup>
- import { ref } from 'vue'
+ import { ref, onMounted } from 'vue'
+ import axios from 'axios';

defineProps({
  msg: String,
})

const count = ref(0)
+ const msg2 = ref('');
+
+ onMounted(() => {
+   axios.get('/api/hello')
+     .then(response => {
+       msg2.value = response.data;
+     })
+     .catch(error => {
+       console.error('Error:', error);
+     });
+ });
</script>

<template>
  <h1>{{ msg }}</h1>
+   <h2>{{ msg2 }}</h2>

  <div class="card">
    ...
  </div>

  ...
</template>

<style scoped>
  ...
</style>
```

以上です。以下のコマンドを実行してアプリケーションを起動してください。

```bash
$ ./gradlew bootRun
```

`Node.js` プロジェクトのビルドに成功すると、以下のようにルートプロジェクトの `src/main/resources/static/` ディレクトリ配下に `index.html` などのビルド成果物が生成されているはずです。

![3-3-dir.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3671461/5098689c-d2d4-dd70-2607-3f763c11f9e5.png)


ブラウザで [http://localhost:8080](http://localhost:8080) にアクセスして、以下のように表示されれば成功です。

![3-3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3671461/57ff9b51-e048-fbbb-98b9-4b2d444a8d21.png)

## 4. Vue Router を追加する

### 4.1 Vue Router をインストールする

続いて、SPA 内で画面遷移や URL 管理を行うために、`Vue Router` を追加して、設定を行っていきましょう。

frontend ディレクトリに移動して、以下のコマンドを実行してください。

```bash
$ npm install vue-router@latest
```

### 4.2 Vue Router を組み込む

つぎに `Vue.js` アプリケーションに `Vue Router` を組み込む設定を行います。
手順 2.1 において `create-vite` で自動生成された `frountend/src/main.js` に、以下のような変更を加えてください。

```diff_javascript:main.js
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
+ import router from './router'

- createApp(App).mount('#app')
+ const app = createApp(App)
+ app.use(router)
+ app.mount('#app')
```

この変更により、`Vue Router` が `Vue` アプリケーション内でのルーティングを担当するようになります。

### 4.3 SPA における URL の変更とバックエンド側の対応

SPA では、初回ロード時に必要な静的ファイル一式をサーバから取得し、それ以降の画面遷移やデータ取得は、基本的にクライアント側で処理します。

そのため、基本的にはページ遷移を行っても、サーバに新しいページを要求することはありませんが、リロードや URL の直接入力によってサーバにリクエストが送られた場合、通常の Web サーバではリソースが存在しないため、エラーになってしまいます。

SPA を正しく機能させるためには、サーバは常に index.html を返すように設定する必要があるのです。

ルートプロジェクトの `src/main/java/` ディレクトリ配下の適当なパッケージに、以下のような `WebMvcConfigurer` を実装したクラスを作成してください。

```java:SpaWebMvcConfigurer.java
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;
import java.io.IOException;

@Configuration
public class SpaWebMvcConfigurer implements WebMvcConfigurer {

    // 静的リソースのハンドリングをカスタマイズする
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/**")
                .addResourceLocations("classpath:/static/")
                .resourceChain(true)
                .addResolver(new PathResourceResolver() {
                    @Override
                    protected Resource getResource(String resourcePath, Resource location) throws IOException {
                        Resource resource = location.createRelative(resourcePath);
                        // 静的リソースが存在しない場合は、index.html を提供する
                        return resource.exists() ? resource : location.createRelative("index.html");
                    }
                });
    }
}
```

ここでは、`addResourceHandlers()` メソッドで静的リソースのハンドリングをカスタマイズすることで、静的リソースへのリクエストを処理し、リソースが存在しない場合は `index.html` を提供するように設定しています。

この変更により、SPA が正しく機能し、ブラウザでのページ遷移がスムーズに行われるようになります。

設定は以上です。

### 4.4 動作確認

せっかくなので、動作確認を行いましょう。

まず、`frontend/src/` ディレクトリ直下に `views/` ディレクトリを作成し、その中に以下の 2 つの Vue コンポーネントファイルを作成してください。

```vue:HomeView.vue
<template>
  <div class="home">
    <h1>This is an home page</h1>
  </div>
</template>

<style>
@media (min-width: 1024px) {
  .home {
    min-height: 100vh;
    align-items: center;
  }
}
</style>
```

```vue:AboutView.vue
<template>
  <div class="about">
    <h1>This is an about page</h1>
  </div>
</template>

<style>
@media (min-width: 1024px) {
  .about {
    min-height: 100vh;
    align-items: center;
  }
}
</style>
```

これらのファイルは `Vue Router` のページコンポーネントとして機能します。

続いて、ルーティングの設定を行います。
`frontend/src/` ディレクトリ直下に `router/` ディレクトリを作成し、以下のような内容の `index.js` ファイルを作成してください。

```javascript:index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/about',
      name: 'about',
      component: () => import('../views/AboutView.vue')
    }
  ]
})

export default router
```

簡単に説明すると、`history` オプションでは、ブラウザの `HistoryAPI` を使ったルーティングを指定しています。これにより、ユーザーがブラウザの戻る / 進むボタンを使ってもページ遷移できるようになります。

`routes` オプションでは、各ルートの設定を行っています。`path` には `URL` パスを、`name` にはルート名を、`component` にはそのルートに対応するページコンポーネントを設定しています。

さいごに、`frontend/src/App.vue` ファイルを以下のように編集してください (※ 一部省略)。

```diff_vue:App.vue
<script setup>
import { RouterLink, RouterView } from 'vue-router'
import HelloWorld from './components/HelloWorld.vue'
</script>

<template>
  <div>
    ...
  </div>

-  <HelloWorld msg="Vite + Vue" />
+  <div>
+    <HelloWorld msg="Vite + Vue" />
+
+    <nav>
+      <RouterLink to="/">Home</RouterLink>
+      <RouterLink to="/about">About</RouterLink>
+    </nav>
+  </div>
+
+  <RouterView />
</template>

<style scoped>
...

+ nav a.router-link-exact-active {
+   color: var(--color-text);
+ }
+ nav a {
+   display: inline-block;
+   padding: 0 1rem;
+   border-left: 1px solid var(--color-border);
+ }
</style>
```

ここでは、`Vue Router` の基本的な機能を使って、アプリケーションのナビゲーション部分と、ページコンテンツの表示を行うための変更を加えています。

`RouterLink` コンポーネントは、ナビゲーションリンクを定義するために使用されます。
`to` 属性に URL パスを指定することで、ルーティングに応じた適切なリンクが生成されます。

そして、`RouterView` コンポーネントは、現在のルーティングに対応するページコンポーネントをレンダリングする役割を担います。
これにより、ユーザーがナビゲーションリンクをクリックした際に、正しいページコンテンツが表示されるようになります。

以上です。以下のコマンドを実行してアプリケーションを起動してみましょう。

```bash
$ ./gradlew bootRun
```

ブラウザで [http://localhost:8080](http://localhost:8080) にアクセスします。

追加したリンクをクリックして画面遷移を行うと、それに伴って赤枠で囲まれた部分が変化すれば成功です。

![4-4-home.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3671461/9325d5cc-3108-0cf7-c88f-d71e16744c85.png)

![4-4-about.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3671461/0c6f6632-09ce-c0ea-ed08-90a0e1b7c854.png)

# さいごに

SPA って難しいんじゃないかと思っていましたが、こうしてできるところから徐々に攻めていくと、案外スルッと馴染めるものですね。
できることをひとつずつ、地道に増やしていけたら、きっといつかはつよつよになれますよね。

それでは、最後までお読みいただき、ありがとうございました！

# 追記

OpenAPI を導入する記事も書きました！

https://qiita.com/hiroe-mkk/items/cced6bfcc1a4164f80ab

よろしければ、こちらもどうぞ。