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

## はじめに

Vue 3 Compositions API と Firebase でちょっとしたアプリを作成しようとした際、Vue Router の部分で Vue 2 の頃のコードの流用では上手くいかなかったところや直したところがあったので、メモしておきます。

- routesの`*`指定が使用できない
- Navigation Guards で `next` 使用時に警告メッセージが出る
- Navigation Guards のコードからFirebaseへの直接の依存を排除する


## サンプルコード概要

### プロジェクトのセットアップ

```bash
$ 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
```

### プロジェクト構成

```sh
.
├── 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
```

```json: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して使用可能としています。

```typescript: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');
```

```typescript: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ファイルに切り出しています。

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

```typescript: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初期化部分です。

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

```typescript:src/infrastructure/firebase/index.ts
import { firebaseConfig } from '../config/firebase';
import { initializeApp } from 'firebase/app';

export const firebaseApp = initializeApp(firebaseConfig);
```


### コンポーネント

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

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

<template>
  <Navigation></Navigation>
</template>
```

```vue: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>
```

```vue:src/views/SignInView.vue
<script setup lang="ts">
import SignInButton from '../components/SignInButton.vue';
</script>

<template>
  <SignInButton></SignInButton>
</template>
```

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

<template>
  <p>コンテンツ</p>
  <SignOutButton></SignOutButton>
</template>
```

```vue: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>
```

```vue: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）の頃のコードをほぼそのまま流用してみたものがこちらです。

```typescript: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 };
```

ただ、これでは以下の２点の問題が発生しました。

- routesの`*`指定が使用できない旨のエラーメッセージ出力
- `next`が複数回呼び出されている旨の警告メッセージ出力

また、Firebaseの`getAuth`に直接依存してしまっている点も解消したいと思います。


### routesの`*`指定が使用できない

問題のコードでは、実行時に以下のエラーメッセージが発生しました。

```
Catch all routes ("*") must now be defined using a param with a custom regexp.
```

Vue Router のv4では、以下の`*`がそのままでは使用できなくなったようです。

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

- v3：[https://v3.router.vuejs.org/ja/guide/essentials/dynamic-matching.html#すべてキャッチするルート-404-not-found-ルート](https://v3.router.vuejs.org/ja/guide/essentials/dynamic-matching.html#%E3%81%99%E3%81%B9%E3%81%A6%E3%82%AD%E3%83%A3%E3%83%83%E3%83%81%E3%81%99%E3%82%8B%E3%83%AB%E3%83%BC%E3%83%88-404-not-found-%E3%83%AB%E3%83%BC%E3%83%88)
- v4：[https://router.vuejs.org/guide/essentials/dynamic-matching.html#catch-all-404-not-found-route](https://router.vuejs.org/guide/essentials/dynamic-matching.html#catch-all-404-not-found-route)

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

```typescript: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 `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:

```javascript
// 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](https://router.vuejs.org/guide/advanced/navigation-guards.html#optional-third-argument-next)


ただ、改めて問題のコードを見ても、ドキュメントの例のようにあからさまに`next`が2回呼ばれるようになっているようには見えません。
しかし、よく考えると、`onAuthStateChanged`メソッド実行ではオブザーバーが設定されているので、`onAuthStateChanged`のコールバックが実行されるのは、必ずしもページ遷移時の１回だけではなく、その後に認証状態の更新契機でも再度実行されている可能性がありそうです。
[https://firebase.google.com/docs/auth/web/manage-users?hl=ja#get_the_currently_signed-in_user](https://firebase.google.com/docs/auth/web/manage-users?hl=ja#get_the_currently_signed-in_user)

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

```typescript: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](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0037-router-return-guards.md#motivation) to remove it. However, it is still supported, meaning you can pass a third argument to any navigation guard.

そこで、ドキュメントに倣い、`next`を使用せずに[RouteLocation](https://router.vuejs.org/api/#routelocationraw)（ここではリダイレクト先パスのstring）を返すように修正してみました。

```typescript: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.

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

```typescript: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 に直接依存しないようにしたいと思います。

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

```typescript:src/usecase/IAuthClient.ts
export interface IAuthClient {
  signInWithGoogle: () => Promise<void>;
  signOut: () => void;
  isAuthenticated: () => Promise<unknown>; // 追加
}
```

```typescript: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側は以下のようになります。

```typescript: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 の使用は避ける