Vue3でFirebaseログイン
はじめに
この記事は2年前に書いたVue vuexでfirebaseのログイン保持を、Vue3+Composition APIで書き直したものです。アーキテクチャも変更しています。
チュートリアル
デモはこちら https://vue-firebase-demo-ebdef.web.app/
ソースコードはGitHubへ載せています ErgoFriend/vue-firebase-auth
以下の要素を組み合わせて、複数コンポーネントで状態を共有するVueアプリをつくっていきます。
- TypeScript
- SPA
- Vue3
- Composition API
- ストアパターン
-
vue-router
による遷移 - Firebase Authentication
流れは以下の通りです。
- 環境構築
- 見た目(UI)をつくる
- 状態管理(store)を追加
- 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
<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
<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
<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
<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
<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
<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
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
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
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)
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
<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
<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
<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
<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
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
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