【Vue + Vuetify + Firebase】Todoアプリを爆速で作る

# はじめに
仕事で学んだフロントエンドの技術を駆使し、復習も兼ねて自作Todoアプリを作成してみました。
「フロントエンド」にはVueとVuetify、
「バックエンド x データベース」の処理にはFirebaseを使用しました。


# Vueとは
Vue.js（Vue）は、JavaScriptで構築されたフロントエンドのフレームワークで、ユーザーインターフェース（UI）を効率的に作成するためのツールです。2014年にEvan Youによって開発され、学習しやすく、柔軟性が高いことで人気があります。
他によく使われるフレームワークとして、React, Angularなどがあります。


# Vuetifyとは
Vuetifyは、Vue.jsのために設計された人気のあるUIフレームワークです。Googleの「Material Design」ガイドラインに基づいて構築されており、Vue.jsアプリケーションでのフロントエンド開発を効率化するために、多くのプリビルドコンポーネントやテーマカスタマイズ機能を提供しています。
ゼロからUIを作成する手間を省けるだけでなく、開発者に依存しない、プロジェクト全体で統一感を持たせた開発を行えるようになります。


# Firebaseとは
Firebaseは、Googleが提供するクラウドベースのバックエンドサービスで、モバイルアプリやウェブアプリの開発を効率化するためのプラットフォームです。開発者がアプリのコア機能に集中できるよう、サーバー管理やインフラの構築を代行する仕組みを提供します。

#### Firebaseの特徴
Firebaseは、以下のような機能を簡単に利用できるため、バックエンドの知識がなくてもアプリケーションを開発しやすい環境を提供します。
1. リアルタイムデータベース
    - クラウド上に保存されたデータをリアルタイムで同期。
    - アプリのユーザー同士でデータを共有するチャットアプリや、リアルタイムで更新が必要なダッシュボードの構築に最適。
例:
        - チャットアプリでのメッセージの即時表示。
        - 複数人での共同作業ツール。

2. Firestore（Cloud Firestore）
    - リアルタイムデータベースの進化版。スケーラブルで柔軟なNoSQLデータベース。
    - 構造化されたデータをコレクションとドキュメントの形で保存。
    - クエリが簡単に書け、検索やフィルタリングが効率的。
3. 認証（Firebase Authentication）
    - Google、Facebook、Twitter、GitHubなどのソーシャルログインをサポート。
    - 電話番号やメール・パスワードでの認証も簡単に設定可能。
    - セキュリティが強化された認証システムを簡単に実装できる。
4. Firebase Hosting
    - 高速で安全なウェブホスティングサービス。
    - 静的ファイルやSPA（シングルページアプリケーション）のホスティングに最適。
    - HTTPS、カスタムドメイン、キャッシュ制御などが簡単に設定可能。
5. クラウドストレージ（Firebase Storage）
    - 画像、動画、音声ファイルなどの大容量データを安全に保存。
    - ユーザーがアップロードするファイルの管理が簡単。
6. クラウド機能（Cloud Functions）
    - サーバーレスの関数を利用して、バックエンドのロジックを実行可能。
    - 例えば、特定のデータが更新されたときに通知を送信するなど、自動化されたタスクを実現。
7. プッシュ通知（Firebase Cloud Messaging, FCM）
    - 無料でプッシュ通知を送信できるサービス。
    - モバイルアプリやウェブアプリに通知を組み込むことで、ユーザーエンゲージメントを向上。
8. Firebase Analytics
    - ユーザーの行動を追跡し、データを分析。
    - アプリの使用状況やマーケティング効果を把握できる。
    - 目標達成率やユーザーリテンションを向上させるためのデータドリブンな改善が可能。
9. Firebase Test Lab
    - 仮想デバイス上でアプリをテストできる機能。
    - 開発したアプリがさまざまなデバイスやOSで問題なく動作するかを確認可能。


# 実装していく

### 1.　環境構築
ターミナルを開き、以下のコマンドを実行します。（npmコマンドが使える前提で進めます）
`npm create vuetify@latest`

とりあえず今回は以下のような設定で進めます。
（設定項目の詳しい内容については調べてみてください）


![スクリーンショット 2025-01-26 午後9.51.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3899248/cb2ff71a-b333-0417-51d6-ac6190e20d8c.png)

作成されたプロジェクトのディレクトリに移動します。
`cd todo-app`
`npm install`
`npm run dev`
`http://localhost:3000`　にアクセスすると以下のようにプロジェクトが確認できます。

![スクリーンショット 2025-01-26 午後9.58.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3899248/88b242e2-358c-17a6-9fc5-76f8868d9a81.png)


これで環境構築は終了です。

デフォルトではテーマカラーが黒なので、
`src/plugins/vuetify.ts`の
`defaultTheme: 'dark',`の部分をコメントアウトすると見やすくなります。





### 2.　ワイアーフレームの使用
Vuetifyで提供されている以下のソースコードをコピーしてApp.vueに貼り付けます。
（scriptタグを一部修正しています）

https://github.com/vuetifyjs/vuetify/blob/master/packages/docs/src/examples/wireframes/inbox.vue

```typescript: src/App.vue
<script lang="ts" setup>
  import { ref } from 'vue'

const cards = ['Today', 'Yesterday']
const links = [
  ['mdi-inbox-arrow-down', 'Inbox'],
  ['mdi-send', 'Send'],
  ['mdi-delete', 'Trash'],
  ['mdi-alert-octagon', 'Spam'],
]

const drawer = ref(null)
</script>

<template>
  <v-app id="inspire">
    <v-system-bar>
      <v-spacer></v-spacer>

      <v-icon>mdi-square</v-icon>

      <v-icon>mdi-circle</v-icon>

      <v-icon>mdi-triangle</v-icon>
    </v-system-bar>

    <v-navigation-drawer v-model="drawer">
      <v-sheet
        class="pa-4"
        color="grey-lighten-4"
      >
        <v-avatar
          class="mb-4"
          color="grey-darken-1"
          size="64"
        ></v-avatar>

        <div>john@google.com</div>
      </v-sheet>

      <v-divider></v-divider>

      <v-list>
        <v-list-item
          v-for="[icon, text] in links"
          :key="icon"
          :prepend-icon="icon"
          :title="text"
          link
        ></v-list-item>
      </v-list>
    </v-navigation-drawer>

    <v-main>
      <v-container
        class="py-8 px-6"
        fluid
      >
        <v-row>
          <v-col
            v-for="card in cards"
            :key="card"
            cols="12"
          >
            <v-card>
              <v-list lines="two">
                <v-list-subheader :title="card"></v-list-subheader>

                <template v-for="n in 6" :key="n">
                  <v-list-item>
                    <template v-slot:prepend>
                      <v-avatar color="grey-darken-1"></v-avatar>
                    </template>

                    <v-list-item-title :title="`Message ${n}`"></v-list-item-title>

                    <v-list-item-subtitle title="Lorem ipsum dolor sit amet, consectetur adipisicing elit. Nihil repellendus distinctio similique"></v-list-item-subtitle>
                  </v-list-item>

                  <v-divider
                    v-if="n !== 6"
                    :key="`divider-${n}`"
                    inset
                  ></v-divider>
                </template>
              </v-list>
            </v-card>
          </v-col>
        </v-row>
      </v-container>
    </v-main>
  </v-app>
</template>
```

`http://localhost:3000`　にアクセスすると以下のようにプロジェクトが確認できます。

![スクリーンショット 2025-01-26 午後10.10.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3899248/f9d2d4df-2b83-04ce-5dd4-22bfc3a6548e.png)



### 3.　Firebaseの設定
バックエンドxデータベースの処理をFirebaseに任せるために、設定をしていきます。
手順としては、
1. Firebase公式サイトにアクセス
2. googleアカウントでログイン
3. コンソールへ移動
4. プロジェクトを作成
5. データベースの作成
6. コレクションを開始

![スクリーンショット 2025-01-27 午前12.14.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3899248/948c16a2-1850-3d46-0af6-40df0d81e5c1.png)

ドキュメントIDは自動ID、フィールドと値は空のまま、タイプはstringで保存します。

![スクリーンショット 2025-01-27 午前12.16.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3899248/06b63857-5696-893f-4616-4e8cedd6fa72.png)


ここまで完了したら、FirebaseプロジェクトのトップページからウェブアプリへのFirebaseの追加を行います。
アプリの登録が完了すると、ユニークなFirebase SDKが作成されます。

`src/firebase/firebase.ts`を作成し、先ほど作成されたユニークなスクリプトを貼り付けます。
例）
```typescript: firebase.ts
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";

const firebaseConfig = {
  apiKey: "*********************",
  authDomain: "*********************",
  projectId: "*********************",
  storageBucket: "*********************",
  messagingSenderId: "*********************",
  appId: "*********************"
};

const app = initializeApp(firebaseConfig);
const db = getFirestore(app);

export { app, db };
```

プロジェクトのルートディレクトリで、Firebaseのパッケージをインストールします。
`npm install firebase`

これでデータベースへのcrud処理が可能になります。
詳しくは以下の動画を参考にしてみてください。
https://youtu.be/apeOMI3xln0?list=PLaW8A7W8XgYIUmHiQuGkueWFZaW-92Czu


### 4.　App.vueの修正
最後に、設定したFirebase、Vue、Vuetifyの機能をフルに活用してTodoアプリを実装していきます。
長くなるので今回は細かいコードの内容説明は省きますが、気になる方は調べてみてください。
以下のソースコードでApp.vueを書き換えれば、Todoアプリを体験できると思います。

```typescript: src/App.vue
<script lang="ts" setup>
import { onMounted, ref, reactive } from 'vue';
import { db } from '@/firebase/firebase.ts';
import { collection, getDocs, addDoc, deleteDoc, doc, updateDoc, query, orderBy } from 'firebase/firestore';

const cards = ['Todo'];
const todoList = reactive([]);
const taskInput = ref('');

const handleSubmit = async () => {
  if (taskInput.value.trim() !== '') {
    const newTask = {
      task: taskInput.value,
      timestamp: new Date()
    };

    try {
      const docRef = await addDoc(collection(db, "tasks"), newTask);
      todoList.unshift({ ...newTask, id: docRef.id });
      taskInput.value = '';
    } catch (error) {
      console.error("Firestoreへのメッセージ保存に失敗しました:", error);
    }
  }
};

const handleDelete = async (id) => {
  try {
    // Firestoreから該当タスクを削除
    await deleteDoc(doc(db, "tasks", id));

    // ローカルのタスクリストから削除
    const index = todoList.findIndex((tsk) => tsk.id === id);
    if (index !== -1) {
      todoList.splice(index, 1);
    }
  } catch (error) {
    console.error("Firestoreからのタスク削除に失敗しました:", error);
  }
};

const handleEdit = (item) => {
  // 編集モードに切り替え & 現在のタスクを保存
  item.originalTask = item.task; 
  item.isEditing = true;
};

const handleSave = async (item) => {
  try {
    const taskDoc = doc(db, "tasks", item.id);
    await updateDoc(taskDoc, { task: item.task });

    item.isEditing = false; // 編集モードを解除
    delete item.originalTask; // 保存後にoriginalTaskを削除
  } catch (error) {
    console.error("Firestoreのタスク更新に失敗しました:", error);
  }
};

const handleCancelEdit = (item) => {
  // 元のタスクを復元し、編集モードを解除
  item.task = item.originalTask;
  delete item.originalTask;
  item.isEditing = false;
};

const handleReset = () => {
  taskInput.value = '';
};

onMounted(async () => {
  try {
    const chatRef = collection(db, "tasks");

    // timestampで降順にソート
    const chatQuery = query(chatRef, orderBy("timestamp", "desc"));
    const snapShot = await getDocs(chatQuery);
    snapShot.forEach((doc) => {
      todoList.push({ ...doc.data(), id: doc.id }); // doc.idを含める
    });
  } catch (error) {
    console.error("Firestoreのデータ取得に失敗しました:", error);
  }
});
</script>

<template>
  <v-app>
    <v-main>
      <v-container class="py-8 px-6" fluid>
        <v-row>
          <v-col v-for="card in cards" :key="card" cols="12">
            <v-card>
              <v-list lines="two">
                <v-list-subheader>{{ card }}</v-list-subheader>

                <template v-for="(item, index) in todoList" :key="index">
                  <v-list-item>
                    <template #prepend>
                      <v-avatar color="grey-darken-1" size="24"></v-avatar>
                    </template>
                    <div class="mcon">
                      <div v-if="item.isEditing">
                        <v-textarea
                          v-model="item.task"
                          label="タスクを編集"
                          rows="1"
                          auto-grow
                        ></v-textarea>
                        <v-btn @click="handleSave(item)" color="success" small>保存</v-btn>
                        <v-btn @click="handleCancelEdit(item)" color="error" small>キャンセル</v-btn>
                      </div>
                      <div v-else class="todo-item">
                        <v-list-item-title class="task">{{ item.task }}</v-list-item-title>
                        <v-btn size="30" @click="handleEdit(item)" icon>
                          <v-icon>mdi-pencil</v-icon>
                        </v-btn>
                        <v-btn size="30" @click="handleDelete(item.id)" icon>
                          <v-icon>mdi-delete</v-icon>
                        </v-btn>
                      </div>
                    </div>
                  </v-list-item>

                  <v-divider v-if="index !== todoList.length - 1" :key="`divider-${index}`" inset></v-divider>
                </template>
              </v-list>
            </v-card>
          </v-col>
        </v-row>
      </v-container>

      <v-container class="input-container">
          <div class="input-form">
            <v-textarea
              v-model="taskInput"
              class="mx-2"
              label="タスクを追加する"
              rows="1"
              append-inner-icon="mdi-close"
              auto-grow
              @click:append-inner="handleReset"
            ></v-textarea>
            <v-btn size="55" class="submit-button" @click="handleSubmit" type="submit">送信</v-btn>
          </div>
      </v-container>
    </v-main>
  </v-app>
</template>

<style scoped>
.task {
	text-align: left;
}
.mcon {
  margin-left: 20px;
}
.input-container {
	margin: 0;
}
.input-form {
  display: flex;
  width: 50%;
}
.todo-item {
  display: flex;
  gap: 10px;
  align-items: center;
  height: 40px;
}
</style>

```


完成したのがこちら

![画面収録-2025-01-27-午前1.03.23.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3899248/bd29fc92-ef37-2400-8b6a-4c6dbd9a42b9.gif)



# おわりに
実際に環境構築から作ってみたことで、Vuetifyのテンプレートやコンポーネントの使い方をより一層理解できた気がします。
Firebaseに関しても、今回のTodoアプリ実装を通して、基本的なcrud処理を学べました。
バックエンドを別で実装せずに済むので、フロントエンド開発だけに集中したい方にはオススメです！