2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

[Vue3] Vue Router v4で引っかかったところ

Posted at

はじめに

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
package.json
{
  "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して使用可能としています。

src/main.ts
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');
src/plugins/modules.ts
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ファイルに切り出しています。

src/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(インフラストラクチャ層)に依存する部分はなるべく限定したいと考えています。

src/usecase/AuthService.ts
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初期化部分です。

src/infrastructure/firebase/config.ts
// Firebaseプロジェクト側で払い出したものを設定する
export const firebaseConfig = {
  apiKey: '',
  authDomain: '',
  databaseURL: '',
  projectId: '',
  storageBucket: '',
  messagingSenderId: '',
  appId: '',
  measurementId: '',
};
src/infrastructure/firebase/index.ts
import { firebaseConfig } from '../config/firebase';
import { initializeApp } from 'firebase/app';

export const firebaseApp = initializeApp(firebaseConfig);

コンポーネント

コンポーネントの簡易版のコードです。なお、自分以外はサインアップ不可にしてるのでサインアップまわりは無いです。

src/App.vue
<script setup lang="ts">
import Navigation from './components/Navigation.vue';
</script>

<template>
  <Navigation></Navigation>
</template>
src/components/Navigation.vue
<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>
src/views/SignInView.vue
<script setup lang="ts">
import SignInButton from '../components/SignInButton.vue';
</script>

<template>
  <SignInButton></SignInButton>
</template>
src/views/ContentsView.vue
<script setup lang="ts">
import SignOutButton from '../components/SignOutButton.vue';
</script>

<template>
  <p>コンテンツ</p>
  <SignOutButton></SignOutButton>
</template>
src/components/SignInButton.vue
<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>
src/components/SignOutButton.vue
<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)の頃のコードをほぼそのまま流用してみたものがこちらです。

src/router/index.ts
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では、以下の*がそのままでは使用できなくなったようです。

src/router/index.ts抜粋
  {
    path: '*',
    redirect: 'signin',
  },

v4のドキュメントどおり以下のように修正することで、エラー解消しました。

src/router/index.ts抜粋
  {
    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 nextexactly 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

そこで、以下のようにコールバック実行時にすぐオブザーバー解除するようにしたところ、メッセージ出力されなくなりました。

src/router/index.ts抜粋
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)を返すように修正してみました。

src/router/index.ts抜粋
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 or true is returned, the navigation is validated, and the next navigation guard is called.

以下のように、認証状態判定を待つように修正することで、意図通り動作するようになりました。

src/router/index.ts抜粋
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 に直接依存しないようにしたいと思います。

もともとサインイン/アウト処理を実装していたクラスに、認証状態判定のメソッドを追加します。

src/usecase/IAuthClient.ts
export interface IAuthClient {
  signInWithGoogle: () => Promise<void>;
  signOut: () => void;
  isAuthenticated: () => Promise<unknown>; // 追加
}
src/infrastructure/AuthClientOnFirestore.ts
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側は以下のようになります。

src/router/index.ts
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 の使用は避ける
2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?