Vue3でFirebaseログイン

2021/01/06に公開

はじめに

この記事は2年前に書いたVue vuexでfirebaseのログイン保持を、Vue3+Composition APIで書き直したものです。アーキテクチャも変更しています。

チュートリアル

デモはこちら https://vue-firebase-demo-ebdef.web.app/
ソースコードはGitHubへ載せています ErgoFriend/vue-firebase-auth

Image from Gyazo

以下の要素を組み合わせて、複数コンポーネントで状態を共有するVueアプリをつくっていきます。

  • TypeScript
  • SPA
  • Vue3
  • Composition API
  • ストアパターン
  • vue-routerによる遷移
  • Firebase Authentication

流れは以下の通りです。

  1. 環境構築
  2. 見た目(UI)をつくる
  3. 状態管理(store)を追加
  4. firebaseの導入(ログイン・ログアウト)

各ファイルの最終コードブロックをコピペすると動きます。

開発環境

アプリの雛形を生成するためにvue/cliをインストールします。

node v15.5.0
npm 7.3.0

npm install -g @vue/cli @vue/cli-service-global
# or
yarn global add @vue/cli @vue/cli-service-global

次にアプリの雛形を作っていきます。

vue create vue3-firebase-demo

Vue3とTypeScriptを使うのでマニュアル選択を選びます。追加でTypeScript・Routerを選択して、決定してください。次にVue2か3のどちらを使用するか尋ねられるので、3を選択します。後はすべてエンターを押すだけです。
最終的にこのようになると思います。そうしたらエンターで雛形を生成します。

? Please pick a preset: Manually select features
? Check the features needed for your project: Choose Vue version, Babel, TS, Router, Linter
? Choose a version of Vue.js that you want to start the project with 3.x (Preview)
? Use class-style component syntax? No
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? Yes
? Use history mode for router? (Requires proper server setup for index fallback in production) Yes
? Pick a linter / formatter config: Basic
? Pick additional lint features: Lint on save
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? No

npm ERR! code ERR_SOCKET_TIMEOUT

筆者の環境(win10+wsl2+ubuntu20)では上記のエラーが発生したので、sudo vue create vue3-firebase-demoを実行しました。

UIの作成

UIを構成するコンポーネントをつくっていきます。

1. ヘッダー


ヘッダーはログイン状態へアクセスするコンポーネントの1つです。

componentsへファイルを追加

src/components/LoginButton.vue
LoginButton.vue
<template>
  <button type="button" class="google-button">
    <span class="google-button__icon">
      <svg viewBox="0 0 366 372" xmlns="http://www.w3.org/2000/svg">
        <path
          d="M125.9 10.2c40.2-13.9 85.3-13.6 125.3 1.1 22.2 8.2 42.5 21 59.9 37.1-5.8 6.3-12.1 12.2-18.1 18.3l-34.2 34.2c-11.3-10.8-25.1-19-40.1-23.6-17.6-5.3-36.6-6.1-54.6-2.2-21 4.5-40.5 15.5-55.6 30.9-12.2 12.3-21.4 27.5-27 43.9-20.3-15.8-40.6-31.5-61-47.3 21.5-43 60.1-76.9 105.4-92.4z"
          id="Shape"
          fill="#EA4335"
        />
        <path
          d="M20.6 102.4c20.3 15.8 40.6 31.5 61 47.3-8 23.3-8 49.2 0 72.4-20.3 15.8-40.6 31.6-60.9 47.3C1.9 232.7-3.8 189.6 4.4 149.2c3.3-16.2 8.7-32 16.2-46.8z"
          id="Shape"
          fill="#FBBC05"
        />
        <path
          d="M361.7 151.1c5.8 32.7 4.5 66.8-4.7 98.8-8.5 29.3-24.6 56.5-47.1 77.2l-59.1-45.9c19.5-13.1 33.3-34.3 37.2-57.5H186.6c.1-24.2.1-48.4.1-72.6h175z"
          id="Shape"
          fill="#4285F4"
        />
        <path
          d="M81.4 222.2c7.8 22.9 22.8 43.2 42.6 57.1 12.4 8.7 26.6 14.9 41.4 17.9 14.6 3 29.7 2.6 44.4.1 14.6-2.6 28.7-7.9 41-16.2l59.1 45.9c-21.3 19.7-48 33.1-76.2 39.6-31.2 7.1-64.2 7.3-95.2-1-24.6-6.5-47.7-18.2-67.6-34.1-20.9-16.6-38.3-38-50.4-62 20.3-15.7 40.6-31.5 60.9-47.3z"
          fill="#34A853"
        />
      </svg>
    </span>
    <span class="google-button__text">Sign in with Google</span>
  </button>
</template>

<script lang='ts'>
import { defineComponent } from "vue";

export default defineComponent({
  name: "LoginButton",
});
</script>

<style scoped>
.google-button {
  height: 40px;
  border-width: 0;
  border-radius: 5px;
  white-space: nowrap;
  box-shadow: 1px 1px 0px 1px rgba(0, 0, 0, 0.05);
  transition-property: background-color, box-shadow;
  transition-duration: 150ms;
  transition-timing-function: ease-in-out;
  padding: 0;
}
.google-button__icon {
  display: inline-block;
  vertical-align: middle;
  margin: 8px 0 8px 8px;
  width: 18px;
  height: 18px;
  box-sizing: border-box;
}
.google-button__icon--plus {
  width: 27px;
}
.google-button__text {
  display: inline-block;
  vertical-align: middle;
  padding: 0 24px;
  font-size: 14px;
  font-weight: bold;
  font-family: "Roboto", arial, sans-serif;
}
</style>
src/components/User.vue
User.vue
<template>
  <div class="user" v-if="true">
    <img class="photoURL" src="" alt="" />
    <h3 class="displayName">Guest</h3>
    <button
      type="button"
      class="button is-small is-info is-outlined"
    >
      Sign out
    </button>
  </div>
  <div class="user" v-else>
    <LoginButton />
  </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import LoginButton from "@/components/LoginButton.vue";

export default defineComponent({
  name: "User",
  components: {
    LoginButton,
  },
});
</script>

<style scoped>
.user {
  margin-left: auto;
  height: 50px;
  display: inline-flex;
  align-items: center;
  flex-wrap: wrap;
}
.displayName,
button {
  white-space: nowrap;
}
.displayName {
  margin: 0 20px 0 10px;
}
button {
  font-weight: 600;
}
.photoURL {
  border-radius: 50%;
  object-fit: cover;
  width: 50px;
  height: 50px;
}
</style>
src/components/Header.vue
Header.vue
<template>
  <div class="header">
    <router-link to="/">Home</router-link>
    <p>/</p>
    <router-link to="/setting">Setting</router-link>
    <User />
  </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import User from "@/components/User.vue";

export default defineComponent({
  name: "Header",
  components: {
    User,
  },
});
</script>

<style scoped>
.header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  padding: 10px 0;
}
p {
  margin: 0 5px;
}
</style>

アプリにヘッダーを追加

本アプリではWater.cssを利用するため、App.vueで読み込みます。

src/App.vue
App.vue
<template>
  <Header />
  <router-view />
</template>

<script lang="ts">
import { defineComponent } from "vue";
import Header from "@/components/Header.vue";

export default defineComponent({
  name: "App",
  components: {
    Header,
  },
});
</script>

<style>
@import "https://cdn.jsdelivr.net/npm/water.css@2/out/dark.min.css";
</style>

2. ホーム画面

アプリで最初に表示される画面へログインしている人の名前を表示するUIを用意します。

src/views/Home.vue
Home.vue
<template>
  <div class="hello">
    <h1>Hello, {{ guest }}</h1>
    <p>
      This is a example of an article.
    </p>
    <p>
      Vue3 + Composition API + store pattern + Firebase Auth
    </p>
    <h3>Related links</h3>
    <ul>
      <li><a href="https://github.com/ErgoFriend/vue-firebase-authl" target="_blank" rel="noopener">GitHub Repo</a></li>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-typescript" target="_blank" rel="noopener">Zenn Article</a></li>
    </ul>
    <br>
    <p>Copyright © 2021 <a href="https://kasu.dev">kasu.dev</a> All Rights Reserved.</p>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';

export default defineComponent({
  name: 'Home',
  setup(){
    const guest = '{{ Guest }}'
    return { guest }
  }
});
</script>

<style scoped>
h3 {
  margin: 40px 0 0;
}
ul {
  list-style-type: none;
  padding: 0;
}
li {
  display: inline-block;
  margin: 0 10px;
}
a {
  color: #42b983;
}
</style>

3. 設定画面

ログインしている人のユーザー名とプロフィール画像を変更するページを用意します。
本アプリでログイン状態へアクセスするコンポーネントはヘッダー・ホーム画面・設定画面の3つです。

src/views/Setting.vue
Setting.vue
<template>
  <div class="view">
    <h1>Setting</h1>
    <div class="updater">
      <label>displayName</label>
      <input type="text" v-model="displayName" />
    </div>
    <div class="updater">
      <label>photoURL</label>
      <input type="text" v-model="photoURL" />
    </div>
    <button>Update</button>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref, watchEffect } from "vue";

export default defineComponent({
  name: "Setting",
  setup() {
    const displayName = ref("");
    const photoURL = ref("");
    return { displayName, photoURL };
  },
});
</script>

<style scoped>
.updater {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
}
label {
  flex: 1;
}
input {
  flex: 3;
}
button {
  float: right;
}
</style>

4. ルーティング

ホーム画面・設定画面を遷移できるように、vue-routerの設定を行います。

src/router/index.ts
index.ts
import { createWebHistory, createRouter } from "vue-router";
import Home from "@/views/Home.vue";
import Setting from "@/views/Setting.vue";

const routes = [
  {
    path: "/",
    name: "Home",
    component: Home,
  },
  {
    path: "/setting",
    name: "Setting",
    component: Setting,
  }
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

export default router;

ストアの作成

ログイン状態を管理するストア

ログイン状態を管理するauthStoreを作成します。

ログイン状態を保持するコンポーネントへauthStoreを登録し、ログイン状態を利用するコンポーネント内でuseAuthStore()を呼ぶことで登録されたauthStoreからデータを読み取り、メソッドの呼び出しを行います。

src/stores/auth.ts
auth.ts
import { inject, InjectionKey, reactive } from "vue";

type DummyUser = {
  displayName: string;
  photoURL: string;
}

const dummyUser: DummyUser = {
  displayName: 'test',
  photoURL: 'https://pbs.twimg.com/profile_images/1308010958862905345/-SGZioPb_400x400.jpg'
}

const authStore = () => {
  console.log('init authStore')
  const state = reactive({ isLoggedin: false, displayName: '', photoURL: ''})
  const setUser = (user: DummyUser | null) => {
    state.isLoggedin = !!user
    if (user) {
      state.displayName = user.displayName ?? ''
      state.photoURL = user.photoURL ?? ''
    }
  }
  const signin = () => {
    setUser(dummyUser)
  }
  const signout = () => setUser(null)
  const updateUser = (input: DummyUser) => {
    setUser(input)
  }

  return {
    state,
    setUser,
    signin,
    signout,
    updateUser,
  };
}

export default authStore

export type AuthStore = ReturnType<typeof authStore>;

export const authStoreKey: InjectionKey<AuthStore> = Symbol('authStore');

export const useAuthStore = () => {
  const store = inject(authStoreKey);
  if (!store) {
    throw new Error(`${authStoreKey} is not provided`);
  }
  return store;
}

ストアの接続

ストアパターンでは、利用するコンポーネントに対してストアを接続(provide)します。今回はログイン状態をアプリ全体で利用するためrootにあたるAppへprovideしています。

src/main.ts
main.ts
import { createApp } from 'vue'
import App from '@/App.vue'
import router from '@/router'
import authStore, { authStoreKey } from '@/stores/auth'

const app = createApp(App)
app.use(router)
app.provide(authStoreKey, authStore())
app.mount('#app')

ストアの呼び出し

各コンポーネントからログイン状態へアクセスします。
ストアは、authStoreをinjectしたuseAuthStore()を下記のように利用します。

使用例
const { state } = useAuthStore();
console.log('name:', state.displayName)
src/stores/auth.ts
export const useAuthStore = () => {
  const store = inject(authStoreKey);
  if (!store) {
    throw new Error(`${authStoreKey} is not provided`);
  }
  return store;
}

1. ヘッダー

LoginButton.vue

ログインボタンをクリックすると、authStoreのログイン関数signin()を実行します。

src/components/LoginButton.vue
LoginButton.vue
<template>
  <button type="button" class="google-button" @click="signin()">
    <span class="google-button__icon">
      <svg viewBox="0 0 366 372" xmlns="http://www.w3.org/2000/svg">
        <path
          d="M125.9 10.2c40.2-13.9 85.3-13.6 125.3 1.1 22.2 8.2 42.5 21 59.9 37.1-5.8 6.3-12.1 12.2-18.1 18.3l-34.2 34.2c-11.3-10.8-25.1-19-40.1-23.6-17.6-5.3-36.6-6.1-54.6-2.2-21 4.5-40.5 15.5-55.6 30.9-12.2 12.3-21.4 27.5-27 43.9-20.3-15.8-40.6-31.5-61-47.3 21.5-43 60.1-76.9 105.4-92.4z"
          id="Shape"
          fill="#EA4335"
        />
        <path
          d="M20.6 102.4c20.3 15.8 40.6 31.5 61 47.3-8 23.3-8 49.2 0 72.4-20.3 15.8-40.6 31.6-60.9 47.3C1.9 232.7-3.8 189.6 4.4 149.2c3.3-16.2 8.7-32 16.2-46.8z"
          id="Shape"
          fill="#FBBC05"
        />
        <path
          d="M361.7 151.1c5.8 32.7 4.5 66.8-4.7 98.8-8.5 29.3-24.6 56.5-47.1 77.2l-59.1-45.9c19.5-13.1 33.3-34.3 37.2-57.5H186.6c.1-24.2.1-48.4.1-72.6h175z"
          id="Shape"
          fill="#4285F4"
        />
        <path
          d="M81.4 222.2c7.8 22.9 22.8 43.2 42.6 57.1 12.4 8.7 26.6 14.9 41.4 17.9 14.6 3 29.7 2.6 44.4.1 14.6-2.6 28.7-7.9 41-16.2l59.1 45.9c-21.3 19.7-48 33.1-76.2 39.6-31.2 7.1-64.2 7.3-95.2-1-24.6-6.5-47.7-18.2-67.6-34.1-20.9-16.6-38.3-38-50.4-62 20.3-15.7 40.6-31.5 60.9-47.3z"
          fill="#34A853"
        />
      </svg>
    </span>
    <span class="google-button__text">Sign in with Google</span>
  </button>
</template>

<script lang='ts'>
import { defineComponent } from "vue";
import { useAuthStore } from "@/stores/auth";
export default defineComponent({
  name: "LoginButton",
  setup() {
    const { signin } = useAuthStore();
    return { signin };
  },
});
</script>

<style scoped>
.google-button {
  height: 40px;
  border-width: 0;
  border-radius: 5px;
  white-space: nowrap;
  box-shadow: 1px 1px 0px 1px rgba(0, 0, 0, 0.05);
  transition-property: background-color, box-shadow;
  transition-duration: 150ms;
  transition-timing-function: ease-in-out;
  padding: 0;
}
.google-button__icon {
  display: inline-block;
  vertical-align: middle;
  margin: 8px 0 8px 8px;
  width: 18px;
  height: 18px;
  box-sizing: border-box;
}
.google-button__icon--plus {
  width: 27px;
}
.google-button__text {
  display: inline-block;
  vertical-align: middle;
  padding: 0 24px;
  font-size: 14px;
  font-weight: bold;
  font-family: "Roboto", arial, sans-serif;
}
</style>
User.vue

ログインしている場合はプロフィール画像と名前を表示、そうでなければログインボタンが表示されるようにします。

src/components/User.vue
User.vue
<template>
  <div class="user" v-if="state.isLoggedin">
    <img class="photoURL" :src="state.photoURL" alt="" />
    <h3 class="displayName">{{ state.displayName }}</h3>
    <button
      type="button"
      class="button is-small is-info is-outlined"
      @click="signout()"
    >
      Sign out
    </button>
  </div>
  <div class="user" v-else>
    <LoginButton />
  </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import { useAuthStore } from "@/stores/auth";
import LoginButton from "@/components/LoginButton.vue";

export default defineComponent({
  name: "User",
  components: {
    LoginButton,
  },
  setup() {
    const { signout, state } = useAuthStore();
    return {
      state,
      signout,
    };
  },
});
</script>

<style scoped>
.user {
  margin-left: auto;
  height: 50px;
  display: inline-flex;
  align-items: center;
  flex-wrap: wrap;
}
.displayName,
button {
  white-space: nowrap;
}
.displayName {
  margin: 0 20px 0 10px;
}
button {
  font-weight: 600;
}
.photoURL {
  border-radius: 50%;
  object-fit: cover;
  width: 50px;
  height: 50px;
}
</style>

2. ホーム画面

ログインしている人の名前を表示させます。

src/views/Home.vue
Home.vue
<template>
  <div class="hello">
    <h1>Hello, {{ state.isLoggedin ? state.displayName : guest }}</h1>
    <p>
      This is a example of an article.
    </p>
    <p>
      Vue3 + Composition API + store pattern + Firebase Auth
    </p>
    <h3>Related links</h3>
    <ul>
      <li><a href="https://github.com/ErgoFriend/vue-firebase-authl" target="_blank" rel="noopener">GitHub Repo</a></li>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-typescript" target="_blank" rel="noopener">Zenn Article</a></li>
    </ul>
    <br>
    <p>Copyright © 2021 <a href="https://kasu.dev">kasu.dev</a> All Rights Reserved.</p>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import { useAuthStore } from '@/stores/auth';

export default defineComponent({
  name: 'Home',
  setup(){
    const guest = '{{ Guest }}'
    const { state } = useAuthStore()
    return { guest, state }
  }
});
</script>

<style scoped>
h3 {
  margin: 40px 0 0;
}
ul {
  list-style-type: none;
  padding: 0;
}
li {
  display: inline-block;
  margin: 0 10px;
}
a {
  color: #42b983;
}
</style>

3. 設定画面

ログインしている人の名前とプロフィール画像のURLを変更できるようにします。
最初に入力用の初期値を空文字列としたv-modelを用意します。その後watchEffectでストアを監視し、値を同期させています。

src/views/Setting.vue
Setting.vue
<template>
  <div class="view">
    <h1>Setting</h1>
    <div class="updater">
      <label>displayName</label>
      <input type="text" v-model="displayName" />
    </div>
    <div class="updater">
      <label>photoURL</label>
      <input type="text" v-model="photoURL" />
    </div>
    <button @click="update()">Update</button>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref, watchEffect } from "vue";
import { useAuthStore } from "@/stores/auth";

export default defineComponent({
  name: "Setting",
  setup() {
    const { state, updateUser } = useAuthStore();
    const displayName = ref("");
    const photoURL = ref("");
    const update = () =>
      updateUser({ displayName: displayName.value, photoURL: photoURL.value });

    watchEffect(() => {
      displayName.value = state.displayName;
      photoURL.value = state.photoURL;
    });

    return { displayName, photoURL, update };
  },
});
</script>

<style scoped>
.updater {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
}
label {
  flex: 1;
}
input {
  flex: 3;
}
button {
  float: right;
}
</style>

firebaseの追加

アプリにfirebaseのパッケージを追加します。

npm install --save firebase
or
yarn add firebase

セットアップ

アプリへfirebaseを接続します。
main.tsではfirebaseのセットアップを行っています。configはFirebaseのプロジェクト設定画面からウェブのマイアプリを追加して取得します。https://console.firebase.google.com/

src/main.ts
main.ts
import { createApp } from 'vue'
import firebase from "firebase/app"
import 'firebase/auth'
import App from '@/App.vue'
import router from '@/router'
import authStore, { authStoreKey } from '@/stores/auth'

const config = {
  apiKey: "AIzaSyC65X-Nz7uVsrPR1x6rI2tx_Mjdny6pAl8",
  authDomain: "vue-firebase-demo-ebdef.firebaseapp.com",
  projectId: "vue-firebase-demo-ebdef",
  storageBucket: "vue-firebase-demo-ebdef.appspot.com",
  messagingSenderId: "213709504759",
  appId: "1:213709504759:web:40aac8e6d6cc38cbeb53f2",
  measurementId: "G-EVD8ZEB5M3"
}

firebase.initializeApp(config);
firebase.auth().setPersistence(firebase.auth.Auth.Persistence.LOCAL)

const app = createApp(App)
app.use(router)
app.provide(authStoreKey, authStore())
app.mount('#app')

アプリへ導入

siginin()siginin()firebase.auth()を実行し、ログイン状態はfirebase.auth().onAuthStateChangedがログイン・ログアウト時に呼ばれることを利用してsetUser()からstateを変更しています。

src/stores/auth.ts
auth.ts
import firebase from "firebase/app";
import 'firebase/auth';
import { inject, InjectionKey, reactive } from "vue";

const authStore = () => {
  console.log('init authStore')
  const state = reactive({ isLoggedin: false, displayName: '', photoURL: ''})
  const setUser = (user: firebase.User | null) => {
    state.isLoggedin = !!user
    if (user) {
      state.displayName = user.displayName ?? ''
      state.photoURL = user.photoURL ?? ''
    }
  }
  const signin = () => {
    const provider = new firebase.auth.GoogleAuthProvider();
    firebase.auth().signInWithPopup(provider)
  }
  const signout = () => firebase.auth().signOut()
  const updateUser = (input: { displayName?: string; photoURL?: string }) => {
    firebase.auth().currentUser?.updateProfile(input)
      .then(() => setUser(firebase.auth().currentUser)
    )
  }

  firebase.auth().onAuthStateChanged((user) => setUser(user))

  return {
    state,
    setUser,
    signin,
    signout,
    updateUser,
  };
}

export default authStore

export type AuthStore = ReturnType<typeof authStore>;

export const authStoreKey: InjectionKey<AuthStore> = Symbol('authStore');

export const useAuthStore = () => {
  const store = inject(authStoreKey);
  if (!store) {
    throw new Error(`${authStoreKey} is not provided`);
  }
  return store;
}

本アプリは以上で完成です。今回はストアパターンでログイン状態の管理を行うアプリを作成しました。デモの通りに動かない場合は、GitHubリポジトリを参考にするかDiscussionへお願いします。

おわりに

最後までお読みいただきありがとうございます。

筆者のプログラミング経験はVueが最初で、その後すぐにReactをやることになったのですがとても苦戦したのを覚えています。Composition API時代のVueからReactを始めたら、比較的楽な感じがしなくもありません。

Discussion