TechHotoke’s diary

日々の学びについて記事としてまとめてます。

Vue×SpringでSPA作成③【ログイン機能のカスタマイズ~フロントエンド編~】

f:id:TechHotoke:20211216134054p:plain

まえがき

こちらの記事の続編です。

techhotoke.hatenablog.com

目的

VueとSpringで作成したプロジェクトの構築手順の備忘録。

備忘録のため、詳細な説明を省略している部分があります。

前提

基本的なJavaの知識やSpring、Vueの知識があること。

環境

  • Java 11
  • Spring Boot2.5.6
  • Gradle 7.1.1
  • Vue2.6
  • IDESTS

やること

  • ログイン画面の実装
  • ルーティングの実装
  • ホーム画面の実装(APIで取得したテキストを画面に表示させる)
  • ログアウト機能の実装

実装

それでは早速、実装を進めていきます。

環境構築

まずはaxiosとvuex,vuetifyをインストールします。

npm install vuex
npm install axios
npm install vuetify
npm install sass@~1.32 sass-loader deepmerge -D

git diff コマンドなどで確認してpackage.jsonにインストールしたパッケージの依存関係が追加されていればOKです。

※ Vuetify導入時にエラーが発生したので記事にまとめましたのでご参考までに。。。(フロントエンド開発は流れが特に早いので各ライブラリ間でのバージョンの互換性が取れなくなるのは当たりまえなのかなぁ・・・)

techhotoke.hatenablog.com

Vutifyの導入にあたって公式のnpmに沿ってwebpack.config.jsなどを作成しましたが、公式のまんまなので省略。 下記参考にしてください〜

vuetifyjs.com

次に、src直下のmain.jsファイルを以下のように書き換えます。

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import axios from 'axios'
import vuetify from '@/plugins/vuetify'

Vue.config.productionTip = false

axios.defaults.baseURL = '/api';

// Cookieを有効にする。
axios.defaults.withCredentials = true;

Vue.prototype.$axios = axios;

Vue.use(vuetify)

new Vue({
  router,
  store,
  axios,
  vuetify,
  render: h => h(App)
}).$mount('#app')

説明

  • インストールしたパッケージたちをVueのインスタンスにマウントしてます。

  • axiosのベースとなるURLを指定しています。これでaxiosを利用する際は/apiプレフィックスとして付きます。

  • Vueのprototypeにaxiosを追加することで各componentからthis.$axiosの形でアクセスすることができます。

Storeの実装

続いて、Storeにログイン処理時のstateを登録していきます。 srcディレクトリ直下にstoreディレクトリを作成し、index.jsを以下のように記述します。

import Vue from "vue";
import Vuex from "vuex";
import axios from "axios"

Vue.use(Vuex);

const getAuthState = () => ({
  emailAddress: null
});

export default new Vuex.Store({

state: getAuthState(),

getters: {
  isAuthenticated: state => {
    return state.emailAddress != null;
  },
},
mutations: {
  updateId(state, emailAddress) {
    state.emailAddress = emailAddress;
  },
  resetData(state) {
    state.emailAddress = null;
  },
},
actions: {
  login({ commit }, authData) {
    axios
      .post('/login', {
        emailAddress: authData.emailAddress,
        password: authData.password,
      })
      .then(() => {
        commit('updateId', authData.emailAddress);
      });
  },
  logout({ commit }) {
    axios.post('/logout').then(() => {
      commit('resetData');
    });
  },
},
});

説明

  • state

stateはアプリケーションレベルの状態が全て含まれており、"信頼できる唯一の情報源 (single source of truth)" として機能します。ここでは、getAuthStateという関数に切り出しています。stateの初期値を返却する関数を作ることでstateのリセットをしやすくなるからです。

Vue.jsではオブジェクト内の一部を変更してもリアクティブに伝搬してくれないので、resetDataメソッドでstate全体を変更することで対応しています。 ちなみに、getAuthStateを関数ではなく変数にしてしまうと、コンポーネント側で色々とstateを変えたりしているとgetAuthStateの値も変わってしまいますので注意(参照渡しになるので)。

認証の判定にはemailaddressをユーザーIDとして利用する想定なので、こちらに値がセットされているか否かで判定を行なっていきます。

  • getters

VuexではgettersからStoreのstateにアクセスします。

ここでは、emailaddressがnullでなければtureを返し、nullならfalseを返すメソッドを定義しこの値に応じてcomponentの公開・非公開を制御していきます。

  • mutations

Vuex ではミューテーションをコミットすることでストアの状態を変更できるので、mutationにはstateを更新するメソッドとstateをリセットするメソッドを定義しています。

  • actions

actionはmutationに似た役割を担っていますが、公式には以下の点が異なると記載されています。

アクションは、状態を変更するのではなく、ミューテーションをコミットします。 アクションは任意の非同期処理を含むことができます。

なのでここではaxiosを使用してAPIでから取得したデータをmutationにコミットすることで間接的にstateを書き換えています。

ルーティングの実装

お次は、ルーティングの設定を行なっていきます。

import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
import Login from '../views/Login.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Login',
    component: Login,
    meta: {isPublic: true}
  },
  {
    path: '/home',
    name: 'Home',
    component: Home,
    meta: {isPublic: false}
  },
  {
    path: '/about',
    name: 'About',
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'),
    meta: {isPublic: false}
  }
]

const router = new VueRouter({
  routes,
  mode: 'history',
  base: process.env.BASE_URL,
  scrollBehavior: (to, from, savedPosition) => {
    if (to.hash) return { selector: to.hash }
    if (savedPosition) return savedPosition

    return { x: 0, y: 0 }
  },
})

router.beforeEach((to, from, next) => {
  // 非公開コンポーネントで未ログインの場合ログイン画面にリダイレクト
  if (
    to.matched.some(
      record => (record.meta.isPublic || Store.getters.isAuthenticated)
    )
  ) {
    next();
  } else {
    next({ path: "/login", query: { redirect: to.fullPath } });
  }
});

export default router

説明

  • routes変数にcomponentにアクセスするパスを記載します。
  • metaプロパティにコンポーネントの公開設定を判定するisPublicというプロパティを設定します。
  • router変数にVueRouterインスタンスを格納します。
  • historymodeのバックエンドの実装を前回の記事で行ったので、VueRouterのhistorymodeを有効にします。
  • BASE_URLはvue.config.jsに記載されているpublicpathになります。(ここではfrontend) 環境変数についてはプロジェクトルートに.envファイルなどを作成し、そこにキーバリュー形式で記載することでテスト環境本番環境ごとに変更するなどが可能のようですが今回は作成していません。下記参照。

Modes and Environment Variables | Vue CLI

  • scrollBehaviorはルーティング時に任意の位置にスクロールを行う設定が可能です。

スクロールの振る舞い | Vue Router

  • VueRouterでは遅延ローディングルートを使用することが出来ます。大規模なアプリケーション開発になるとバンドルされるJSファイルが大きくなるため、初期ロード時は全体のJSを読み込み、ルーティング時にはチャンク分割したファイルのみ読み込むことでローディングの遅延に対処する仕組みが備わってるみたいです。

遅延ローディングルート | Vue Router

  • beforeEachメソッドに非公開コンポーネントに未ログイン状態でアクセスした場合にログインフォームにリダイレクトさせるように設定します。

ログイン・ログアウトの実装

次に、 Vuetifyの設定、ログイン画面、ログイン後の遷移画面、ログイン後の遷移画面から遅延ローディングの実証画面を実装します。

srcフォルダ直下にviewsという名称のフォルダを作成し、その直下に各画面を作成していきます。

Login.vue

<template>
    <div>
        <v-main>
            <v-card :tile="$vuetify.breakpoint.sm || $vuetify.breakpoint.xs" elevation="2" class="mx-auto fill-width" flat max-width="540" height="300">
                <div class="pt-6">
                    <v-card-title>ログイン</v-card-title>
                    <div class="forms">
                        <v-text-field v-model="emailAddress" :rules="[emailRules.required, emailRules.regex]" autofocus dense height="48px" outlined placeholder="メールアドレスを入力してください"></v-text-field>
                        <v-text-field v-model="password" :append-icon="passwordShow ? 'mdi-eye' : 'mdi-eye-off'" :rules="[passwordRules.required]" :type="passwordShow ? 'text' : 'password'" dense height="48px" name="input-password" outlined placeholder="パスワードを入力してください" @click:append="passwordShow = !passwordShow"></v-text-field>
                    </div>
                    <v-btn @click="login" color="#7b68ee">ログイン</v-btn>
                </div>
            </v-card>
        </v-main>
    </div>
</template>
  
<script>
export default {
    name: 'Login',
    data() {
        return {
            emailAddress: '',
            password: '',
            emailRules: {
                required: value =>
                    !!value || 'メールアドレスは必須です',
                regex: value =>
                    /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
                        value
                    ) || 'メールアドレスの形式が違います'
            },
            passwordShow: false,
            passwordRules: {
                required: value =>
                    !!value || 'パスワードは必須です',
            }
        };
    },
    methods: {
        login() {
            this.$store.dispatch('login', {
                emailAddress: this.emailAddress,
                password: this.password
            });
        }
    },
    computed: {
        isLogedIn() {
            return this.$store.state.emailAddress
        }
    },
    watch: {
        isLogedIn() {
            this.$router.push('/home').catch(err => console.log(`エラー:${err}`));
        }
    }
};
</script>

<style scoped>
.fill-width {
    top: 50%;
    text-align: center;
}

.error--text {
    color: red !important;
}

.forms {
    width: 80%;
    margin:0 auto;
}
</style>

Home.vue

<template>
    <div>
        <h1>Home画面です</h1>
        <router-link to="/about">about</router-link>
        <p>API取得値:{{ text }}</p>
    </div>
</template>

<script>

export default {
    name: 'Home',
    data(){
        return{
            text: null
        }
    },
    created() {
        this.getApi();
    },
    methods: {
        getApi() {
            const _this = this;
            this.$axios.get('/home')
            .then(response => {
                _this.text = response.data
            })
            .catch(error => {
                _this.text = error
            })
        }
    },

}
</script>

About.vue

<template>
  <div class="about">
    <h1>This is an about page</h1>
    <router-link to="/home">back to Home</router-link>
  </div>
</template>

<script>

export default {
    name: 'About',

    components: {
    },
}
</script>

また、App.vueファイルを以下のように変更します。

<template>
  <v-app>
    <v-main>
      <router-view/>
    </v-main>
  </v-app>
</template>

<script>

export default {
  name: 'App',
};
</script>

router-vueタグの中身がルーティング時に表示されるコンポーネントに置き換えられる形になります。

次に、ログインボタン押下時にバックエンドアプリケーションにPOSTする必要があるため、vue.config.jsにプロキシ設定を追記します。

  devServer: {
    proxy: {
        '^/api': {
            target: 'http://localhost:8080/',
            ws: true,
            changeOrigin: true
        }
    }

ここまでの設定で、

  • ログイン画面→Home画面(Home画面上にバックエンドで作成したAPIの値が取得されていること)→Aboutページへの遷移が実装されていること

  • 未ログイン状態でHome/About画面にアクセスするとログイン画面にリダイレクトされること

が達成されていれば完了です。 f:id:TechHotoke:20211205002237p:plain f:id:TechHotoke:20211205002314p:plain f:id:TechHotoke:20211205002328p:plain

最後にログアウト機能を実装します。 Home.vueにログアウトボタンを実装します。 templateタグ内に下記のように実装し、

 <v-btn @click="logout" color="#7b68ee">ログアウト</v-btn>

scriptタグ内にlogoutメソッドを定義しstoreにdispatchします。

logout() {
            this.$store.dispatch('logout')
        }

storeのlogoutメソッドはstateをnullに変更するメソッドとして定義されています。(store/index.jsのコード参照) そして、stateの値をcomputed(※算術プロパティ)で取得し、watchプロパティでstateの変更を感知したらログインフォームに遷移するように実装します。

※算術プロパティ

あるデータから派生するデータをプロパティとして公開する仕組みで、Vueコンストラクタのオプションオブジェクトの1つです。データそのものに何らかの処理を与えたものをプロパティにしたいときなどに使われます。

computed: {
        isLogout() {
            return this.$store.state.emailAddress
        }
    },
    watch: {
        isLogout() {
            this.$router.push('/');
        }
    }

これでログアウト機能の実装は完了です。


今回の内容は一旦これで終了です。

ここまで読んで頂きありがとうございます。

参考