简介:这是一份基于 Angular 11.2.6 构建的聊天应用前端项目源码,面向具备 TypeScript 与 Angular 基础、希望学习或二次开发即时通讯界面的开发者。项目采用标准 Angular CLI 工程结构,涵盖组件、指令、管道、服务、路由与模块等核心概念,适合作为课程设计、练手项目或企业聊天模块的起步脚手架。压缩包共 51 个文件,约 961KB,其中 23 个 ts 文件承载组件与业务逻辑,8 个 json 负责依赖与工程配置,另有 5 个 html 模板、5 个 css 样式及少量 js、svg、ico 等静态资源,目录划分清晰。已有 94 人学习关注。借助该源码,读者可快速运行开发服务器预览效果,理解 Angular 模块化组织方式,并在此基础上扩展消息收发、用户列表等聊天功能,同时参考单元测试与端到端测试配置,掌握项目构建与调试流程。
1. 从零搭一个 Angular 聊天应用:为什么我劝你先别急着写消息列表
很多人第一次做 Angular-chat-application,第一反应是打开组件生成器,先把消息列表、输入框、发送按钮铺出来,跑起来看着像那么回事,结果接 WebSocket 的时候发现状态全散在组件里,刷新一次全丢,多开一个标签页两边消息对不上。聊天应用和普通 CRUD 最大的区别在于:它是一个长连接驱动的、状态持续追加的、对时序敏感的前端系统。你在列表页那套「请求-渲染」的思路,放到聊天里会处处别扭。
这篇笔记面向的是已经会写 Angular 组件、懂基本 RxJS、但没做过实时聊天类应用的开发者。我会按「先定架构 → 再搭最小可跑 → 再接实时通道 → 再处理状态与持久化 → 最后讲坑」的顺序推一遍,每一步都给能直接抄的代码和参数说明。整套方案基于 Angular 独立组件 + 服务层 + RxJS Subject 做消息总线,不依赖任何第三方聊天 SDK,方便你看清每一层在干什么。读完你应该能自己判断:这个方向值不值得投入,以及投入的话边界在哪。
2. 先把架构定下来:聊天应用的三层状态该放哪
在动手写第一行组件代码之前,得先回答一个问题:消息、连接状态、当前会话,这三类东西分别归谁管。我见过太多项目把这三样全塞进一个 ChatComponent,前期爽,后期改一个已读逻辑要翻三百行。正确的做法是按生命周期分层,而不是按 UI 区域分层。
2.1 消息、连接、会话三层状态的职责划分
消息(Message)是追加型数据,只增不改,生命周期最长,应该放在一个单例服务里,用 BehaviorSubject 或 signal 持有数组。连接状态(ConnectionState)是瞬态数据,连接中、已连接、断开、重连中,它变化频繁但不需要持久化,放在同一个服务的另一个 Subject 里即可。当前会话(ActiveConversation)是路由级数据,切换会话时整个消息列表要换,它更适合放在路由参数 + 一个会话服务里,而不是全局。
这样分的直接好处是:断线重连时你只需要动连接状态,消息数组原封不动;切换会话时你只需要换会话 ID 再重新订阅,连接层完全不用感知。三层之间通过服务注入通信,组件只做订阅和渲染,不持有任何业务状态。这是整个方案能撑住后续扩展的地基。
2.2 用独立组件 + 服务层组织目录,而不是 NgModule 堆砌
Angular 现在默认走独立组件(standalone),聊天应用尤其适合,因为它的功能边界很清晰。我一般会这样组织:
src/app/ core/ chat-connection.service.ts # 连接与重连 message-store.service.ts # 消息状态 conversation.service.ts # 会话切换 features/ chat/ chat-window.component.ts # 消息列表容器 message-item.component.ts # 单条消息 message-input.component.ts # 输入框 shared/ models/ message.model.tscore放单例服务,用providedIn: 'root'注册;features放路由级组件;shared放纯类型和工具。这个划分的关键是:core里的服务不允许 importfeatures里的任何东西,依赖方向单向,避免循环引用。很多聊天应用后期编译报循环依赖,根源就是服务反向引用了组件。
2.3 消息模型先定死,别等接后端再改
消息模型是整个应用的地基,改一次要动的地方极多。我建议一开始就把字段定全,哪怕暂时用不上:
// shared/models/message.model.ts export type MessageStatus = 'sending' | 'sent' | 'failed' | 'received'; export interface ChatMessage { id: string; // 客户端生成的临时 ID,用于乐观更新 serverId?: string; // 服务端返回的真实 ID conversationId: string; // 所属会话 senderId: string; // 发送者 content: string; // 文本内容 createdAt: number; // 毫秒时间戳,客户端生成 status: MessageStatus; // 发送状态机 }这里有几个参数值得说清楚。id用客户端生成的 UUID,不要等服务端返回,否则乐观更新没法做——你点发送后消息要立刻上屏,此时还没有 serverId。status是个状态机,sending → sent或sending → failed,失败的消息允许重发,重发时复用同一个id,这样列表不会闪。createdAt用客户端时间戳,服务端时间只用来做排序校正,不要直接覆盖,否则用户本地时间和服务端差几秒会导致消息顺序跳动。
3. 搭最小可跑骨架:从消息列表到输入框的完整链路
架构定完,接下来把它跑起来。这一章的目标是:不接任何后端,用内存数据让消息能发、能显示、能滚动到底部。跑通这条链路后,接实时通道只是替换数据来源的事。
3.1 用 MessageStoreService 管消息数组与乐观更新
消息服务是核心,它要同时处理「本地追加」和「服务端确认」两件事。我用 BehaviorSubject 持有数组,对外暴露只读流:
// core/message-store.service.ts import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; import { ChatMessage } from '../shared/models/message.model'; @Injectable({ providedIn: 'root' }) export class MessageStoreService { private readonly store = new BehaviorSubject<ChatMessage[]>([]); readonly messages$: Observable<ChatMessage[]> = this.store.asObservable(); // 乐观追加:发送时立刻上屏 appendOptimistic(msg: ChatMessage): void { this.store.next([...this.store.value, msg]); } // 服务端确认:按客户端 id 找到并更新状态 confirm(clientId: string, serverId: string): void { const next = this.store.value.map(m => m.id === clientId ? { ...m, serverId, status: 'sent' as const } : m ); this.store.next(next); } // 标记失败,允许重发 markFailed(clientId: string): void { const next = this.store.value.map(m => m.id === clientId ? { ...m, status: 'failed' as const } : m ); this.store.next(next); } }逻辑说明:appendOptimistic用展开运算符生成新数组,而不是 push,因为 BehaviorSubject 靠引用变化触发变更检测,原地修改数组 Angular 不一定能感知。confirm和markFailed都按客户端id匹配,这就是为什么模型里id必须客户端生成。参数上,status用了as const断言,保证类型收窄到字面量,避免被推断成 string 导致模板里比较失败。
3.2 消息列表组件:变更检测策略与 trackBy 一起用
列表组件订阅messages$,渲染每一条。这里有两个性能参数必须设:
// features/chat/chat-window.component.ts import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MessageStoreService } from '../../core/message-store.service'; import { ChatMessage } from '../../shared/models/message.model'; @Component({ selector: 'app-chat-window', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` <div class="list"> <div *ngFor="let msg of messages$ | async; trackBy: trackById" [class.failed]="msg.status === 'failed'"> {{ msg.content }} </div> </div> ` }) export class ChatWindowComponent { private store = inject(MessageStoreService); messages$ = this.store.messages$; trackById(_: number, msg: ChatMessage): string { return msg.id; } }ChangeDetectionStrategy.OnPush配合async管道是标配:OnPush 让组件只在输入引用变化或事件触发时检查,async管道在流推新值时自动标记,两者配合能把长列表的检查次数压到最低。trackBy返回msg.id,让 Angular 复用 DOM 节点而不是整段重建——消息列表几百条时,没有 trackBy 每次追加都会重绘全部,滚动位置会跳。这两个参数是聊天列表的必调项,缺一个都会在消息量大时翻车。
3.3 输入框与发送:把回车、防抖、禁用态一次处理对
输入框看着简单,坑最多。回车发送、Shift+回车换行、空内容禁用、发送中防重复提交,这几件事要一次做对:
// features/chat/message-input.component.ts import { Component, output, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; @Component({ selector: 'app-message-input', standalone: true, imports: [FormsModule], template: ` <textarea [(ngModel)]="draft" (keydown.enter)="onEnter($event)" [disabled]="sending()" placeholder="输入消息,回车发送"> </textarea> ` }) export class MessageInputComponent { draft = ''; sending = signal(false); send = output<string>(); onEnter(event: Event): void { const kb = event as KeyboardEvent; if (kb.shiftKey) return; // Shift+回车换行 kb.preventDefault(); const text = this.draft.trim(); if (!text || this.sending()) return; this.send.emit(text); this.draft = ''; } }逻辑说明:keydown.enter是 Angular 的伪事件绑定,等价于监听 Enter 键。shiftKey判断放最前面,让换行走默认行为。sending用 signal 而不是普通布尔,是因为它在模板里被读取,signal 能让 OnPush 组件精确知道何时该刷新。output是 Angular 新的输出 API,替代@Output + EventEmitter,类型更严格。参数上,trim()后再判空,避免用户只敲空格也能发出去。
4. 接实时通道:WebSocket 封装、重连与心跳怎么设
内存版跑通后,把数据源换成真实连接。这一章讲 WebSocket 的封装方式,重点是重连和心跳这两个参数——它们决定了你的应用在网络抖动时是优雅恢复还是直接假死。
4.1 用 RxJS 把 WebSocket 包成可重订阅的流
原生 WebSocket 是命令式的,和 Angular 的响应式风格不搭。我用webSocket工厂函数配合retry操作符封装:
// core/chat-connection.service.ts import { Injectable } from '@angular/core'; import { webSocket, WebSocketSubject } from 'rxjs/webSocket'; import { retry, timer } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class ChatConnectionService { private socket$?: WebSocketSubject<any>; connect(url: string): WebSocketSubject<any> { if (!this.socket$ || this.socket$.closed) { this.socket$ = webSocket({ url, openObserver: { next: () => console.log('connected') }, closeObserver: { next: () => console.log('closed') } }); } return this.socket$; } // 带退避的重连 connectWithRetry(url: string) { return this.connect(url).pipe( retry({ count: 5, delay: (err, n) => timer(Math.min(1000 * 2 ** n, 30000)) }) ); } }逻辑说明:webSocket返回一个 Subject,既能next发送也能subscribe接收,天然适配 RxJS。retry的delay用指数退避,第 n 次重试等2^n秒,但用Math.min封顶 30 秒,避免无限增长。参数上,count: 5是重试上限,超过后流会 error,你需要在组件层捕获并提示用户手动重连。注意connect里判断closed再重建,否则重复调用会创建多个连接,这是最常见的翻车点。
4.2 心跳与超时:30 秒 ping 和 10 秒 pong 超时怎么配
长连接会被中间设备静默断开,客户端不主动探测就发现不了。心跳要客户端定时发 ping,服务端回 pong,超时未回就判定断线:
// 在连接建立后启动心跳 startHeartbeat(socket: WebSocketSubject<any>) { const PING_INTERVAL = 30000; // 30 秒发一次 const PONG_TIMEOUT = 10000; // 10 秒没回就重连 let pongTimer: any; const pingTimer = setInterval(() => { socket.next({ type: 'ping', ts: Date.now() }); pongTimer = setTimeout(() => { console.warn('pong timeout, reconnecting'); socket.error({ type: 'timeout' }); }, PONG_TIMEOUT); }, PING_INTERVAL); socket.subscribe(msg => { if (msg.type === 'pong') clearTimeout(pongTimer); }); return () => { clearInterval(pingTimer); clearTimeout(pongTimer); }; }参数说明:PING_INTERVAL设 30 秒是经验值,太短浪费流量,太长断线发现慢。PONG_TIMEOUT设 10 秒,要明显小于 ping 间隔,否则两次 ping 的 pong 会互相干扰。收到 pong 必须clearTimeout,否则超时定时器会误触发重连。这个心跳逻辑要放在连接服务里,不要放组件,组件销毁时连接不该断。
4.3 断线期间的消息队列:本地暂存与重连后补发
断线时用户还在打字,这些消息不能丢。做法是维护一个待发队列,连接恢复后按顺序补发:
private pending: ChatMessage[] = []; send(msg: ChatMessage) { if (this.isConnected()) { this.socket$.next({ type: 'message', payload: msg }); } else { this.pending.push(msg); // 暂存 } } // 重连成功后调用 flushPending() { while (this.pending.length) { const msg = this.pending.shift()!; this.socket$.next({ type: 'message', payload: msg }); } }逻辑说明:pending用数组而非 Set,因为消息有顺序,必须按发送顺序补发。flushPending在重连成功的回调里调用,用shift逐个取出保证 FIFO。参数上要注意:补发时消息的status还是sending,服务端确认后才变sent,所以补发逻辑和正常发送走同一条确认路径,不要另写一套。
5. 避坑与排查:聊天应用最容易翻车的 5 个地方
这一章是我踩过的坑,按「现象 → 原因 → 解决」写,每条都能帮你省几个小时。
5.1 消息重复上屏:乐观更新和推送回显撞车
现象:自己发的消息出现两条,一条是本地乐观追加的,一条是服务端广播回来的。原因:服务端把消息广播给所有客户端,包括发送者自己,而发送者本地已经追加过。解决:广播消息里带上客户端id,接收时先查本地是否已存在同id的消息,存在就只更新serverId和status,不追加。这个去重逻辑要放在消息入库的唯一入口,不要散在组件里。
5.2 切换会话后旧消息串台
现象:从会话 A 切到 B,B 的列表里混进了 A 的消息。原因:订阅messages$时没有按conversationId过滤,或者切换时旧订阅没取消。解决:消息流用switchMap按当前会话 ID 切换,switchMap会自动取消上一个内部订阅。同时消息数组按conversationId分组存储,渲染时只取当前组。
5.3 长列表滚动到底部失效
现象:新消息来了,列表不自动滚到底,或者滚动位置乱跳。原因:在ngOnChanges里滚,此时 DOM 还没渲染完;或者没设trackBy导致节点重建。解决:用AfterViewChecked配合一个「是否在底部」的标志位,只有用户本来就在底部时才自动滚,用户手动上翻看历史时不要强制拉回。trackBy必须设,前面讲过。
5.4 内存泄漏:组件销毁后订阅还在跑
现象:反复进出聊天页,内存持续增长,控制台报错说往已销毁组件写值。原因:手动subscribe后没unsubscribe。解决:优先用async管道,它自动取消订阅;必须手动订阅时用takeUntilDestroyed(Angular 16+)或takeUntil(destroy$)。连接服务的订阅生命周期和应用一致,不要放组件。
5.5 时间戳显示错乱:本地时间和服务端时间混用
现象:消息顺序偶尔颠倒,或者显示「刚刚」的消息实际是几分钟前。原因:排序用服务端时间,显示用本地时间,两者有偏差。解决:排序统一用服务端时间戳,显示统一用本地时间戳,模型里存两个字段。相对时间(几分钟前)用本地时间算,绝对顺序用服务端时间定,不要混。
6. 进阶:把消息状态机做成可测试的纯函数
前面所有逻辑里,最值得单独抽出来做测试的是消息状态流转。它不依赖 DOM、不依赖网络,是纯函数,测起来快且稳。我一般会把状态转换写成 reducer 风格:
// shared/models/message.reducer.ts import { ChatMessage, MessageStatus } from './message.model'; type Action = | { type: 'optimistic'; msg: ChatMessage } | { type: 'confirm'; clientId: string; serverId: string } | { type: 'fail'; clientId: string } | { type: 'retry'; clientId: string }; export function reduce(state: ChatMessage[], action: Action): ChatMessage[] { switch (action.type) { case 'optimistic': return [...state, action.msg]; case 'confirm': return state.map(m => m.id === action.clientId ? { ...m, serverId: action.serverId, status: 'sent' as MessageStatus } : m); case 'fail': return state.map(m => m.id === action.clientId ? { ...m, status: 'failed' as MessageStatus } : m); case 'retry': return state.map(m => m.id === action.clientId ? { ...m, status: 'sending' as MessageStatus } : m); default: return state; } }这个 reducer 的好处是:所有状态变化集中在一处,测试时直接喂输入断言输出,不用起组件、不用 mock 网络。参数上,retry把状态改回sending而不是直接sent,因为重发后还要等服务端确认,状态机不能跳步。测试用例我一般覆盖四条:乐观追加后长度加一、确认后状态变 sent、失败后状态变 failed、重试后状态回到 sending。
再补一个验证技巧:把 reducer 和真实服务对接时,用scan操作符把 action 流喂给 reducer,这样服务层只负责产生 action,状态计算全在纯函数里。调试时你只要打印 action 序列,就能完整复现任何一次状态异常,相当于给消息状态装了个黑匣子。这个习惯我从做第一个聊天应用起就保留到现在,后悔药没少吃,但至少状态问题再没靠猜。
希望帮到你。
本文还有配套的精品资源,点击获取