Vue.js with TypeScript で Firebase を使う

弊社福岡オフィスでだいたい月1くらいで「開発合宿」なるものをやってまして、その時の個人のテーマとして「VueでFirebase使う」というのをやったので、そのまとめです。

まあ、実際には、開発合宿（日帰り）の3倍くらい時間使ってやってますけどねｗ

# この記事のゴール

Vue.js with TypeScript で、Firebaseの認証機能を使ってログイン・ログアウトができるようになる。

# 前提

- Vue CLIでプロジェクトを作っている
- Firebaseでもプロジェクト作成済み
- Vue.extend()を使ってます
- Vue.jsもTSも勉強中なので、なにか突っ込みどころがあればぜひ。

# 環境

```
    "firebase": "^7.17.2",
    "vue": "^2.6.11",
    "vue-router": "^3.2.0",
    "vuefire": "^2.2.4",
    "vuex": "^3.4.0"
```

# Vue プロジェクトにFirebaseをインストール

```
# NPM
npm install firebase —save

# Yarn
yarn add firebase
```


# Firebase認証情報

## env ファイル作成

```
touch .env.development.local
```

以下の環境変数を設定。
値はFirebaseのプロジェクトから持ってきて入れます。

```
VUE_APP_FIREBASE_API_KEY=""
VUE_APP_FIREBASE_AUTH_DOMAIN=""
VUE_APP_FIREBASE_DB_URL=""
VUE_APP_FIREBASE_PROJECT_ID=""
VUE_APP_FIREBASE_STORAGE_BUCKET=""
VUE_APP_FIREBASE_MESSAGING_SENDER_ID=""
```

Firebaseの認証情報を持ったファイルを作成。

```
mkdir src/firebase
mkdir src/firebase/types
touch src/firebase/types/credentials.ts 
touch src/firebase/credentials.ts
```

中身は以下

```
// src/firebase/types/credentials.ts
interface Config {
  apiKey: string;
  authDomain: string;
  storageBucket: string;
  databaseURL: string;
  projectId: string;
  messagingSenderId: string;
}

export interface Credentials {
  config: Config;
}

// src/firebase/credentials.ts

import { Credentials } from './types/credentials'

export const credentials: Credentials = {
  config: {
    apiKey: process.env.VUE_APP_FIREBASE_API_KEY,
    authDomain: process.env.VUE_APP_FIREBASE_AUTH_DOMAIN,
    storageBucket: process.env.VUE_APP_FIREBASE_STORAGE_BUCKET,
    databaseURL: process.env.VUE_APP_FIREBASE_DB_URL,
    projectId: process.env.VUE_APP_FIREBASE_PROJECT_ID,
    messagingSenderId: process.env.VUE_APP_FIREBASE_MESSAGING_SENDER_ID
  }
}
```


# Vuefire のインストール

VueとFirebaseのやりとりを簡潔化してくれる `vuefire` というパッケージをインストール。

```
npm i vuefire
```

`main.ts` でvuefireを読み込む。

```
import Vue from 'vue'
import App from './App.vue'
import './registerServiceWorker'
import router from './router'
import store from './store'
import { firestorePlugin } from 'vuefire'

Vue.config.productionTip = false
Vue.use(firestorePlugin)

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

# 各種設定

## firebaseアプリの初期化

```
touch src/firebase/app.ts
```

中身

```
import Firebase from 'firebase/app'
import { credentials } from './credentials'

export const App: Firebase.app.App = Firebase.initializeApp(credentials.config)
```

## 認証

```
touch src/firebase/auth.ts
```

中身

```
import { App } from './app'
import firebase from 'firebase'
import 'firebase/auth'

export const Auth: firebase.auth.Auth = App.auth()
```

## DB (Firestore)

```
touch src/firebase/db.ts
```

中身

```
import { App } from './app'
import firebase from 'firebase'
import 'firebase/firestore'

export const DB: firebase.firestore.Firestore = App.firestore()
```

# Vuexの設定

今回は「ログインしているかどうか」の状態をVuexで管理することにします。
また、「ヘッダーを表示するかどうか」の状態もVuexで管理することにします。（ヘッダーに関しては後述）

まずは `RootState` を定義した `types` を作ります。

```
mkdir src/store/types
touch src/store/types/index.ts
```

`store/types/index.ts` の中身

```
export interface RootState {
  loggedIn: boolean;
  showHeader: boolean;
}
```

`store/index.ts` を書き換えます。

```
import Vue from 'vue'
import Vuex, { Commit, StoreOptions } from 'vuex'
import { RootState } from '@/store/types'
import { User } from 'firebase'

Vue.use(Vuex)

const store: StoreOptions<RootState> = {
  state: {
    loggedIn: false,
    showHeader: true
  },
  getters: {
    loggedIn (state: RootState): boolean {
      return state.loggedIn
    },
    showHeader (state: RootState): boolean {
      return state.showHeader
    }
  },
  mutations: {
    updateLogInState (state: RootState, loggedIn: boolean): void {
      state.loggedIn = loggedIn
    },
    updateShowHeaderState (state: RootState, showHeader: boolean): void {
      state.showHeader = showHeader
    }
  },
  actions: {
    updateLogInState ({ commit }: { commit: Commit }, user: User): void {
      commit('updateLogInState', user !== null)
    },
    updateShowHeaderState ({ commit }: { commit: Commit}, showHeader: boolean): void {
      commit('updateShowHeaderState', showHeader)
    }
  }
}

export default new Vuex.Store<RootState>(store)
```

これで設定は以上！

# Firebase を使った認証

予めFirebase側で認証情報を登録しておきます。
今回はメールアドレスです。

## App.vue の書き換え

まずは `main.ts` にて、ログイン状態を調べてVuexの状態ををアップデートしてから、Vueをインスタンス化するように書き換えます。
`App.vue` 内で `beforeCreate` してもいいけど、それだと非同期処理のため、ラグが発生してしまうので、このようにしました。

```
import Vue from 'vue'
import App from './App.vue'
import './registerServiceWorker'
import router from './router'
import store from './store'
import { firestorePlugin } from 'vuefire'
import { Auth } from '@/firebase/auth'

Vue.config.productionTip = false
Vue.use(firestorePlugin)

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

Auth.onAuthStateChanged(user => {
  store.dispatch('updateLogInState', user)
    .then(initVue)
})
```

## ログイン画面作成

次は、ログイン画面を作ります。
一旦CSSを放棄して作ってますので、お好みでスタイルを入れてください。

```
touch src/views/Login.vue
```

中身

```
<template>
  <div id="login">
    <form novalidate @submit.prevent="login">
      <p class="">Sign in</p>
      <ul v-if="errors.length" class="errors">
        <li v-for="error in errors" :key="error" class="error">{{error}}</li>
      </ul>

      <div>
        <input v-model="email" type="email" required />
        <input v-model="password" type="password" required />
      </div>

      <div>
        <button type="submit">Login</button>
      </div>
    </form>
  </div>
</template>

<script lang="ts">
import Vue from 'vue'
import { Auth } from '@/firebase/auth'

interface UserLoginInfo {
  email: string;
  password: string;
  errors: object[];
}

export default Vue.extend({
  name: 'login',
  components: {
  },
  data (): UserLoginInfo {
    return {
      email: '',
      password: '',
      errors: []
    }
  },
  methods: {
    login (event: any) {
      this.errors = []

      if (!this.errors.length) {
        Auth.signInWithEmailAndPassword(this.email, this.password)
          .then(() => this.$router.push('/'))
          .catch(err => this.errors.push(err.message))
      }
    }
  }
})
</script>
```

## ルーティング設定

続いて、 `router` の設定です。
今回は、最初から設定されている `About` ページにアクセスするには認証が必要になるようガードしました。
`About` の設定に `meta: {requiresAuth: true}` を設定し、 `router.beforeEach` で前述の `meta` が設定されているかどうかを確認しています。

また、同様に、 `noHeader: true` という meta を見て、ヘッダーを表示するページなのかどうかを確認し、Vuexの状態を更新しています。

```
import Vue from 'vue'
import VueRouter, { Route, RouteConfig } from 'vue-router'
import store from '@/store'
import Home from '../views/Home.vue'
import About from '@/views/About.vue'
import Login from '@/views/Login.vue'
import { Auth } from '@/firebase/auth'

Vue.use(VueRouter)

const routes: Array<RouteConfig> = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    component: About,
    meta: {
      requiresAuth: true
    }
  },
  {
    path: '/login',
    name: 'Login',
    component: Login,
    meta: {
      noHeader: true
    }
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

function requiresAuth (to: Route): boolean {
  return to.matched.some(route => route.meta.requiresAuth) && !Auth.currentUser
}

function hasNoHeader (to: Route): boolean {
  return !to.matched.some(route => route.meta.noHeader)
}

router.beforeEach((to, from, next) => {
  if (requiresAuth(to)) {
    next('/login')
    return
  }

  store.dispatch('updateShowHeaderState', hasNoHeader(to))
    .then(() => { next() })
})

export default router
```

## ヘッダーを切り出し、ログアウト機能をつける

デフォルトでは `App.vue` で持っているナビゲーション部分をヘッダーとして切り出し、ログアウト機能を加えました。
~~あと、ログイン画面ではヘッダーを表示しないようにしました。~~
~~ただ、この機能は router と Vuex で持つのが正しい気がしてきた。。。~~
やっぱり気になったので、 router で管理するようにしました。

```
touch src/components/Header.vue
```

中身

```
<template>
  <div id="nav" v-show="showHeader">
    <router-link to="/">Home</router-link> |
    <router-link to="/about">About</router-link>
    <span v-if="loggedIn"> | <a href="" @click.prevent="logout">Logout</a></span>
  </div>
</template>

<style lang="scss">
  #nav {
    padding: 30px;

    a {
      font-weight: bold;
      color: #2c3e50;

      &.router-link-exact-active {
        color: #42b983;
      }
    }
  }
</style>

<script lang="ts">
import Vue from 'vue'
import { mapGetters } from 'vuex'
import { Auth } from '@/firebase/auth'

export default Vue.extend({
  name: 'Header',
  computed: {
    ...mapGetters([
      'loggedIn',
      'showHeader'
    ])
  },
  methods: {
    logout (): void {
      Auth.signOut().then(() => {
        Auth.onAuthStateChanged(() => {
          if (this.$router.currentRoute.path !== '/') this.$router.push('/')
        })
      })
    }
  }
})
</script>
```

## App.vueを修正して完成

最後に、HeaderコンポーネントをApp.vueに追加する形に修正して終了。

```
<template>
  <div id="app">
    <Header />
    <router-view/>
  </div>
</template>

<style lang="scss">
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

#nav {
  padding: 30px;

  a {
    font-weight: bold;
    color: #2c3e50;

    &.router-link-exact-active {
      color: #42b983;
    }
  }
}
</style>

<script lang="ts">
import Vue from 'vue'
import Header from '@/components/Header.vue'

export default Vue.extend({
  name: 'App',
  components: {
    Header
  }})
</script>
```

これで、

- ログインしていない状態でトップにアクセスすると `Home` と`About` しかない
- `About` にアクセスしようとするとヘッダーのないログインページにリダイレクト
- ログインに成功すると` Logout` が表示されているトップに戻される
 
という機能を実装できました。

# 課題

兎にも角にも **TypeScriptを使い切れていない** 感がすごいです。
特にVuexでTypeScriptを使い切れなかったというか、ちょっと力尽きた感あります。

まあ、取り扱うデータが増えたら、考えようと思います。

# 蛇足

最初はVueのClass APIを使ってやろうと思っていたんだけれど、やってくうちに「え、なにこれ、どうしたらいいか全然わからん」ということが増えました。特にVuex周り。

で、調べていくうちに [デコレータ使わない Vue.js + TypeScript で進んだ「LINEのお年玉」キャンペーン](https://engineering.linecorp.com/ja/blog/vue-js-typescript-otoshidama/) を見つけて、「なるほどなー、時代はClass APIじゃないのか」となって、ある程度作ったところで方向転換したりしました。

# 参考
順不同

- [How to Install Firebase with Vue.JS](https://garywoodfine.com/how-to-install-firebase-with-vue-js/)
- [How to use Firebase Authentication with Vue](https://garywoodfine.com/vue-app-with-firebase-authentication/)
- [How to implement Navigation Guards Vue](https://garywoodfine.com/how-to-implement-navigation-guards-vue/)
- [\[Vue+TypeScript\] Vue.extend で Vue らしさを保ちつつ TypeScript で書くときの型宣言についてまとめた](https://qiita.com/is_ryo/items/6fc799ba4214db61d8ab)
- [Vue + Firebase authentication](https://blog.logrocket.com/vue-firebase-authentication/)
- [Vuex and Typescript](https://codeburst.io/vuex-and-typescript-3427ba78cfa8)
- [デコレータ使わない Vue.js + TypeScript で進んだ「LINEのお年玉」キャンペーン](https://engineering.linecorp.com/ja/blog/vue-js-typescript-otoshidama/)