ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Laravel+Vue实时聊天室开发实战:WebSocket、Reverb与消息聚合排坑

Laravel+Vue实时聊天室开发实战:WebSocket、Reverb与消息聚合排坑 最近在给团队内部的一个协作工具加实时聊天功能。最开始我觉得这事不难无非就是前端轮询接口、有消息就刷新列表等真往下做才发现“实时”这两个字背后藏着一整套链路WebSocket连接、事件广播、频道鉴权、消息确认、断线重连还有历史消息的聚合查询。我最后用了 Laravel 11 加 Vue 3 把这套聊天室完整撸了一遍后端用 Laravel Reverb 跑 WebSocket前端用 Vue 3 组合式 API 管理会话状态。在做会话列表时还踩了 orderBy 和 groupBy 的坑折腾到半夜才搞清楚原理。这篇文章就把我从技术选型到最终上线的全过程、踩过的坑、以及面试里常常问到的相关知识点都写出来给最近也想用 Laravel Vue 做实时聊天室的朋友做个参考。1. 从需求到选型为什么是 Laravel 11 Vue 3 Reverb1.1 实时聊天室的真实需求拆解聊天室听起来简单但真正落到需求上至少要拆成这几块用户登录和在线状态、单聊和群聊、文本消息和多媒体消息、历史消息记录、未读数量、消息广播与接收、确收和重连机制。每一块单独做都不算难难的是组合在一起不能让整体体验“卡顿”或“丢消息”。我当时的核心预期是消息从 A 发出到 B 收到网络正常情况下延迟不超过 1 秒断网后能自动重连用户重新登录后还能看到离线期间漏掉的消息。这些要求基本决定了不能用传统 HTTP 轮询因为轮询的延迟、服务器压力、以及消息顺序都不好控制。所以实时通道必须是 WebSocket或者至少要有一个 WebSocket 风格的推送通道。在技术选型上我也纠结过要不要上 Node.js毕竟的 Socket.IO 在这类场景里太成熟了。但最终我更倾向在现有团队体系内解决因为团队后端主力是 PHPLaravel 已经有一套现成的用户系统、权限体系和数据库迁移工具再引入 Node 等于多维护一套服务。而且 Laravel 官方在 2024 年推出了 Reverb专门把 WebSocket 服务下沉到 PHP 进程中可以让 Laravel 团队用一套语言把聊天室端到端打通。这个组合在当时是“成本最低、链路最短”的选项。1.2 为什么用 Vue 3 而不是其他前端框架聊天室前端是一个非常典型的“响应式状态”场景消息列表要实时追加、未读数要实时变化、会话列表要按最后一条消息重新排序。Vue 3 的组合式 API 恰恰适合这种高频状态交互因为你可以把某个会话的消息、连接状态、加载状态都封装在一个 composable 里组件之间只通过 Pinia 共享数据不再用事件总线来回传参数。很多人初学 Vue 时会拿 jQuery 的思路去操作 DOM写完发现数据变了页面不更新其实核心没搞懂Vue 的响应式系统会自动追踪 state 的变化然后精准更新视图。聊天室里的消息列表本质就是一个响应式数组每次 WebSocket 推过来一条新消息直接 push 进数组列表就会自动滚动更新。这也是我在项目里反复和前端同事强调的“js 深入浅出 vue”的核心点数据驱动视图而不是手动操作 DOM。1.3 WebSocket 服务选型Pusher、Soketi、Reverb 怎么选在确定用 Laravel 后实时推送仍然有几个选择方案接入成本性能/运维适用场景Pusher低配置简单无需自运维但国外服务网络延迟不稳定免费额度有限追求快速开发对数据出境和成本不敏感Soketi中兼容 Pusher 协议自托管Node 进程内存占用小需要自己监控已有 Node 运维经验想用 Pusher 协议但不想被云服务绑定Laravel Reverb低官方自带不需要额外运行时直接作为 Laravel 进程运行配置清晰使用 Laravel 技术栈希望尽量少引入新组件我最终选了 Laravel Reverb原因很简单它不需要额外部署 Node 服务也不用注册外部 SaaS只需要php artisan reverb:start就能跑起来和队列、事件系统天然配合。消息广播虽然是异步事件但事件触发、频道鉴权、用户关联都可以复用 Laravel 生态的现成机制省去很多“胶水代码”。2. 后端消息链路频道权限、事件广播与消息落库2.1 数据表设计conversations、participants、messages聊天室的数据模型不需要太复杂但一定要有“会话”和“消息”两层概念。一开始我只建了users和messages两张表结果发现用户想找和某个人聊过的历史记录时根本没有一个聚合维度去查。后来补上了conversations和participants才算把聊天模型搭完整。我用 Laravel 迁移建了这三张核心表conversations会话表字段包括id、typeprivate 或 group、name、created_at、updated_at。群聊时 name 是群名私聊时 name 可以为空直接用参与者关系推导。participants会话成员表字段包括id、conversation_id、user_id、last_read_at。last_read_at用来做未读计数和阅读状态非常关键。messages消息表字段包括id、conversation_id、user_id、typetext、image、video 等、content、metadataJSON 字段存文件地址、缩略图、视频时长等、created_at。索引设计上我在messages表的conversation_id created_at建了联合索引因为历史消息翻页查询和高频的“取每个会话最新一条”都依赖这个索引。participants表的user_id conversation_id也要建联合索引因为频道鉴权时我会频繁用用户 id 查关联会话。2.2 用 Laravel 事件和私有频道实现实时推送消息发送的完整流程是用户提交内容 → 写入messages表 → 触发事件 → 事件通过 Reverb 广播给该会话所有在线用户。在 Laravel 里这一步可以通过事件类完成我定义了一个MessageSent事件实现ShouldBroadcastNow接口让它立即广播而不是塞进队列等待执行。有一点必须注意聊天室消息千万不能放到公共频道否则任何用户只要知道了频道名就能订阅并偷看别人的聊天内容。我用的是私有频道private-conversation.{id}在routes/channels.php里写鉴权回调Broadcast::channel(conversation.{conversationId}, function ($user, $conversationId) { return $user-conversations() -where(conversations.id, $conversationId) -exists(); });当用户尝试订阅私有频道时Laravel 会调用这个回调来验证当前用户是否有权访问。只有返回 true浏览器端的 Echo 才能建立订阅关系。这个机制是所有 Laravel 广播应用的底线千万别省。在事件类里我这样指定广播频道class MessageSent implements ShouldBroadcastNow { public function __construct( public Message $message, ) {} public function broadcastOn(): array { return [ new PrivateChannel(conversation. . $this-message-conversation_id), ]; } public function broadcastWith(): array { return [ id $this-message-id, user $this-message-user-only([id, name, avatar]), content $this-message-content, type $this-message-type, created_at $this-message-created_at-toIso8601String(), ]; } }broadcastWith定义了推送给前端的具体字段刻意不带user_id对应的全部模型减少广播包大小。前端订阅频道后通过.listen(.message.sent, callback)监听注意事件名前面的点号这是 Laravel 事件广播的命名约定表示事件默认在App\Events命名空间下。2.3 为什么要走队列不要把广播和数据库写入混在一起最早我图省事在控制器里写完消息入库后直接event(new MessageSent($message))并且事件没有实现ShouldBroadcastNow而是默认的ShouldQueue。这样事件的处理会被 Laravel 投递到队列 worker 执行避免阻塞请求响应。但很多初学者会把“事件”和“队列”混为一谈其实事件只是触发了一个动作是否异步取决于事件类是否实现了ShouldQueue。我最终方案是控制器只负责校验和落库然后返回 JSON 给发送方消息广播和推送通知统一交给MessageSent事件事件实现ShouldQueue由 Redis 队列驱动消费。这样做的好处是接口响应很快即使 Reverb 短暂抖动消息事件也会在队列里重试不会因为 WebSocket 推送失败就丢失消息。如果你在本地调试记得同时跑两个进程php artisan queue:work和php artisan reverb:start。很多朋友以为只要启动了serve就能收到实时推送结果队列没跑、 Reverb 也没跑前端自然一直收不到消息。这个坑我在项目里帮同事排过不少次。3. 会话列表查询orderBy groupBy 取每个会话最新一条消息的完整排坑3.1 需求场景和我最初的“想当然”写法聊天室界面左边通常是一个会话列表每一个会话要显示“最后一条消息内容”和“最后一条消息时间”。我第一次实现时脑子里蹦出来的 SQL 是这样的SELECT * FROM messages GROUP BY conversation_id ORDER BY created_at DESC当时我想的是先按会话分组再在每个组里取时间最新的一条最后按时间倒序排列。但这条 SQL 在 MySQL 里直接报错错误信息是SQLSTATE[42000]: Syntax error or access violation: 1055 Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column ...这个错误的原因很明确MySQL 在ONLY_FULL_GROUP_BY模式下要求SELECT里的每个非聚合列都必须出现在GROUP BY子句中。现在我SELECT *自然违背了这个约束。第一反应是在数据库配置里把sql_mode里的ONLY_FULL_GROUP_BY去掉但排查后我发现就算去掉结果也是错的。3.2 定位过程为什么关闭 ONLY_FULL_GROUP_BY 后结果仍然不对我把本机 MySQL 的sql_mode临时改掉后那条 SQL 能跑了但会话列表里的“最后一条消息”很多并不是最新那条而是这个会话最早插入的一条时好时坏。在数据量少的时候我用几条消息做了测试依然复现这个问题。这时候我意识到问题出在 SQL 执行顺序上。数据库的顺序是FROM→WHERE→GROUP BY→HAVING→SELECT→ORDER BY。也就是说GROUP BY在ORDER BY之前执行分组时并不会考虑“组内按时间排序”这个条件它只会按索引顺序或物理存储顺序随便选一行。所以GROUP BY之后ORDER BY created_at DESC排序的根本不是组内消息而是“分组后选出来的那一行”再排序这样的结果当然是错的。后面我用一个小表验证A 会话有三条消息插入顺序分别是 id1上午、id2中午、id3晚上。执行GROUP BY conversation_id ORDER BY created_at DESC时MySQL 往往取到 id1因为分组发生在排序前。即使偶而取到 id3也只是存储顺序碰巧对了逻辑上完全不可靠。3.3 正确写法和 Laravel 查询构造器实现要取“每个会话最新一条消息”正确的思路是先找到每个 session 的最大消息标识再回到原表取整行。最大 id 比最大 created_at 更可靠因为自增主键天然单调递增不会出现同一秒多条消息导致的时间平局。SQL 可以写成SELECT m.* FROM messages m INNER JOIN ( SELECT conversation_id, MAX(id) AS max_id FROM messages GROUP BY conversation_id ) latest ON latest.conversation_id m.conversation_id AND latest.max_id m.id ORDER BY m.created_at DESC;Laravel 里我用查询构造器实现$latestMessageIds DB::table(messages) -select(conversation_id, DB::raw(MAX(id) as max_id)) -groupBy(conversation_id) -toSql(); $conversations Conversation::query() -leftJoinSub($latestMessageIds, latest, function ($join) { $join-on(conversations.id, , latest.conversation_id); }) -leftJoin(messages, function ($join) { $join-on(messages.conversation_id, , conversations.id) -on(messages.id, , latest.max_id); }) -orderBy(messages.created_at, desc) -get();这里用leftJoinSub把子查询作为一个临时表再连接messages表取整行。实测下来只要子查询里MAX(id)对应的联合索引(conversation_id, id)命中性能完全可控。相比依赖ONLY_FULL_GROUP_BY的“碰运气”写法这个方案在逻辑上是自洽的先确定每个组最新消息的 id再通过唯一主键回表取数据不会有歧义。3.4 数据量大时的优化思路当消息表累积到百万级上面的写法仍然会全表扫描messages的索引因为子查询需要遍历每个会话的所有消息。我在生产环境里加了两个优化实测效果明显只聚合“最近 30 天”的消息超过 30 天不再出现在侧边栏会话列表里用户点击进入具体会话后按需拉取更早历史。这就把子查询扫描范围大幅缩小。在 Redis 里维护每个会话的last_message_id发送新消息时同步更新这个值。会话列表直接读 Redis不再每次实时跑 SQL。这个方案在数据量更大时几乎是必须的因为它把“算最新消息”从查询变成了写入时的维护动作实时聊天场景里写入频率远低于查询频率收益很高。我当时排这个坑还有一个心得不要迷信网上随手搜来的GROUP BY小技巧尤其是涉及“取最新一条”的场景一定要回到执行顺序去理解。命令行里多造几条数据验证比在框架层反复调整 SQL 快得多。4. Vue 3 端搭建环境配置、路由与 Echo 连接4.1 Vue 安装及环境配置Vite 初始化项目前端部分我用 Vite 搭建的 Vue 3 项目初始化命令很简单npm create vitelatest chat-front -- --template vue cd chat-front npm install npm run dev如果本机 Node 版本太低Vite 会直接报错建议 Node 18。然后安装 Laravel Echo、PusherReverb 兼容 Pusher 协议前端仍用它做 Socket 连接、Axios以及后面会用到的 Pinia 和 Vue Routernpm install laravel-echo pusher-js axios pinia vue-router4 hls.js在.env文件里配置 Vite 连接 Reverb 的变量VITE_PUSHER_APP_KEYmy-app-key VITE_PUSHER_HOST127.0.0.1 VITE_PUSHER_PORT8080 VITE_PUSHER_SCHEMEhttp VITE_PUSHER_CLUSTERmt1注意Reverb 启动时的端口默认是 8080和你后端 Laravel 的 8000 端口不是一个。前端连的是8080不是8000。很多新手会把这两个端口搞混导致 Echo 始终连不上 WebSocket。4.2 Vue Router 配置和登录守卫聊天室需要登录态判断。我用 Vue Router 的全局前置守卫做访问控制未登录用户跳转到/loginimport { createRouter, createWebHistory } from vue-router import { useAuthStore } from ../stores/auth const router createRouter({ history: createWebHistory(), routes: [ { path: /login, component: () import(../views/LoginView.vue) }, { path: /chat, component: () import(../views/ChatView.vue), meta: { requiresAuth: true } }, ], }) router.beforeEach((to) { const authStore useAuthStore() if (to.meta.requiresAuth !authStore.token) { return { name: login } } })登录成功后我把用户 token 存到 Pinia 里同时带Authorization: Bearer {token}的全局 Axios 拦截器也设置好。这样后续 Laravel Echo 在请求私有频道鉴权时才能正确通过auth中间件验证用户身份。4.3 用 Laravel Echo 订阅私有频道在main.js里初始化 Echoimport Echo from laravel-echo import Pusher from pusher-js window.Pusher Pusher window.Echo new Echo({ broadcaster: pusher, key: import.meta.env.VITE_PUSHER_APP_KEY, wsHost: import.meta.env.VITE_PUSHER_HOST, wsPort: import.meta.env.VITE_PUSHER_PORT, wssPort: import.meta.env.VITE_PUSHER_PORT, forceTLS: false, enabledTransports: [ws, wss], authEndpoint: http://127.0.0.1:8000/broadcasting/auth, auth: { headers: { Authorization: Bearer ${localStorage.getItem(token)}, }, }, })登录成功后进入某个会话页就订阅对应私有频道Echo.private(conversation.${conversationId}) .listen(.message.sent, (payload) { messageStore.addMessage(payload) conversationStore.updateLastMessage(conversationId, payload) })这里有个容易踩的细节.listen(.message.sent)前面那个点不能漏也可以写成.listen(MessageSent, ...)但需要注意 Laravel 事件广播命名空间。我统一用点号前缀避免事件类名写错后前端收不到。离开页面时记得调用Echo.leaveChannel(conversation. conversationId)不然会一直占着连接资源。4.4 消息状态管理和组件拆分聊天室的状态分散在多个组件里我用 Pinia 统一管理。创建一个conversationstoreconversations会话列表数组messagesByConversation以conversation_id为 key 的消息列表映射unreadCount未读数量connectionStatus当前 WebSocket 的连接状态组件拆分也比较常规ConversationList.vue负责左侧列表MessageList.vue负责右侧聊天窗口和高频更新MessageInput.vue负责发消息和上传文件VideoMessage.vue负责视频播放。这些组件只依赖 store 提供数据通过 composable 函数把逻辑抽出来比如useMessageReceiver(conversationId)封装订阅和取消订阅的逻辑不会在整个页面里堆满一堆onMounted的逻辑代码可读性会好很多。5. 聊天室里的视频消息Vue 播放 m3u8 的实现细节5.1 为什么聊天室里会出现 m3u8做聊天室的过程中产品提了个需求要支持发送视频消息。一开始我以为直接给一个 mp4 链接就行但后来发现服务端上传视频后为了能在各种网速下流畅播放会做转码切片输出成 HLS 流也就是 m3u8 索引文件加一堆 ts 分片。这样用户打开视频消息时播放器可以按需加载分片体验比一次性加载完整 mp4 好很多。问题来了原生video标签在 Safari 里能直接播 m3u8但在 Chrome、Firefox 和大部分安卓浏览器里不支持。要让 Vue 页面统一播放必须引入一个能解析 m3u8 并加载分片的 JS 播放库。5.2 在 Vue 组件中使用 hls.js 播放 m3u8我在项目里选择了 hls.js相比 video.js它体积更小、没有一堆样式依赖而且 API 非常直接。安装后在VideoMessage.vue组件里这样写template video refvideoRef controls playsinline classmessage-video/video /template script setup import { ref, onMounted, onUnmounted } from vue import Hls from hls.js const props defineProps({ src: { type: String, required: true }, }) const videoRef ref(null) let hls null onMounted(() { const video videoRef.value if (Hls.isSupported()) { hls new Hls({ enableWorker: true }) hls.loadSource(props.src) hls.attachMedia(video) } else if (video.canPlayType(application/vnd.apple.mpegurl)) { // Safari 原生支持 HLS直接设置源 video.src props.src } }) onUnmounted(() { if (hls) { hls.destroy() hls null } }) /script这里最容易被忽略的是hls.js 在加载 ts 分片时会执行跨域请求如果视频文件放在 CDN 上CDN 必须返回正确的Access-Control-Allow-Origin头否则浏览器控制台会报 CORS 错误视频一直黑屏。我在本地测试时用的是 Laravel 的公共磁盘没有跨域问题一上生产换到对象存储就踩了个透加上 CORS 配置后立刻正常。5.3 播放列表的优化懒加载和自动销毁聊天记录里可能同时出现很多条视频消息如果每条都立刻创建 hls.js 实例去解析 m3u8页面会卡顿明显。我的做法是默认只渲染一个封面图或“点击播放”按钮用户点击后才动态创建 video 组件和 hls 实例播放完毕后离开会话时销毁资源。使用v-show控制播放器显示还不够真正要养成的习惯是在onUnmounted里调用hls.destroy()。如果不释放哪怕组件已经从 DOM 里移除hls.js 仍然会持续申请内存、保持网络请求页面久了会越占越大。这个在长列表聊天室里非常致命我后来在做前端性能排查时就是靠 Chrome 的 Performance 面板发现大量未销毁的 hls 实例阻塞了主线程。如果你只是想在项目里快速跑通 m3u8 播放hls.js 绝对够用。但如果产品需要更多样式、控制条、清晰度切换可以考虑 video.js不过引入的包体量和自定义配置会更重自行权衡。6. 上线部署与稳定性反代、重连、面试考点6.1 WebSocket 反向代理和 WSS本地开发时前端直接访问http://127.0.0.1:8080没问题但上线后域名是 HTTPS浏览器不会允许从 HTTPS 页面发起明文 WebSocket 连接所以必须把wss://反向代理到 Reverb 的8080端口。我在 Nginx 里加了这样一段配置location /app/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }Upgrade和Connection两个头是 WebSocket 握手的必备条件少了任何一个客户端发起的升级请求都会失败浏览器会一直显示连接断开。很多人部署时只把 HTTP 请求代理过去了忘了 WebSocket 也要单独配置结果页面能打开但就是收不到实时消息。前端 Echo 里的变量也要改成线上环境VITE_PUSHER_SCHEMEhttps VITE_PUSHER_HOSTchat.example.com VITE_PUSHER_PORT443端口变成 443因为 HTTPS 默认走 443Nginx 才能把wss请求正确转发到内部 8080。6.2 排查“实时消息收不到”的完整链路上线后我收到过几次反馈说“消息发出去但对方收不到”。这种问题不能只盯前端我建议按照下面这条链路逐层排查打开浏览器开发者工具 → Network → WS找到对应的 WebSocket 连接看连接状态是 pending 还是 closed。查看 WS 的初始握手请求确认状态码是 200 还是 401/403。如果是 401多半是频道鉴权失败需要检查Authorization头有没有带 token以及routes/channels.php里的回调逻辑。确认 Nginx 日志中是否出现upgrade关键字如果没有说明 WebSocket 代理配置没生效。检查 Reverb 进程日志看是否接受了该客户端的连接。确认 Laravel 队列是否在运行因为MessageSent事件默认走队列队列 worker 挂掉时消息事件不会广播。最后看前端有没有正确订阅频道比如订阅前有没有拿到conversationId事件名是否带了点号前缀。我之前踩过最隐蔽的一个坑是在broadcasting/auth路由上我一度没有加auth:sanctum中间件导致 Echo 订阅私有频道时Laravel 识别不了当前用户返回 401。可问题表现却是“前端连接正常、但消息不到”因为 WebSocket 连接本身成功只是私有频道授权失败了。后来我直接访问鉴权接口返回 401才发现是中间件漏配。6.3 这些实战经验对应的 Laravel/Vue 面试题做完这个聊天室很多朋友问我它能对应哪些面试题。我梳理了一下确实能串起不少经典问题Vue 面试题v-for里为什么必须绑定 key在会话列表按最后消息重新排序时key 能帮 Vue 精确复用 DOM不绑定 key 会导致列表元素错乱。computed和watch的区别是什么我在消息列表里用computed从 store 里派生排序后的消息用watch监听连接状态变化来提示用户重连。nextTick有什么用新消息到达后滚到底部的操作必须放在nextTick里否则 DOM 还没更新滚动位置不对。组件通信方式有哪些我在项目里用 Pinia 管理全局状态Echo 事件作为外部数据源组件之间不直接传事件思路很清晰。Laravel 面试题广播和队列的关系是什么事件类实现ShouldQueue后广播事件被投递到 Redis 队列队列 worker 消费事件并通过 Reverb 推送这是解耦的关键。如何解决 ORM 查询 N1 问题拉取会话列表时我提前with([latestMessage.user, participants.user])避免循环里查询数据库。私有频道和公共频道的区别私有频道会在订阅时触发Broadcast::channel回调校验用户权限聊天室必须用私有频道。把这些项目里的真实实现和面试题一一对应起来比背八股文可靠得多。我在面试候选人时也更愿意听对方说“我在这个场景里选了这个方案原因是……”而不是单纯罗列 API 名称。聊到这就基本把整个实时聊天室的实现思路和坑点过了一遍。最后分享一个我自己后来一直沿用的细节消息列表不要一次加载全部历史采用“进入会话先加载最近 50 条上滑触底时再拉更早的数据”的方式配合created_at游标分页既保证打开速度快又不会让 DOM 节点爆炸。真正做起来你会发现聊天室的技术难点并不在某个单一功能上而在于把消息、会话、实时连接、前端状态这一整条链路串起来后还能保持稳定和流畅。希望这篇实战记录能帮你少走点弯路。
返回列表