はじめに
Vue 3 Compositions API と Firebase でちょっとしたアプリを作成しようとした際、Vue Router の部分で Vue 2 の頃のコードの流用では上手くいかなかったところや直したところがあったので、メモしておきます。
- routesの
*
指定が使用できない - Navigation Guards で
next
使用時に警告メッセージが出る - Navigation Guards のコードからFirebaseへの直接の依存を排除する
サンプルコード概要
プロジェクトのセットアップ
$ node -v
v16.16.0
$ npm -v
8.11.0
$ npm create vite@latest
Need to install the following packages:
create-vite@latest
Ok to proceed? (y) y
✔ Project name: … firebase-auth-test
✔ Select a framework: › vue
✔ Select a variant: › vue-ts
...
$ cd firebase-auth-test/
$ npm install
$ npm i -S vue-router firebase
プロジェクト構成
.
├── README.md
├── index.html
├── node_modules
├── package-lock.json
├── package.json
├── public
├── src
│ ├── App.vue
│ ├── assets
│ ├── components
│ │ ├── Navigation.vue
│ │ ├── SignInButton.vue
│ │ └── SignOutButton.vue
│ ├── infrastructure
│ │ ├── AuthClientDummy.ts
│ │ ├── AuthClientOnFirebase.ts
│ │ └── firebase
│ │ ├── config.ts
│ │ └── index.ts
│ ├── main.ts
│ ├── modules.ts
│ ├── plugins
│ │ ├── modules.ts
│ ├── router
│ │ └── index.ts
│ ├── style.css
│ ├── usecase
│ │ ├── AuthService.ts
│ │ ├── IAuthClient.ts
│ │ └── IAuthService.ts
│ ├── views
│ │ ├── ContentsView.vue
│ │ └── SignInView.vue
│ └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
{
"name": "firebase-auth-test",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"firebase": "^9.9.3",
"vue": "^3.2.37",
"vue-router": "^4.1.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^3.0.3",
"typescript": "^4.6.4",
"vite": "^3.0.7",
"vue-tsc": "^0.39.5"
}
}
main.tsでは Vue Router を追加しています。ルーティング設定の詳細については後述します。
また、modulesプラグインで、AuthServiceをグローバルにProvideし、各コンポーネント内でInjectして使用可能としています。
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { router } from './router';
import { modules } from './plugins/modules';
const app = createApp(App);
app.use(router);
app.use(modules);
app.mount('#app');
import { App, InjectionKey, Plugin } from 'vue'
import { IAuthService } from '../usecase/IAuthService';
import { authService } from '../modules';
export const authServiceKey: InjectionKey<IAuthService> = Symbol('authService');
export const modules: Plugin = {
install(app: App) {
app.provide(authServiceKey, authService);
}
}
DIするAuthServiceのインスタンス生成をmodules.tsファイルに切り出しています。
import { firebaseApp } from './infrastructure/firebase';
import { AuthClientOnFirebase } from './infrastructure/AuthClientOnFirebase';
// import { AuthClientDummy } from './infrastructure/AuthClientDummy';
import { IAuthService } from './usecase/IAuthService';
import { AuthService } from './usecase/AuthService';
const authClient = new AuthClientOnFirebase(firebaseApp);
// const authClient = new AuthClientDummy();
const authService: IAuthService = new AuthService(authClient);
export {
authService,
}
通常はFirebase Authenticationを使用して実装したAuthClientOnFirebaseを使用していますが、テスト時はダミー実装のAuthClientDummyに差し替えたりできるようにしています。
このあたりはDDDで言うレイヤ化アーキテクチャを意識してるものの、単純なCRUDアプリでアプリケーション層やドメイン層になるロジックがほぼ無いので過剰な感じは否めないですが、少なくともVue(UI層)やFirebase(インフラストラクチャ層)に依存する部分はなるべく限定したいと考えています。
import { IAuthService } from './IAuthService';
import { IAuthClient } from './IAuthClient';
export class AuthService implements IAuthService {
private authClient;
constructor(authClient: IAuthClient) {
this.authClient = authClient;
}
public signInWithGoogle() {
return this.authClient.signInWithGoogle();
}
public signOut() {
this.authClient.signOut();
}
}
以下はFirebase初期化部分です。
// Firebaseプロジェクト側で払い出したものを設定する
export const firebaseConfig = {
apiKey: '',
authDomain: '',
databaseURL: '',
projectId: '',
storageBucket: '',
messagingSenderId: '',
appId: '',
measurementId: '',
};
import { firebaseConfig } from '../config/firebase';
import { initializeApp } from 'firebase/app';
export const firebaseApp = initializeApp(firebaseConfig);
コンポーネント
コンポーネントの簡易版のコードです。なお、自分以外はサインアップ不可にしてるのでサインアップまわりは無いです。
<script setup lang="ts">
import Navigation from './components/Navigation.vue';
</script>
<template>
<Navigation></Navigation>
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router';
</script>
<template>
<router-link to="/">home</router-link> |
<router-link to="/signin">signin</router-link>
<RouterView></RouterView>
</template>
<script setup lang="ts">
import SignInButton from '../components/SignInButton.vue';
</script>
<template>
<SignInButton></SignInButton>
</template>
<script setup lang="ts">
import SignOutButton from '../components/SignOutButton.vue';
</script>
<template>
<p>コンテンツ</p>
<SignOutButton></SignOutButton>
</template>
<script setup lang="ts">
import { inject } from 'vue';
import { router } from '../router';
import { authServiceKey } from '../plugins/modules';
const authService = inject(authServiceKey);
if(!authService) throw new Error('provide missing: authService');
const signInWithGoogle = () => {
authService.signInWithGoogle().then(() => {
console.log('Sign In Success!');
router.push('/');
}).catch((err) => {
console.log(err.message);
});
}
</script>
<template>
<button @click="signInWithGoogle">Google ログイン</button>
</template>
<script setup lang="ts">
import { inject } from 'vue';
import { router } from '../router';
import { authServiceKey } from '../plugins/modules';
const authService = inject(authServiceKey);
if(!authService) throw new Error('provide missing: authService');
const signOut = () => {
authService.signOut();
router.push('/signin');
}
</script>
<template>
<button @click="signOut">ログアウト</button>
</template>
Vue Routerの設定
期待動作
以下の動作となるように設定したいと思います。
-
/
にアクセスした際- 認証済の場合:コンテンツ画面(ContentsView)を表示
- 未認証の場合:
/signin
にリダイレクト
-
/signin
にアクセスした際- サインイン画面(SignInView)を表示
- その他の未定義のパスにアクセスした際
-
/signin
にリダイレクト
-
問題のコード
とりあえず Vue 2(vue-routerは3.1.5)の頃のコードをほぼそのまま流用してみたものがこちらです。
import { createRouter, createWebHistory } from 'vue-router';
import ContentsView from '../views/ContentsView.vue';
import SignInView from '../views/SignInView.vue';
import { getAuth } from "firebase/auth";
const routes = [
{
path: '/',
component: ContentsView,
meta: { requiresAuth: true },
},
{
path: '/signin',
component: SignInView,
},
{
path: '*',
redirect: 'signin',
},
];
const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach((to, from, next) => {
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth);
if (requiresAuth) {
getAuth().onAuthStateChanged((user) => {
if (user) {
next();
} else {
next({
path: '/signin',
query: { redirect: to.fullPath },
});
}
});
} else {
next();
}
});
export { router };
ただ、これでは以下の2点の問題が発生しました。
- routesの
*
指定が使用できない旨のエラーメッセージ出力 -
next
が複数回呼び出されている旨の警告メッセージ出力
また、FirebaseのgetAuth
に直接依存してしまっている点も解消したいと思います。
routesの*
指定が使用できない
問題のコードでは、実行時に以下のエラーメッセージが発生しました。
Catch all routes ("*") must now be defined using a param with a custom regexp.
Vue Router のv4では、以下の*
がそのままでは使用できなくなったようです。
{
path: '*',
redirect: 'signin',
},
- v3:https://v3.router.vuejs.org/ja/guide/essentials/dynamic-matching.html#すべてキャッチするルート-404-not-found-ルート
- v4:https://router.vuejs.org/guide/essentials/dynamic-matching.html#catch-all-404-not-found-route
v4のドキュメントどおり以下のように修正することで、エラー解消しました。
{
path: '/:pathMatch(.*)*',
redirect: 'signin',
},
Navigation Guards でnext
使用時に警告メッセージが出る
問題のコードでは、サインイン/アウト時のページ遷移のタイミングで、以下の警告メッセージが出力されました。
Vue Router warn]: The "next" callback was called more than once in one navigation guard when going from "/signin" to "/". It should be called exactly one time in each navigation guard. This will fail in production.
next
の複数回呼び出しについては、ドキュメントにも悪い例として以下が記載されています。
you must call
next
exactly once in any given pass through a navigation guard. It can appear more than once, but only if the logical paths have no overlap, otherwise the hook will never be resolved or produce errors. Here is a bad example of redirecting to user to/login
if they are not authenticated:
// BAD
router.beforeEach((to, from, next) => {
if (to.name !== 'Login' && !isAuthenticated) next({ name: 'Login' })
// if the user is not authenticated, `next` is called twice
next()
})
https://router.vuejs.org/guide/advanced/navigation-guards.html#optional-third-argument-next
ただ、改めて問題のコードを見ても、ドキュメントの例のようにあからさまにnext
が2回呼ばれるようになっているようには見えません。
しかし、よく考えると、onAuthStateChanged
メソッド実行ではオブザーバーが設定されているので、onAuthStateChanged
のコールバックが実行されるのは、必ずしもページ遷移時の1回だけではなく、その後に認証状態の更新契機でも再度実行されている可能性がありそうです。
https://firebase.google.com/docs/auth/web/manage-users?hl=ja#get_the_currently_signed-in_user
そこで、以下のようにコールバック実行時にすぐオブザーバー解除するようにしたところ、メッセージ出力されなくなりました。
router.beforeEach((to, from, next) => {
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth);
if (requiresAuth) {
const unsubscribe = getAuth().onAuthStateChanged((user) => {
if (user) {
unsubscribe();
next();
} else {
unsubscribe();
next({
path: '/signin',
query: { redirect: to.fullPath },
});
}
});
} else {
next();
}
});
ただ、ドキュメントの以下の記載を見ると、そもそもnextの使用自体が推奨されていないようです。
In previous versions of Vue Router, it was also possible to use a third argument
next
, this was a common source of mistakes and went through an RFC to remove it. However, it is still supported, meaning you can pass a third argument to any navigation guard.
そこで、ドキュメントに倣い、next
を使用せずにRouteLocation(ここではリダイレクト先パスのstring)を返すように修正してみました。
router.beforeEach((to, from) => {
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth);
if (requiresAuth) {
const unsubscribe = getAuth().onAuthStateChanged((user) => {
if (!user) {
unsubscribe();
return '/signin';
}
});
}
});
しかし、これだとうまく行かず、未認証でもコンテンツ画面が表示されてしまいました。
どうもonAuthStateChanged
のコールバック内でreturn '/signin';
されるより前に、onAuthStateChanged
メソッド実行時点で戻り値なしとみなされ、遷移可と判断されてしまったようです。
If nothing,
undefined
ortrue
is returned, the navigation is validated, and the next navigation guard is called.
以下のように、認証状態判定を待つように修正することで、意図通り動作するようになりました。
router.beforeEach(async (to, from) => {
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth);
const isAuthenticated = new Promise((resolve) => {
const unsubscribe = getAuth().onAuthStateChanged((user) => {
unsubscribe();
resolve(user ? true : false);
});
});
if (requiresAuth && !(await isAuthenticated)) {
return '/signin';
}
});
Navigation Guards のコードから Firebase への直接の依存を排除する
Firebase のgetAuth
への依存部分をインフラストラクチャ層に締め出してアプリケーションサービス経由で使用することにより、UI層と考えられる Vue Router 設定部分が Firebase に直接依存しないようにしたいと思います。
もともとサインイン/アウト処理を実装していたクラスに、認証状態判定のメソッドを追加します。
export interface IAuthClient {
signInWithGoogle: () => Promise<void>;
signOut: () => void;
isAuthenticated: () => Promise<unknown>; // 追加
}
import { FirebaseApp } from 'firebase/app';
import { getAuth, signInWithPopup, GoogleAuthProvider } from "firebase/auth";
import { IAuthClient } from '../usecase/IAuthClient';
export class AuthClientOnFirebase implements IAuthClient {
private auth;
constructor(firebaseApp: FirebaseApp) {
this.auth = getAuth();
}
public signInWithGoogle() {
const provider = new GoogleAuthProvider();
return signInWithPopup(this.auth, provider);
}
public signOut() {
this.auth.signOut();
}
// 追加
public isAuthenticated() {
return new Promise((resolve) => {
const unsubscribe = this.auth.onAuthStateChanged(user => {
unsubscribe();
resolve(user ? true : false);
});
});
}
}
ちょっと冗長なので割愛しますが、アプリケーション層のAuthService(interfaceのIAuthServiceも含む)にも、上記のisAuthenticated
メソッドへの中継メソッドを追加しておきます。
router側は以下のようになります。
import { createRouter, createWebHistory } from 'vue-router';
import ContentsView from '../views/ContentsView.vue';
import SignInView from '../views/SignInView.vue';
import { authService } from '../modules';
const routes = [
{
path: '/',
component: ContentsView,
meta: { requiresAuth: true },
},
{
path: '/signin',
component: SignInView,
},
{
path: '/:pathMatch(.*)*',
redirect: 'signin',
},
];
const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach(async (to, from) => {
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth);
if (requiresAuth && !(await authService.isAuthenticated())) {
return '/signin';
}
});
export { router };
Vue 2 の頃のコードでは、問題のコードのようにgetAuth().onAuthStateChanged
のコールバック内でnext
を呼んでいた都合で切り出しづらかったのですが、今回next
を使わないよう修正した結果、シンプルに切り出しできるようにもなりました。
これで Firebase への依存がインフラストラクチャ層内に限定できました。
(厳密にはsrc/modules.tsには残ってますが、Firebase初期化部分をFirestoreなどインフラストラクチャ層の別リポジトリと共有したい都合で残してます。)
まとめ
- Vue Router v4 の routes で Catch all を指定する場合は custom regexp を使用する
- Vue Router v4 の Navigation Guards では next の使用は避ける