使用Firebase Realtime Database (version 9) 與 Vue 3 建立簡單多人即時聊天室 (二)
簡單多人即時聊天室,簡易流程圖如下,大致會分為兩步驟:
- 開始畫面:讓使用者輸入暱稱,未來可擴充成登入畫面。
- 多人即時聊天室:簡易的即時聊天室,可以接收及發送訊息。
(使用vue3 、vuetify 3、 typescript 建置專案,相關建置專案的步驟可以參考前幾篇的文章)
firebase.ts
首先我們新建一個firebase.ts,封裝firebase相關的程式,並將初始化firebase相關的程式寫在這裡,並export一些在其它components可能會使用的方法或物件。
並引入uuid及day.js(於@/utils/day 進行封裝過),作為寫入database時使用。
writeUserData為寫入資料庫的方法,傳入暱稱、訊息,會先用time uuix在chatroom建立一個key(chatroom/${dayjs().unix()}),才把資料寫入。
// Import the functions you need from the SDKs you need
import { initializeApp } from 'firebase/app'
import { getDatabase, ref, set, onValue, DataSnapshot } from 'firebase/database'
import { v4 as uuidv4 } from 'uuid'
import { useDayjs } from '@/utils/day'
const dayjs = useDayjs()
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
// Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
const firebaseConfig = {
apiKey: 'xxxxxx-xxxxxxxx_xxxxxxxxxx',
authDomain: 'chat-room-xxxx.firebaseapp.com',
projectId: 'chat-room-xxxx',
storageBucket: 'chat-room-xxxx.appspot.com',
messagingSenderId: 'xxxxxxxxx',
appId: '1:xxxxxxx:web:xxxxxxxxxxxxx',
measurementId: 'x-xxxxxxxx'
}
// Initialize Firebase
const app = initializeApp(firebaseConfig)
// Initialize Realtime Database and get a reference to the service
export const database = getDatabase(app)
// 建立資料庫ref
export const realtimeRef = ref(database, 'chatroom')
// 寫入 database
export const writeUserData = (username: string, message: string) => {
set(ref(database, `chatroom/${dayjs().unix()}`), {
key: uuidv4(),
username,
message,
time: dayjs().unix()
})
}
Chatroom.vue
聊天室的父元件,分為兩步驟,登入(輸入暱稱)、聊天室,根據步驟去顯示登入或聊天室。
因為專案比較小,所以就沒使用route,不然使用route去控制頁面可能會是比較好的選擇。
<template>
<div class="w-25 chatroom-container">
<Login v-if="step === 1" @next="nextStep"></Login>
<Chat v-if="step === 2" :messages="messages" :username="username"></Chat>
</div>
</template>
import Login from '@/components/Login.vue'
import Chat from '@/components/Chat.vue'
import { ref } from 'vue'
const username = ref('')
const step = ref(1)
const nextStep = (name: string) => {
if (name !== '') {
username.value = name
step.value = 2
}
}
<style lang="scss" scoped>
.chatroom-container{
margin: 0 auto;
border: 1px solid #F4F4F4;
}
</style>
Login.vue
輸入暱稱畫面的元件,很簡單的只包含暱稱輸入框,開始聊天按鈕。
如果輸入框為空的讓button disabled,button在點擊後觸發nextStep(),發出emit將username傳給父元件,告知進行下一步。
把此元件獨立出來,未來如果想要改為使用帳號密碼登入,只要修改此元件就好,不用動到其他元件。
<template>
<div class="login-block mt-10">
<div class="text-center">請輸入暱稱:</div>
<v-responsive
class="mx-auto my-8"
max-width="300px"
width="300px">
<v-text-field
density="comfortable"
variant="outlined"
hide-details="auto"
v-model.trim="username"
:placeholder="'請輸入使用者暱稱'">
</v-text-field>
</v-responsive>
<v-responsive
class="mx-auto my-8"
max-width="300px"
width="300px">
<v-btn
color="info"
block
fab
:disabled="username === ''"
@click="nextStep()">
開始聊天
</v-btn>
</v-responsive>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const username = ref('')
const emit = defineEmits({
next: (_name: string) => true
})
const nextStep = () => {
emit('next', username.value)
}
</script>
Chat.vue
聊天室主體的元件,會分為三個部分(元件):
- 使用者資訊:顯示聊天室使用者資訊。
- 接收訊息的區塊:接收訊息、要能區分傳送訊息的人。
- 傳送訊息功能區塊:有輸入框及送出訊息的按鈕。
在onMounted時,使用onValue監聽資料。並設定一個loginTimestamp,用來接收訊息時,判斷不會收到登入以前訊息(因為是即時多人聊天室,不用顯示之前的訊息,如果像是Line這種通訊軟體就需要)。
由於snapshot會把整個資料庫中的資料回傳過來,所以使用snapshot.forEach 跑迴圈,並用size紀錄,只把最後(最新)一筆,push到messages中傳給MessageContainer元件顯示於畫面。
因為是使用timestamp當作key存放進資料庫,firebase會用key做排序、存放,所以最新的一筆一定在最後;如果你不是使用timestamp當作key而是使用uuid之類的,根據排序,最後一筆有可能不會是最新的。
<template>
<div>
<Info :username="username"></Info>
<MessageContainer :messages="messages" :username="username"></MessageContainer>
<MessageInput :username="username"></MessageInput>
</div>
</template>
<script lang="ts" setup>
import Info from '@/components/Chat.Info.vue'
import MessageContainer from '@/components/Chat.Message-Container.vue'
import MessageInput from '@/components/Chat.Message-Input.vue'
import { onMounted, ref } from 'vue'
import { realtimeRef } from '@/firebase'
import { onValue, DataSnapshot } from 'firebase/database'
import { useDayjs } from '@/utils/day'
type Message = {
key: string
message: string
username: string
time: string
}
const dayjs = useDayjs()
defineProps({
username: {
type: String,
required: true
}
})
let messages = ref([] as Array<Message>)
let loginTimestamp = 0
onMounted(() => {
loginTimestamp = dayjs().unix()
onValue(realtimeRef, (snapshot: DataSnapshot) => {
if (snapshot.exists()) {
let size = 1
snapshot.forEach((item) => {
if (snapshot.size === size && item.key !== null && Number(item.key) >= loginTimestamp) {
messages.value.push(item.val())
}
size++
})
}
}, (error) => {
console.error(error)
})
})
</script>
Chat.Info.vue
使用者資訊顯示元件,很簡單的就是把父元件傳進來的暱稱顯示出來。
未來如果要顯示其他使用者資訊,只要修改此元件即可。
<template>
<div class="info-block d-flex justify-center align-center pa-2">
<span class="mr-2">使用者暱稱:</span>
<span>{{ username }}</span>
</div>
</template>
<script lang="ts" setup>
defineProps({
username: {
type: String,
required: true
}
})
</script>
<style lang="scss" scoped>
.info-block{
background-color: azure;
}
</style>
Chat.Message-Container.vue
接收訊息的元件,此元件比較複雜的部分是,要如何區分是自己的訊息還是別人的訊息。
這邊是用使用者的暱稱去判斷,如果收到訊息的使用者名稱跟自己相同就顯示在右邊,如果不是就是左邊,並顯示icon及其他使用者的暱稱。
然後監聽messages,每次傳入訊息時就呼叫scrollToEnd方法,滑動至最下方,確保如果訊息超過高度,最新訊息一樣可以顯示在最下方,不用讓使用者自己去滑動。
scrollToEnd方法使用nextTick確保畫面更新正確。
此元件專注於處理訊息的顯示。
<template>
<div class="message-container pb-10" align="end">
<div class="message-block" ref="messageContainer">
<div
v-for="(item) in messages"
:class="['d-flex flex-row align-center my-2', item.username === username ? 'justify-end': null]"
:key="item.key">
<span v-if="item.username === username" class="mx-3 pa-2 rounded msg-border-user">
<v-chip>{{ item.message }}</v-chip>
</span>
<div v-if="item.username !== username" class="d-flex mx-2">
<div class="d-flex flex-column align-center">
<v-avatar class="rounded-circle avatar-border" size="36">
<v-icon class="face-agent-icon">mdi-emoticon-cool-outline</v-icon>
</v-avatar>
<span>{{ item.username }}</span>
</div>
<span class="mx-3 pa-2 rounded msg-border-service">
<v-chip color="secondary">{{ item.message }}</v-chip>
</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, nextTick, type PropType, watch } from 'vue'
type Message = {
key: string
message: string
username: string
time: string
}
const messageContainer = ref<HTMLInputElement>()
const props = defineProps({
messages: {
type: [] as PropType<Array<Message>>,
required: true
},
username: {
type: String,
required: true
}
})
watch(props.messages, () => {
scrollToEnd()
})
const scrollToEnd = () => {
nextTick(() => {
messageContainer.value!.scrollTo({ top: messageContainer.value!.offsetHeight })
})
}
</script>
<style lang="scss" scoped>
.message-container{
height: 80vh;
}
.message-block{
height: 100%;
overflow: auto;
}
.avatar-border {
border: 1px solid var(--v-gray-lighten1);
}
.msg-border-user {
border: 1px solid var(--v-gray-lighten1);
background-color: #fff;
}
.msg-border-service {
background-color: var(--v-gray-lighten1);
}
.face-agent-icon {
font-size: 30px;
}
</style>
Chat.Message-Input.vue
傳送訊息功能元件,此元件的作用就是把輸入框的訊息傳出,透過封裝在firebase.ts中的writeUserData方法將訊息送到firebase database。
增加類似於throttle的概念,每次按下送出訊息鈕後,就將sendController設為false(setTimeout一秒後才將改回true),button 會判斷如果sendController為false就disabled,讓使用者不能連續發送訊息。
發送完訊息自動focus回輸入框。
未來如果想要增加emoji、貼圖傳送…等功能,只要修改此元件即可。
<template>
<v-row class="send-message-block" no-gutters align="center">
<v-col class="d-flex align-center">
<v-textarea
ref="messageInput"
v-model="msg"
auto-grow
outlined
hide-details
dense
rows="1"
row-height="15"
:placeholder="'請輸入訊息'"
></v-textarea>
<v-btn
class="mx-2 elevation-0"
fab
dark
x-small
:color="msg === '' ? 'gray' : 'primary'"
:loading="loading"
:disabled="msg === '' || !sendController"
@click="send()">
<v-icon>mdi-send</v-icon>
</v-btn>
</v-col>
</v-row>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { writeUserData } from '@/firebase'
const messageInput = ref<HTMLInputElement>()
const msg = ref('')
const loading = ref(false)
let sendController = ref(true)
const props = defineProps({
username: {
type: String,
required: true
}
})
const send = () => {
try {
sendController.value = false
writeUserData(props.username, msg.value)
setTimeout(() => {
sendController.value = true
}, 1000)
msg.value = ''
} catch (error) {
console.log('error: ', error)
}
toFocusMessageInput()
}
const toFocusMessageInput = () => {
messageInput.value?.focus()
}
</script>
<style lang="scss" scoped>
.send-message-block{
height: 80px;
}
</style>
app.vue
最後在專案進入點import Chatroom這個component,就完成了簡單的即時聊天室了。
<template>
<v-app>
<v-main>
<Chatroom/>
</v-main>
</v-app>
</template>
<script lang="ts" setup>
import Chatroom from './components/Chatroom.vue'
</script>
總結:firebase讓前端可以不用學習後端就可以將資料存放在資料庫,在一些練習或小專案中就不用再特別去撰寫後端程式,可以節省不少時間,而firebase所提供的免費方案對個人使用者來說也非常足夠了。
然後這篇不斷提到只要修改此元件即可的概念,是最近一直在加強學習的。如何把程式寫得更好維護,一直是一門很大的學問。
盡可能把元件拆分成,只有一個修改的理由、改A元件不會動到B元件進而造成B元件有bug,以及把firebase封裝在firebase.ts中,未來如果有什麼更新、甚至是更換套件,我只要調整firebase.ts的程式,其他元件完全不用動到,可以大幅增加維護性甚至可讀性。
如有不足或缺漏部分歡迎一起討論。