Firebase+Vue.js(Vue3 Composition API)でチャット機能を作成する

2021年4月22日Vue.js

Firebase+Vue.jsでチャット機能を作成します。

ログイン/サインアップのフォームを作成する

ログイン/サインアップのフォームの作成方法は、こちらの記事を参考にしてみてください。
Firebase+Vue.js(Vue3 Composition API)でログイン/サインアップフォームを作成する

ディレクトリ構造


root
├─ src
   ├─ App.vue
   ├─ main.js
   ├─ assets
   │   └─ main.css
   ├─ components
   │   ├─ ChatWindow.vue
   │   ├─ LoginForm.vue
   │   ├─ Navbar.vue
   │   ├─ NewChatForm.vue
   │   └─ SignupForm.vue
   ├─ composables
   │   ├─ getCollection.js
   │   ├─ getUser.js
   │   ├─ useCollection.js
   │   ├─ useLogin.js
   │   ├─ useLogout.js
   │   └─ useSignup.js
   ├─ firebase
   │   └─ config.js
   ├─ views
   │   ├─ Chatroom.vue
   │   └─ Welcome.vue

Firestoreにチャットを追加する

Firebaseのcollectionを使う-useCollection.js

import { ref } from 'vue'
import { projectFirestore } from '../firebase/config'


// Firescoreのcollection()メソッドに渡したいコレクションを指定する。
// 今回の場合messageを渡したいので、useCollectionの引数としてmessageを受け取れるようにする
const useCollection = collection => {
  const error = ref(null) //collectionはいろいろあるので複数のエラーが考えられる。そのため毎回errorを作成できるように関数内で宣言する

  const addDoc = async doc => { //docにはchatが渡ってくる
    error.value = null
    try {
      // collection()メソッドの引数にはmessageが渡ってくる
      await projectFirestore.collection(collection).add(doc)
    } catch (err) {
      console.log(err.message)
      error.value = 'could not send the message'
    }
  }
  return { addDoc, error }
}

export default useCollection

FirebaseのprojectFirestoreを使います。
Firescoreのcollection()メソッドに渡したいコレクションを指定します。
今回の場合messageを渡したいので、useCollectionの引数としてmessageを受け取れるようにします。

collectionはいろいろあるので複数のエラーが考えられるため、ため毎回errorを作成できるようにエラーは関数内で宣言します。

そして、async-awaitで非同期処理でFirebaseのcollection()メソッドを実行します。その引数として受け取りたいcollectionを指定して、追加するためにadd()メソッドを使います。
add()メソッドの引数には、docとして、chatが渡ってくるようにします。

これで、useCollectionが呼び出されたら、非同期でcollectionにchatが追加される、という処理が可能になります。

チャットを作成するーNewChatForm.vue

<template>
  <form>
    <textarea
      placeholder="メッセージを入力して、enterキーを押してください"
      v-model="message"
      @keypress.enter.prevent="handleSubmit"
    >
    </textarea>
    <div class="error">{{error}}</div>
  </form>
</template>
<script>
import { ref } from '@vue/reactivity'
import getUser from '../composables/getUser'
import useCollection from '../composables/useCollection'
import { timestamp } from '../firebase/config'

export default {
  setup() {
    const { user } = getUser()
    const { addDoc, error } = useCollection('message')
    const message = ref('')
    const handleSubmit = async () => {
      const chat = {
        name: user.value.displayName,
        message: message.value,
        createdAt: timestamp()
      }
      await addDoc(chat)
      if(!error.value) {
        message.value = ''

      }
    }

    return { message, handleSubmit, error }
  }
}
</script>

ユーザー情報を取得するgetUserやFirebaseのtimestampは、ログイン/サインアップのフォームの作成方法を参照してみてください。
getUserからuserを受け取り、useCollectionで引数としてcollection名にしたいmessageを渡し、addDocとerrorを受け取ります。
送信イベントが発火したら、chatを作成し、async-awaitで非同期的に指定したmessageのcollectionにchatを追加します。

リアルタイムででチャットを表示する

firestoreから必要な表示するために必要な情報を整理する-getCollection.js

import { ref, watchEffect } from 'vue'
import { projectFirestore } from '../firebase/config'

const getCollection = collection => {
  const documents = ref(null) //複数のコンポーネントが考えられるため、毎回宣言されるように関数内で宣言する
  const error = ref(null)
  let collectionRef = projectFirestore
    .collection(collection)
    .orderBy('createdAt')

  // onSnapshotでは第一引数ではsnapshotのメソッドを作成する
  // 第二引数では、エラーのメソッドを作成する
  const unsub = collectionRef.onSnapshot(
    // onSnapshotの第一引数部分
    snap => {
      //snapにはすべてのdocの情報が渡される
      let results = [] //ここに必要な情報のみを格納する
      snap.docs.forEach(doc => {
        //doc.data().createdAtがある場合にのみ、resultsにデータを格納するので&&でつなげる
        // createdAtはtimestampなので、timestampがない場合はそれ移行は実行されない。
        doc.data().createdAt && results.push({ ...doc.data(), id: doc.id })
      })
      documents.value = results //chatの内容の入ったデータをdocumentsに代入する
      error.value = null
    },
    // onSnapshotの第二引数部分
    err => {
      console.log(err.message)
      documents.value = null
      error.value = 'could not fetch data'
    }
  )

  // snapshotが複数回実行されてしまうのでアップデートされたときのみ実行したい
  watchEffect(onInvalidate => {
    // ウォッチャーが止まったら(コンポーネントがアンマウントしたら)prev collectionからアンサブスクライブする
    onInvalidate(() => unsub())
  })
  return { documents, error }
}

export default getCollection

documentsに必要なチャットの情報のみを整理して格納します。
errorにエラーがあった場合のメッセージを格納します。
それぞれ他のコンポーネントがある場合も考えて、毎回実行されるように関数内で宣言します。

colectionは日付順に並べたいのでorderByでソートしたものをcollectionRefに格納します。
onSnapshot()メソッドでは2つの引数を用意します。

第一引数ではsnapshotのメソッドを作成して、第二引数では、エラーのメソッドを作成します。
snapにはすべてのdocの情報が渡されますが、たくさんの情報が含まれているので、必要な情報のみをresultsに格納します。
&&はJavaScriptの短絡評価(ショートサーキット)です。
より詳しく知りたい場合は、次の記事を参考にしてみてください。

watchEffect+onInvalidate関数

複数のリアルタイムリスナーがあるので、ログアウトしてログインしたとき、毎回新しいコレクションを作成して同じデータのsnapshotが実行されてしまいます。
パフォーマンスとして悪くなってしまうため、不要なsnapshotの実行をさせないために、watchEffectとonInvalidate関数を使います。
Vue.jsの公式に使い方が書かれています。
上では、まずvueからwatchEffectをimportします。
そして、onSnapshot()メソッドの部分をアンサブスクライブするためにunsubという変数に格納します。
そして、watchEffectでonInvalidate関数を引数に設定して、onInvalidate関数を通して、unsubを実行します。
こうすることで、投稿したら、ブラウザのsnapshotとfirebaseから返されるsnapshotの2回だけ実行されます。

watchEffectの使い方は、こちらの記事を参考にしてみてください。

リアルタイムでチャットを表示する-ChatWindow.vue

<template>
  <div class="chat-window">
    <div v-if="error">{{ error }}</div>
    <div v-if="documents" class="message" ref="messages">
      <div v-for="doc in formattedDocuments" :key="doc.id" class="single">
        <span class="created-at">{{ doc.createdAt }}</span>
        <span class="name">{{ doc.name }}</span>
        <span class="message">{{ doc.message }}</span>
      </div>
    </div>
  </div>
</template>

documentsをv-forでループしてそれぞれ単体のチャットにすることで、各プロパティにアクセスしてメッセージなどをリアルタイムで表示することができるようになります。
投稿日時を「○○分前」「~時間前」「○○日前」といった表記に変更したいので、変数formattedDocumentsを作成して、それをv-forでループします。

<script>

import getCollection from '../composables/getCollection'
import { formatDistanceToNow } from 'date-fns'
import { ja } from 'date-fns/locale'
import { computed, onUpdated, ref } from 'vue'

export default {
  setup() {
    const { error, documents } = getCollection('message')

    // 新しい日付に変換する
    const formattedDocuments = computed(() => {
      if (documents.value) {
        return documents.value.map(doc => {
          let time = formatDistanceToNow(doc.createdAt.toDate(), {
            addSuffix: true,
            locale: ja
          })
          return { ...doc, createdAt: time } //docにはmessageとかが含まれていて、createdAtはtimeで上書きする
        })
      }
    })
    // 最新メッセージへ自動スクロール
    const messages = ref(null)

    onUpdated(() => {
      messages.value.scrollTop = messages.value.scrollHeight
    })
    return { error, documents, formattedDocuments, messages }
  }
}
</script>

getCollection.jsからチャットで表示するために必要な情報だけを含んだdocumentsとerrorを受け取ります。
そして、reutrnで返します。

date-fnsを使って投稿日時を「○○分前」「~時間前」「○○日前」といった表記に変更する

formattedDocumentsでは、すでに存在する投稿日時のプロパティを変換したいので、computedを使います。
忘れずにvueからimportします。

documentsに値がない場合は投稿日時がないので実行する必要がないため、if文で条件分岐してから、map()で新しい日付フォーマットを格納した配列を作成します。
投稿日時を「~時間前」といった表記に変更したいので、date-fnsを使います。
date-fnsのドキュメントを参考にインストールします。

ディレクトリを確認してから、

npm install date-fns

もしくは

yarn add date-fns

を実行してdate-fnsをインストールします。
ドキュメントには–saveの記載がありますが、npm 5.0.0以降からはデフォルトでsaveしてくれるので –save や -S オプションを指定する必要はありません。

そして、formatDistanceToNow()とlocaleを日本にするためjaをimportします。
formatDistanceToNow()メソッドの第一引数にFirebaseから取得した日付を指定し、第二引数にオプションを指定できます。
addSuffixをtrueにすると、「前」と表示することができます。
それに加えて、localeをjaにすることで「○○時間前」という表記にすることができます。

最後にスプレッド構文を使ってチャットの一かたまりが格納されているドキュメント(doc)とその中に格納されいるcreatedAtをtimeに上書きしてリターンします。

投稿1分以内のときはJust nowと表示したい場合

アバウトにはなりますが、たとえば次のように現在日時を取得してからそれとfirebaseの日時を比較することで時間によって切り分けることができます。
1分以内なら1000ms * 60なので60000が比較対象となります。

...
    const date = new Date()

    // 新しい日付に変換する
    const formattedDocuments = computed(() => {
      if (documents.value) {
        return documents.value.map(doc => {
          let time = date-doc.createdAt.toDate() <= 60000 ? 
      'Just now' : formatDistanceToNow(doc.createdAt.toDate(),
      {
          addSuffix: true,
          locale: ja
           }
      )
...

最新メッセージへ自動スクロールする

自動スクロールを反応させたい部分にref属性を設定します。
今回の場合、チャットのかたまりを対象にしたいので、そこにref属性でmessagesとします。

最新メッセージというのはメッセージが新たに更新されたときということなので、ライフサイクルフックのonUpdatedを使います。
refとonUpdatedを忘れずにimportします。
そしてonUpdatedでscrollHeightをscrollTopに代入することで自動スクロールするようになります。

Vue.js

Posted by devsakaso