在现代数字时代,实时通信已成为各种应用的重要功能。从社交媒体平台到协作工具,实时交互通过提供即时更新和交互增强了用户体验。本文探讨了实时连接的需求,比较了不同的解决方案,并提供了使用 Angular 和 Firebase 建立实时聊天应用程序的分步指南。
实时连接的必要性
需要实时通信的场景:
- 社交媒体和消息应用程序:WhatsApp、Facebook Messenger 和 Slack 等平台依靠实时更新来确保用户能够即时通信。
- 协作工具:Google Docs 和 Trello 等应用程序提供实时协作功能,使多个用户能够无缝协同工作。
- 实时体育和新闻更新:实时更新对于向用户提供最新比分、新闻和警报至关重要。
- 客户支持:实时聊天系统可为用户提供即时协助和支持,从而增强客户服务。
比较实时通信解决方案
有几种技术可以实现实时通信,每种技术都有自己的优势和利弊:
- WebSockets:通过单个 TCP 连接提供全双工通信通道。非常适合需要低延迟和高频率信息交换的场景。
- 服务器发送事件(SSE):允许服务器向客户端推送更新。适用于需要实时更新而不需要双向通信的应用。
- 轮询:客户端定期向服务器请求更新。更容易实现,但效率较低,并可能导致较高的延迟。
- Firebase 实时数据库:实时更新数据的 NoSQL 云数据库。可简化实施,但对于复杂查询而言灵活性较差。
- Firebase Firestore:提供更高级的查询功能和实时数据同步。它具有可扩展性,适合复杂应用。
设置 Angular 和 Firebase 项目
步骤 1:设置 Angular 项目
首先,使用 Angular CLI 创建一个新的 Angular 项目:
ng new real-time-chat
cd real-time-chat
安装必要的 Firebase 软件包:
ng add @angular/fire
ng add ngxtension
步骤 2:配置 Firebase
在 Firebase 控制台中创建一个 Firebase 项目,然后获取 Firebase 配置对象。将此配置添加到 Angular 环境文件中:
// src/environments/environment.ts
export const environment = {
production: false,
firebase: {
apiKey: "your-api-key",
authDomain: "your-auth-domain",
projectId: "your-project-id",
storageBucket: "your-storage-bucket",
messagingSenderId: "your-messaging-sender-id",
appId: "your-app-id"
}
};
步骤 3:在 Angular 中设置 Firebase
修改 app.config.ts
,初始化 Firebase:
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
import { provideClientHydration } from '@angular/platform-browser';
import { initializeApp, provideFirebaseApp } from '@angular/fire/app';
import { getAuth, provideAuth } from '@angular/fire/auth';
import { getFirestore, provideFirestore } from '@angular/fire/firestore';
import { environment } from '../environments/environment.development';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withComponentInputBinding()),
provideClientHydration(),
provideFirebaseApp(() => initializeApp(environment.firebase)),
provideAuth(() => getAuth()),
provideFirestore(() => getFirestore()),
],
};
实现实时聊天功能
步骤 4:身份验证
创建一个身份验证存储,处理用户注册、登录和注销:
// src/app/stores/auth.store.ts
import { createInjectable } from 'ngxtension/create-injectable';
import { inject, signal } from '@angular/core';
import {
Auth,
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
signOut,
User,
} from '@angular/fire/auth';
import { Firestore, doc, setDoc, getDoc } from '@angular/fire/firestore';
export interface AppUser {
uid: string;
email: string;
displayName?: string;
}
export const useAuthStore = createInjectable(() => {
const auth = inject(Auth);
const firestore = inject(Firestore);
const currentUser = signal<AppUser | null>(null);
auth.onAuthStateChanged(async (user) => {
if (user) {
const appUser = await getUserFromFirestore(user.uid);
currentUser.set(appUser);
} else {
currentUser.set(null);
}
});
async function getUserFromFirestore(uid: string): Promise<AppUser | null> {
const userDoc = await getDoc(doc(firestore, `users/${uid}`));
if (userDoc.exists()) {
return userDoc.data() as AppUser;
}
return null;
}
async function createUserInFirestore(user: User): Promise<void> {
const newUser: AppUser = {
uid: user.uid,
email: user.email!,
displayName: user.displayName || undefined,
};
await setDoc(doc(firestore, `users/${user.uid}`), newUser);
}
async function signUp(email: string, password: string, displayName: string) {
try {
const userCredential = await createUserWithEmailAndPassword(
auth,
email,
password
);
await createUserInFirestore({ ...userCredential.user, displayName });
const appUser = await getUserFromFirestore(userCredential.user.uid);
currentUser.set(appUser);
} catch (error: any) {
console.error('Signup error:', error);
switch (error.code) {
case 'auth/email-already-in-use':
throw new Error(
'This email is already in use. Please try a different one.'
);
case 'auth/invalid-email':
throw new Error('The email address is not valid.');
case 'auth/weak-password':
throw new Error(
'The password is too weak. Please use a stronger password.'
);
default:
throw new Error(
'An error occurred during sign up. Please try again.'
);
}
}
}
async function login(email: string, password: string) {
try {
const userCredential = await signInWithEmailAndPassword(
auth,
email,
password
);
const appUser = await getUserFromFirestore(userCredential.user.uid);
currentUser.set(appUser);
} catch (error: any) {
console.error('Login error:', error);
switch (error.code) {
case 'auth/user-not-found':
case 'auth/wrong-password':
throw new Error('Invalid email or password. Please try again.');
case 'auth/invalid-email':
throw new Error('The email address is not valid.');
case 'auth/user-disabled':
throw new Error(
'This account has been disabled. Please contact support.'
);
default:
throw new Error('An error occurred during login. Please try again.');
}
}
}
async function logout() {
try {
await signOut(auth);
currentUser.set(null);
} catch (error) {
console.error('Logout error:', error);
throw new Error('An error occurred during logout. Please try again.');
}
}
return {
currentUser,
signUp,
login,
logout,
};
});
步骤 5:Firestore 聊天商店
创建聊天商店来管理与聊天相关的操作:
// src/app/stores/chat.store.ts
import { createInjectable } from 'ngxtension/create-injectable';
import { inject, signal, computed, effect } from '@angular/core';
import {
Firestore,
collection,
doc,
onSnapshot,
addDoc,
updateDoc,
Timestamp,
query,
where,
getDocs,
orderBy,
getDoc,
} from '@angular/fire/firestore';
import { useAuthStore, AppUser } from './auth.store';
export interface Chat {
id: string;
participants: string[];
participantNames?: string[];
lastMessage: string;
lastMessageTimestamp: Timestamp;
}
export interface Message {
id: string;
chatId: string;
senderId: string;
text: string;
timestamp: Timestamp;
}
export const useChatStore = createInjectable(() => {
const firestore = inject(Firestore);
const authStore = inject(useAuthStore);
const chats = signal<Chat[]>([]);
const currentChatId = signal<string | null>(null);
const messages = signal<Message[]>([]);
const currentChat = computed(() =>
chats().find((chat) => chat.id === currentChatId())
);
effect(() => {
const userId = authStore.currentUser()?.uid;
if (userId) {
listenToChats(userId);
}
});
async function getUserName(userId: string): Promise<string> {
const userDoc = await getDoc(doc(firestore, `users/${userId}`));
if (userDoc.exists()) {
const userData = userDoc.data() as AppUser;
return userData.displayName || userData.email || 'Unknown User';
}
return 'Unknown User';
}
async function fetchParticipantNames(
participantIds: string[]
): Promise<string[]> {
const names = await Promise.all(participantIds.map(getUserName));
return names;
}
function listenToChats(userId: string) {
const chatsRef = collection(firestore, 'chats');
const q = query(chatsRef, where('participants', 'array-contains', userId));
return onSnapshot(q, async (snapshot) => {
const updatedChats = await Promise.all(
snapshot.docs.map(async (doc) => {
const chatData = { id: doc.id, ...doc.data() } as Chat;
chatData.participantNames = await fetchParticipantNames(
chatData.participants
);
return chatData;
})
);
chats.set(updatedChats);
});
}
function listenToMessages(chatId: string) {
currentChatId.set(chatId);
const messagesRef = collection(firestore, `chats/${chatId}/messages`);
const q = query(messagesRef, orderBy('timestamp', 'asc'));
return onSnapshot(q, (snapshot) => {
const updatedMessages = snapshot.docs.map(
(doc) => ({ id: doc.id, ...doc.data() } as Message)
);
messages.set(updatedMessages);
});
}
async function sendMessage(chatId: string, senderId: string, text: string) {
const messagesRef = collection(firestore, `chats/${chatId}/messages`);
const newMessage = {
chatId,
senderId,
text,
timestamp: Timestamp.now(),
};
await addDoc(messagesRef, newMessage);
// Update the last message in the chat document
const chatRef = doc(firestore, `chats/${chatId}`);
await updateDoc(chatRef, {
lastMessage: text,
lastMessageTimestamp: Timestamp.now(),
});
}
async function createNewChat(participantEmail: string) {
const currentUser = authStore.currentUser();
if (!currentUser) throw new Error('You must be logged in to create a chat');
// Find the user with the given email
const usersRef = collection(firestore, 'users');
const q = query(usersRef, where('email', '==', participantEmail));
const querySnapshot = await getDocs(q);
if (querySnapshot.empty) {
throw new Error('User not found');
}
const participantUser = querySnapshot.docs[0].data() as AppUser;
// Check if a chat already exists between these users
const existingChatQuery = query(
collection(firestore, 'chats'),
where('participants', 'array-contains', currentUser.uid),
where('participants', 'array-contains', participantUser.uid)
);
const existingChatSnapshot = await getDocs(existingChatQuery);
if (!existingChatSnapshot.empty) {
// Chat already exists, return its ID
return existingChatSnapshot.docs[0].id;
}
// Create a new chat document
const chatsRef = collection(firestore, 'chats');
const newChat = await addDoc(chatsRef, {
participants: [currentUser.uid, participantUser.uid],
lastMessage: '',
lastMessageTimestamp: Timestamp.now(),
});
return newChat.id;
}
return {
chats,
currentChat,
messages,
listenToChats,
listenToMessages,
sendMessage,
createNewChat,
getUserName,
};
});
步骤 6:创建 UI 组件
1. 注册组件:
// src/app/components/signup/signup.component.ts
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterModule } from '@angular/router';
import { useAuthStore } from '../stores/auth.store';
@Component({
selector: 'app-signup',
standalone: true,
imports: [FormsModule, RouterModule],
template: `
<div
class="min-h-screen bg-gray-100 flex flex-col justify-center py-12 sm:px-6 lg:px-8"
>
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create a new account
</h2>
</div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
@if (errorMessage()) {
<div
class="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
role="alert"
>
<span class="block sm:inline">{{ errorMessage() }}</span>
</div>
}
<form (ngSubmit)="onSubmit()" class="space-y-6">
<div>
<label
for="email"
class="block text-sm font-medium text-gray-700"
>
Email address
</label>
<div class="mt-1">
<input
id="email"
name="email"
type="email"
required
[(ngModel)]="email"
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
<div>
<label
for="displayName"
class="block text-sm font-medium text-gray-700"
>
Display Name
</label>
<div class="mt-1">
<input
id="displayName"
name="displayName"
type="text"
required
[(ngModel)]="displayName"
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
<div>
<label
for="password"
class="block text-sm font-medium text-gray-700"
>
Password
</label>
<div class="mt-1">
<input
id="password"
name="password"
type="password"
required
[(ngModel)]="password"
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
<div>
<button
type="submit"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Sign up
</button>
</div>
</form>
<div class="mt-6">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500"> Or </span>
</div>
</div>
<div class="mt-6">
<a
routerLink="/login"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-indigo-600 bg-white hover:bg-gray-50"
>
Sign in to existing account
</a>
</div>
</div>
</div>
</div>
</div>
`,
})
export class SignupComponent {
email = '';
password = '';
displayName = '';
errorMessage = signal<string | null>(null);
private authStore = inject(useAuthStore);
private router = inject(Router);
async onSubmit() {
try {
console.log(this.displayName);
await this.authStore.signUp(this.email, this.password, this.displayName);
this.router.navigate(['/chat']);
// Navigate to chat list after successful signup
} catch (error) {
console.error('Signup error:', error);
if (error instanceof Error) {
this.errorMessage.set(error.message);
} else {
this.errorMessage.set(
'An unexpected error occurred. Please try again.'
);
}
}
}
}
登录组件:
// src/app/components/login/login.component.ts
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterModule } from '@angular/router';
import { useAuthStore } from '../stores/auth.store';
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule, RouterModule],
template: `
<div
class="min-h-screen bg-gray-100 flex flex-col justify-center py-12 sm:px-6 lg:px-8"
>
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
</div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
@if (errorMessage()) {
<div
class="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
role="alert"
>
<span class="block sm:inline">{{ errorMessage() }}</span>
</div>
}
<form (ngSubmit)="onSubmit()" class="space-y-6">
<div>
<label
for="email"
class="block text-sm font-medium text-gray-700"
>
Email address
</label>
<div class="mt-1">
<input
id="email"
name="email"
type="email"
required
[(ngModel)]="email"
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
<div>
<label
for="password"
class="block text-sm font-medium text-gray-700"
>
Password
</label>
<div class="mt-1">
<input
id="password"
name="password"
type="password"
required
[(ngModel)]="password"
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
<div>
<button
type="submit"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Sign in
</button>
</div>
</form>
<div class="mt-6">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500"> Or </span>
</div>
</div>
<div class="mt-6">
<a
routerLink="/signup"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-indigo-600 bg-white hover:bg-gray-50"
>
Create new account
</a>
</div>
</div>
</div>
</div>
</div>
`,
})
export class LoginComponent {
email = '';
password = '';
errorMessage = signal<string | null>(null);
private authStore = inject(useAuthStore);
private router = inject(Router);
async onSubmit() {
try {
await this.authStore.login(this.email, this.password);
this.router.navigate(['/chat']);
// Navigate to chat list after successful login
} catch (error) {
console.error('Login error:', error);
if (error instanceof Error) {
this.errorMessage.set(error.message);
} else {
this.errorMessage.set(
'An unexpected error occurred. Please try again.'
);
}
}
}
}
聊天列表组件:
// src/app/components/chat-list/chat-list.component.ts
import { Component, inject, OnInit } from '@angular/core';
import { Router, RouterModule } from '@angular/router';
import { Chat, useChatStore } from '../stores/chat.store';
import { useAuthStore } from '../stores/auth.store';
import { DatePipe } from '@angular/common';
@Component({
selector: 'app-chat-list',
standalone: true,
imports: [RouterModule, DatePipe],
template: `
<div class="min-h-screen bg-gray-100">
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900">Chats</h1>
<a
routerLink="/new-chat"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
New Chat
</a>
</div>
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<ul role="list" class="divide-y divide-gray-200">
@for (chat of chatStore.chats(); track chat.id) {
<li>
<a
[routerLink]="['/chat', chat.id]"
class="block hover:bg-gray-50"
>
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<p class="text-sm font-medium text-indigo-600 truncate">
{{ getChatName(chat) }}
</p>
<div class="ml-2 flex-shrink-0 flex">
<p
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"
>
{{
chat.lastMessageTimestamp.toDate() | date : 'short'
}}
</p>
</div>
</div>
<div class="mt-2 sm:flex sm:justify-between">
<div class="sm:flex">
<p class="flex items-center text-sm text-gray-500">
{{ chat.lastMessage }}
</p>
</div>
</div>
</div>
</a>
</li>
}
</ul>
</div>
@if (chatStore.chats().length === 0) {
<p class="mt-4 text-gray-500">
No chats available. Start a new chat!
</p>
}
<button
(click)="logout()"
class="mt-6 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Logout
</button>
</div>
</div>
</div>
`,
})
export class ChatListComponent implements OnInit {
chatStore = inject(useChatStore);
authStore = inject(useAuthStore);
router = inject(Router);
ngOnInit() {
const userId = this.authStore.currentUser()?.uid;
if (userId) {
this.chatStore.listenToChats(userId);
} else {
this.router.navigate(['/login']);
}
}
getChatName(chat: Chat): string {
if (chat.participantNames) {
return chat.participantNames
.filter((name) => name !== this.authStore.currentUser()?.displayName)
.join(', ');
}
return 'Loading...';
}
logout() {
this.authStore.logout();
this.router.navigate(['/login']);
}
}
聊天详细信息组件:
// src/app/components/new-chat/new-chat.component.ts
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { useChatStore } from '../stores/chat.store';
@Component({
selector: 'app-new-chat',
standalone: true,
imports: [FormsModule],
template: `
<div
class="min-h-screen bg-gray-100 flex flex-col justify-center py-12 sm:px-6 lg:px-8"
>
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Start a New Chat
</h2>
</div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
@if (errorMessage()) {
<div
class="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
role="alert"
>
<span class="block sm:inline">{{ errorMessage() }}</span>
</div>
}
<form (ngSubmit)="onSubmit()" class="space-y-6">
<div>
<label
for="email"
class="block text-sm font-medium text-gray-700"
>
Participant's Email
</label>
<div class="mt-1">
<input
id="email"
name="email"
type="email"
required
[(ngModel)]="participantEmail"
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
<div>
<button
type="submit"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Start Chat
</button>
</div>
</form>
</div>
</div>
</div>
`,
})
export class NewChatComponent {
participantEmail = '';
errorMessage = signal<string | null>(null);
private chatStore = inject(useChatStore);
private router = inject(Router);
async onSubmit() {
try {
const chatId = await this.chatStore.createNewChat(this.participantEmail);
this.router.navigate(['/chat', chatId]);
} catch (error) {
console.error('New chat error:', error);
if (error instanceof Error) {
this.errorMessage.set(error.message);
} else {
this.errorMessage.set(
'An unexpected error occurred. Please try again.'
);
}
}
}
}
结论
本文全面介绍了如何使用 Angular 和 Firebase 构建实时聊天应用程序。通过利用 Firebase 的实时功能和 Angular 的强大框架,开发人员可以为用户创建无缝的实时通信体验。所提供的代码示例和分步说明旨在促进开发过程并演示这些技术的实际应用。
无论您是在构建社交媒体平台、协作工具还是任何需要实时更新的应用程序,本指南都是将实时聊天功能集成到您的项目中的宝贵资源。您可以在 GitHub 上访问该项目的完整源代码:https://github.com/mollaie/real-time-chat
作者:Moe Mollaie
本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/im/50369.html