Vue.jsで簡単なチャットアプリを作ってみた

Vue.jsの学習の為、チャットアプリを作成してみました。
Vue.jsの記法を学んだ後に、どのように成果物を作ればいいかの参考に少しでもなればと思い記事を作ってみました。

実装はGitHubに上げております。
フロント(Vue.js)
https://github.com/tkNoPrivate/chat-project-front
サーバー(Java)
https://github.com/tkNoPrivate/chat-project-server


## 構成

[Vueアプリ構成](#Vueアプリ構成)
[vuetify](#vuetify)
[ルーティング](#ルーティング)
[APIアクセス](#APIアクセス)
[エラー処理](#エラー処理)
[アプリ紹介](#アプリ紹介)
[まとめ](#まとめ)


## Vueアプリ構成

![Vueアプリ構成図.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/590579/16a1a56c-80ce-6c65-dca0-7b16a734a259.jpeg)

**Vue CLI**というコマンドラインツールを使用してプロジェクトの雛形を作成しています。
**index.html**で全画面を描画することになります。 SPAというらしいです。
### Viewsディレクトリ
URL毎に切り替える画面を実装したVueファイルを格納しています。**router**という公式プラグインで切り替え制御を行います。
### componentsディレクトリ
共通で使用する部分など、さらに細かい部品を実装したVueファイルを格納しています。

### 実装
**1.** **index.html**にて、idに**app**を指定します。

```html:index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <!-- 省略 -->
  </head>
  <body>
    <!-- 省略 -->
    <div id="app"></div> 
    <!-- built files will be auto injected -->
  </body>
</html>
```
**2.** **main.js**で**App.vue**ファイルをマウントします。
**App.vue**をインポートして、templateレンダリングをしています。

```javascript:main.js
import Vue from 'vue'
import App from './ App.vue'

// ~ 省略 ~

Vue.config.productionTip = false

// ~ 省略 ~
new Vue({ // App.vueをマウント
  // ~ 省略 ~
  render: h => h(App) 
}).$mount('#app') 

```

**3.** **App.vue**では画面描画に使用するVueファイルを配置します。

```vuejs:App.vue
<template>
  <v-app>
    <Header @searchPost="setPost" />
    <Message v-show="!isShowDialog" />
    <router-view ref="post" />
  </v-app>
</template>

<script>
import Header from "./components/Header";
import Message from "./components/Message";
import Constant from "./common/constant";
import messageStore from "./store/message-store";
import userStore from "./store/user-store";

export default {
  components: {
    Header,
    Message,
  },
  async mounted() {
    const currentPath = this.$route.path;
    if (
      currentPath !== "/" &&
      currentPath !== "/login" &&
      currentPath !== "/account/signup"
    ) {
      userStore.setUserStore();
    }
  },
  data() {
    return {
      searchText: "",
    };
  },
  computed: {
    isShowDialog() {
      return messageStore.state.isShowDialog;
    },
  },
  errorCaptured(err) {
    const status = err.response.status;
    if (status !== 404 && status !== 500) {
      const data = err.response.data.fields;
      const errorFields = data.fields ? data.fields : [];
      messageStore.setMessageInf(Constant.ERROR, data.messages, errorFields);
    }

    return false;
  },
  methods: {
    setPost(searchText) {
      this.$refs.post.searchPost(searchText);
    },
  },
};
</script>


```

**\<Header \/\>** タグや **\<Message \/\>** タグがありますが、これが実装したコンポーネントです。
ヘッダー、メッセージコンポーネントは前画面共通で表示するので**App.vue**に記述します。

**\<router-view \/\>** タグは、URLが変わった際にテンプレートが切り替わる部分となります。
**router**という公式プラグインを使用して制御しています。それについては[ルーティング](#ルーティング)で記載します。

## vuetify
デザインは、vuetifyというフレームワークを使用しています。
これを使用することで、簡単に良い感じの見た目になります。

以下公式サイトを参考に実装します。
https://vuetifyjs.com/ja/

## ルーティング

各URLに対応したテンプレートを切り替えるには、**router**というプラグインを使用します。
### 前準備
**router**の定義ファイルを作成します。今回はrouter\/index.jsに実装しています。

```javascript:index.js
import Vue from "vue";
import VueRouter from "vue-router";
import Index from "../views/Index"; // 1
import AccountSignup from "../views/AccountSignup";
import Login from "../views/Login";
// ~ 省略 ~

Vue.use(VueRouter);

const routes = [ // 1
  {
    path: "/",
    name: "index",
    component: Index,
  },
  {
    path: "/account/signup",
    name: "AccountSignup",
    component: AccountSignup,
  },
  {
    path: "/login",
    name: "Login",
    component: Login,
  },
  // ~ 省略 ~
];

const router = new VueRouter({ // 2
  mode: "history", // historyを指定することでURLに#が含まれなくなる。デフォルトは含まれる為、指定しておく
  base: process.env.BASE_URL, // 基底URLを指定
  routes, // 定義したオブジェクト達
});

// ~ 省略 ~

export default router; // 3

```

### 実装ポイント

- viewsディレクトリにて実装したファイルをインポートし、下記を定義したオブジェクトを作成します。(コメント1)

|key  |value  |
|---|---|
|path  |URLを指定  |
|name  |名前を定義  |
|component  |コンポーネント名を指定  |

- VueRouterのインスタンスを生成し、定義したオブジェクト達を引数に渡します。(コメント2)

- エクスポートし(コメント3)、エントリポイントの**main.js**にて宣言を追加する。

```javascript:main.js
new Vue({
  router, // routerの宣言
  vuetify,
  render: h => h(App)
}).$mount('#app')

```

### 処理の実装
[構成](#構成)で触れたApp.vueに**router-view**タグがあったかと思います。

```vuejs:App.vue
<template>
  <!-- 省略 -->
    <router-view />
  <!-- 省略 -->
</template>

```

URLが切り替わると、ここが切り替わって画面が変わります。

### リンク
リンクを作成する場合は下記のようにパスを指定します。

```html
<router-link to="/">リンク</router-link>
```

### プログラム遷移
Scriptから遷移する場合は下記のようにパスを指定します。

```javascript
this.$router.push("/")
```

## APIアクセス

今回作成したアプリでは、ユーザー情報や投稿をDBに保存しています。
その際に、**axios**という非同期通信が出来るJavaScriptのライブラリを使用しています。
### 前準備
**main.js**に**axios**を使用するための記述をすることで使用可能です。

```javascript:main.js
import Vue from 'vue'
import App from './ App.vue'
// ~ 省略　~
import axios from 'axios'
import VueAxios from 'vue-axios'

Vue.config.productionTip = false
Vue.use(VueAxios, axios) // axiosの使用宣言。VueAxiosを使用することで記述が簡単になるのであわせて宣言。

// ~ 省略　~

```

### 処理の実装
基本的な書き方はこんな感じです。

```vuejs:基本構文
<script>
import axios from "axios";

export default {
// ~ 省略 ~
  data() {
    return {
      // ~ 省略 ~
    };
  },
  methods: {
    get() {
      axios
        .get("/user")
        .then((res) => {
         // 正常時処理
        })
        .catch((e) => {
         // エラー処理
        });
    },
  },
};
</script>


```

### 実装ポイント

- get(またはpost)の引数にURLを指定する。
- 処理が正常終了すると、thenに処理が入る。上記だとresにレスポンス情報が入っている。
- APIでエラーが発生した場合、catchに処理が入る。

**axios.get\(URL\)** は**Promise**というオブジェクトを返します。
**Promise**には正常またはエラーレスポンスが保管されています。それを**then**、**catch**で取り出して処理をすることになります。

### async/await

APIの戻り値を使用して処理をしたい場合は、その後の処理を**then**の中に書く必要があり可読性が悪くなりそうです。
なので私は**async/await**なるもので実装することにしました。

```vuejs:async/awaitで書いた
<script>
import axios from "axios";

export default {
// ~ 省略 ~
  data() {
    return {
      // ~ 省略 ~
    };
  },
  methods: {
    async get() {
      const res = await axios.get("/user")
        .catch((e) => {
         // エラー処理
        });

      console.log(res);
    },
  },
};
</script>

```
### 実装ポイント

- 関数に**async**をつける。
- APIアクセス処理の頭に**await**をつけると、その処理の終了を待つ。
- 戻り値を変数で受け取る事ができる。

これで、非同期処理でも同期処理のように上から順に処理を記述でき可読性が上がります。
レスポンス情報を使用しない場合など、後の処理に依存しない場合は**await**をつけなくても良いです。

## エラー処理

APIアクセスにてエラーが発生した場合はメッセージを上部に表示する挙動にしています。
その際にaxiosの**interceptors**というものを使用してエラーを検知しています。
axiosでのAPIアクセス前後に処理を挟み込むことが出来ます。

### 処理フロー
![Vueアプリエラー処理フロー図.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/590579/d21e988d-7d0a-bd30-ecb3-203283227cdd.jpeg)


APIアクセスした際に、エラーが返却された場合は**interceptors**へ処理が移り、**App.vue**へエラーをスローします。スローされた情報をもとにエラーメッセージを**message-store.js**に設定します。
**Message.vue**では**message-store.js**を参照しています。グローバルに管理したいデータはstoreパターンという方法を使用しています。

### 実装

実際のコードを見てみます。
**interceptors**は**axiosInterceptor.js**にて実装しました。

```javascript:axiosInterceptor.js
import axios from "axios";
import router from "./router";

//1
const axiosInstance = axios.create({ baseURL: "http://localhost:8080" });
// ~ 省略 ~

axiosInstance.interceptors.response.use( 
  (response) => { //2
    return response;
  },
  (err) => { //3
    const status = err.response.status;
    switch (status) {
      case 401:
        router.push("/login");
        break;
      case 404:
      case 500:
        router.push(`/error/${status}`);
        break;
    }
    throw err;
  }
);

export default axiosInstance; //4

```

### 実装ポイント

- 基底URLを定義したaxiosインスタンスを作成(コメント１)
- 正常レスポンスの場合は、そのまま返却しそれぞれのAPIアクセス箇所で処理を継続する。(コメント2)
- エラーレスポンスの場合は、エラーをApp.vueへスローする。401の場合は未認証の為ログイン画面へ遷移、404、500はそれぞれエラーページへ遷移する。(コメント3)
- エクスポートする。(コメント4)

APIアクセスの際は、**axiosInterceptor.js**でエクスポートしたaxiosを使用すればAPIアクセス処理でエラー処理を毎回記述する必要がなくなります。

```javascript
<script>
import axiosInstance from "../axiosInterceptor";

export default {
// ~ 省略 ~
  data() {
    return {
      // ~ 省略 ~
    };
  },
  methods: {
    async get() {
　　　　　　　　　　　　//エラーが発生した場合はaxiosInterceptorでcatchする
      const res = await axiosInstance.get("/user") 
    },
  },
};
</script>

```

**axiosInterceptor.js**でスローされたエラーはApp.vueでハンドリングします。
コンポーネントでエラーが発生した場合、ハンドリングしない限り親に伝播されていきます。
App.vueはルートとなるため、そこでエラーハンドリングを実装しました。


```vuejs:App.vue
<template>
　　　　<v-app>
　　　　　　　　<!-- 省略 -->

　　　　　　　　<!-- 1 -->
    <Message v-show="!isShowDialog" />

    <!-- 省略 -->
  </v-app>
</template>
<script>
import Header from "./components/Header";
import Message from "./components/Message";
import Constant from "./common/constant";
import messageStore from "./store/message-store";

export default {
　　　components: {
    Header,
    Message,
  },

  //~ 省略 ~

  //伝播されたエラーをハンドリング
  errorCaptured(err) { //2
    const status = err.response.status;
    if (status !== 404 && status !== 500) {
      const data = err.response.data.fields;
      const errorFields = data.fields ? data.fields : [];
      messageStore.setMessageInf(Constant.ERROR, data.messages, errorFields);
    }
    return false; //3
  },

  //~ 省略 ~

};
</script>

```


### 実装ポイント

- メッセージコンポーネントに渡すデータを定義する。(コメント１)
- VueのライフサイクルであるerrorCapturedを使用することでコンポーネントでスローされたエラーをハンドリングすることが出来る。伝播されたエラー情報を基に、エラーメッセージを設定する。(コメント2)
- falseをreturnすることで、伝播がここでストップする。(コメント3)

App.vueから**message-store.js**にメッセージ情報を設定します。

```javascript:message-store.js
import Vue from "vue";

const messageStore = Vue.observable({//1
  state: {//2
    type: "",
    messages: [],
    errorFields: [],
    isShowDialog: false,
  },
  setMessageInf(type, messages, errorFields) {//3
    this.state.type = type;
    this.state.messages = messages;
    this.state.errorFields = errorFields;
  },
  clearMessageInf() {
    this.state.type = "";
    this.state.messages = [];
    this.state.errorFields = [];
  },
  onDialogShowFlg() {
    this.state.isShowDialog = true;
  },
  offDialogShowFlg() {
    this.state.isShowDialog = false;
  },
});

export default messageStore;

```

### 実装ポイント
- **Vue.observable**を使用することで、オブジェクトをリアクティブにすることができる。コンポーネント側では算出プロパティ(computed)で変更を検知します。(コメント1)
- stateオブジェクトにデータを定義する。(コメント2)
- 設定用の関数を用意しておく。値の設定、更新はこの関数を使用することでどこでどんな値の変更が行われたかも追いやすくなる。(コメント3)

**Message.vue**では**message-store.js**のstateを参照しています。

```vuejs:Message.vue
<template>
  <div>
    <!-- 1 -->
    <v-alert
      class="message"
      v-for="val in messages"
      :key="val"
      border="top"
      :color="type === 'info' ? 'blue lighten-2' : 'red lighten-2'"
      dark
      dense
      dismissible
    >
      {{ val }}
    </v-alert>
  </div>
</template>

<script>
import messageStore from "../store/message-store";//2
export default {
  name: "Message",
  watch: {//3
    $route() {
      messageStore.clearMessageInf();
    },
  },
  computed: {//4
    type() {
      return messageStore.state.type;
    },
    messages() {
      return messageStore.state.messages;
    },
  },
};
</script>

<style scoped>
.message {
  position: relative;
  top: 50px;
}
</style>

```

### 実装ポイント
- Vuetifyのv-alertというものを使用している。メッセージは複数の場合もあるので配列になっている。info、errorで色を出し分けている。(コメント1)
- **message-store.js**をインポートする。(コメント2)
- watchプロパティでrouterを監視する。画面遷移時にメッセージをクリアする。(コメント3)
- **computed**でmessage-store.jsのstateの変更を検知する。(コメント4)


## アプリ紹介

最後に、簡単にアプリ紹介をします。
まずトップ画面です。シンプルです。
![チャットアプリ_トップ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/590579/e0804e05-656b-3a6c-50d4-c6acc55d326a.png)


トップから新規登録画面に遷移するとこんな感じです。
![チャットアプリ_新規登録.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/590579/2071558c-191f-f4a4-219e-23bbe7f85327.png)

エラー時はこんな感じです。
![チャットアプリ_新規登録_エラー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/590579/e7f909d0-388a-5834-302b-f95c8ce27d13.png)


ログイン画面です。
![チャットアプリ_ログイン.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/590579/92f63dec-6960-2b6b-1280-5f3764e94778.png)

パスワードは表示、非表示を切り替え可能です。
![チャットアプリ_ログイン_パスワード非表示.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/590579/7b081019-6c59-998d-5644-4dceb707f7ec.png)

![チャットアプリ_ログイン_パスワード表示.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/590579/5eef06b6-06aa-65b3-ab7a-9e21c9d52060.png)

ログインすると、投稿画面に遷移します。
![チャットアプリ_投稿画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/590579/e5d2f947-99d5-1aa4-9db1-181d0527b1c4.png)

「返信する」押下でコメント出来ます。
![チャットアプリ_投稿画面_コメント返信.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/590579/0a043f9a-1f17-4135-a03b-a0dc523bfeea.png)

コメントはこんな感じで表示されます。
投稿と同じコンポーネントを使い回しています。
![チャットアプリ_投稿画面_コメント表示.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/590579/bb86d719-f137-cc09-386b-e7c7e7618aac.png)

いいね!!も出来ます。
![チャットアプリ_投稿画面_いいね.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/590579/70e73b81-54b9-5550-2b0a-d3a29bf15a51.png)

マウスオンでいいねしたユーザーが表示されます。
![チャットアプリ_投稿画面_いいね2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/590579/79192339-3686-2525-6710-75e1a20cb6d0.png)

投稿やコメントの編集、削除も出来ます。
![チャットアプリ_投稿画面_編集.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/590579/b1adad5d-bfb4-ca8f-67d0-4ecc97c8a4f0.png)

![チャットアプリ_投稿画面_編集2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/590579/20a6e8e8-f71a-b403-f117-180d86f4ec3b.png)

サイドメニューを開くと、参加している部屋が表示され、別の部屋に移動できます。
![チャットアプリ_投稿画面_サイドメニュー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/590579/a55729f7-c9df-c058-c3cf-cec6efdf064d.png)

![チャットアプリ_投稿画面_サイドメニュー項目押下.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/590579/3ff1c362-64dc-98a5-8ddf-8948e4815b0a.png)

他にも、アカウント編集や部屋管理の画面がありますが長くなってしまうので割愛します。

## まとめ

新しい言語やフレームワークを学ぶというのは、ワクワクもありますが大変なことが多いです。
今回は下記順番で学習してみました。
1. 基本構文の学習
2. アプリを作成してみる
3. 品質を意識してみる

3については、まだまだ改善の余地はあるかと思います。
現場ではまだvueを使用したことがないので、その機会があれば今回の作成アプリの改善点も多く見えるのかなと思いました！

エンジニア経験3年になるので、動けば良いではなく、品質も意識していければと思います。