前情提要
上一节我们完整的梳理了整个通信过程,接下来我们需要来看前端的处理过程。
Laravel Echo
Laravel Echo 是一个 JavaScript 库,它让您可以轻松订阅频道并监听服务器端广播驱动程序广播的事件。您可以通过 NPM 包管理器安装 Echo。在此示例中,我们还将安装 pusher-js 包,因为 Reverb 使用 Pusher 协议进行 WebSocket 订阅、频道和消息
安装
npm install --save-dev laravel-echo pusher-js
yarn add --save-dev laravel-echo pusher-js
安装 Echo 后,您就可以在应用程序的 JavaScript 中创建一个新的 Echo 实例。执行此操作的最佳位置是 Laravel 框架附带的 resources/js/bootstrap.js 文件的底部。默认情况下,此文件中已包含一个示例 Echo 配置 - 您只需取消注释并将广播器配置选项更新为 reverb:
import Echo from 'laravel-echo';import Pusher from 'pusher-js';
window.Pusher = Pusher;window.Echo = new Echo({broadcaster: 'reverb',key: import.meta.env.VITE_REVERB_APP_KEY,wsHost: import.meta.env.VITE_REVERB_HOST,wsPort: import.meta.env.VITE_REVERB_PORT,wssPort: import.meta.env.VITE_REVERB_PORT,forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',enabledTransports: ['ws', 'wss'],
});
监听事件
安装并实例化 Laravel Echo 后,您就可以开始监听从 Laravel 应用程序广播的事件了。首先,使用 channel 方法检索通道实例,然后调用 listen 方法来监听指定的事件::
Echo.channel(`orders.${this.order.id}`).listen('OrderShipmentStatusUpdated', (e) => {console.log(e.order.name);});
如果您想在私有频道上监听事件,请改用私有方法。您可以继续链接调用 listen 方法以在单个频道上监听多个事件:
Echo.private(`orders.${this.order.id}`).listen(/* ... */).listen(/* ... */).listen(/* ... */);
停止监听事件
如果您想在不离开频道的情况下停止监听给定事件,您可以使用 stopListening 方法:
Echo.private(`orders.${this.order.id}`).stopListening('OrderShipmentStatusUpdated')
离开频道
要离开频道,您可以调用 Echo 实例上的 leaveChannel 方法:
Echo.leaveChannel(`orders.${this.order.id}`);
如果你想离开一个频道以及其关联的私人频道和在线频道,你可以调用 leave 方法:
Echo.leave(`orders.${this.order.id}`);
命名空间
您可能已经注意到,在上面的示例中,我们没有为事件类指定完整的 App\Events 命名空间。这是因为 Echo 会自动假定事件位于 App\Events 命名空间中。但是,您可以在实例化 Echo 时通过传递命名空间配置选项来配置根命名空间:
window.Echo = new Echo({broadcaster: 'pusher',// ...namespace: 'App.Other.Namespace'
});
或者,您可以在使用 Echo 订阅事件类时为其添加前缀 .。这样您就可以始终指定完全限定的类名:
Echo.channel('orders').listen('.Namespace\\Event\\Class', (e) => {// ...});
Presence Channels
在线频道以私人频道的安全性为基础,同时还提供了了解频道订阅者的功能。这样可以轻松构建强大的协作应用程序功能,例如当其他用户正在查看同一页面时通知用户或列出聊天室的成员。
授权状态通道
所有在线频道也是私有频道;因此,用户必须获得授权才能访问它们。但是,在为在线频道定义授权回调时,如果用户被授权加入频道,则不会返回 true。相反,您应该返回有关用户的数据数组。
授权回调返回的数据将提供给 JavaScript 应用程序中的在线频道事件侦听器。如果用户未被授权加入在线频道,则应该返回 false 或 null:
use App\Models\User;Broadcast::channel('chat.{roomId}', function (User $user, int $roomId) {if ($user->canJoinRoom($roomId)) {return ['id' => $user->id, 'name' => $user->name];}
});
连接在线频道
要加入状态频道,您可以使用 Echo 的 join 方法。join 方法将返回 PresenceChannel 实现,它除了公开 listen 方法外,还允许您订阅当前状态、加入和离开事件.
Echo.join(`chat.${roomId}`).here((users) => {// ...}).joining((user) => {console.log(user.name);}).leaving((user) => {console.log(user.name);}).error((error) => {console.error(error);});
成功加入频道后,将立即执行此处的回调,并将收到一个数组,其中包含当前订阅该频道的所有其他用户的用户信息。当新用户加入频道时,将执行加入方法,而当用户离开频道时,将执行离开方法。当身份验证端点返回除 200 以外的 HTTP 状态代码或解析返回的 JSON 时出现问题时,将执行错误方法。
向 Presence 频道广播
Presence 频道可以像公共或私人频道一样接收事件。使用聊天室的示例,我们可能希望将 NewMessage 事件广播到房间的 Presence 频道。为此,我们将从事件的 broadcastOn 方法返回 PresenceChannel 的一个实例:
/*** Get the channels the event should broadcast on.** @return array<int, \Illuminate\Broadcasting\Channel>*/
public function broadcastOn(): array
{return [new PresenceChannel('chat.'.$this->message->room_id),];
}
与其他事件一样,您可以使用广播助手和 toOthers 方法来排除当前用户接收广播:
broadcast(new NewMessage($message));broadcast(new NewMessage($message))->toOthers();
与其他类型的事件一样,您可以使用 Echo 的 listen 方法监听发送到存在通道的事件:
Echo.join(`chat.${roomId}`).here(/* ... */).joining(/* ... */).leaving(/* ... */).listen('NewMessage', (e) => {// ...});
客户端事件
有时您可能希望将事件广播给其他连接的客户端,而无需访问您的 Laravel 应用程序。这对于“输入”通知等情况特别有用,在这种情况下,您希望提醒应用程序的用户另一个用户正在给定的屏幕上输入消息。
要广播客户端事件,您可以使用 Echo 的 whisper 方法:
Echo.private(`chat.${roomId}`).whisper('typing', {name: this.user.name});
要监听客户端事件,你可以使用 listenForWhisper 方法:
Echo.private(`chat.${roomId}`).listenForWhisper('typing', (e) => {console.log(e.name);});
通知
通过将事件广播与通知配对,您的 JavaScript 应用程序可以在新通知发生时接收它们,而无需刷新页面。在开始之前,请务必阅读有关使用广播通知渠道的文档。
配置通知以使用广播渠道后,您可以使用 Echo 的通知方法监听广播事件。请记住,渠道名称应与接收通知的实体的类名匹配:
Echo.private(`App.Models.User.${userId}`).notification((notification) => {console.log(notification.type);});
在此示例中,通过广播渠道发送到 App\Models\User 实例的所有通知都将由回调接收。App.Models.User.{id} 渠道的渠道授权回调包含在应用程序的 routes/channels.php 文件中。
以上内容翻译自laravel官方文档。
提供一个简单的页面案例
这个页面实现了广播事件监听、客户端发送,客户端监听等功能
<script setup>
import {Head, usePage} from '@inertiajs/vue3';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import {ref, reactive, onMounted, onUnmounted, computed} from 'vue';
import axios from "axios";
import {useNotification} from "@kyvg/vue3-notification";
import {layer} from "vue3-layer";const {notify} = useNotification()
const user = usePage().props.auth.user;const showLoading = () => {layer.load();
};// 测试发送的对象
const testReceiverId = user.id == 1 ? 2 : 1;const canSend = ref(true)
const messages = ref([])
const messageBoxRef = ref(null);
const newMessage = ref('')
let coupon;defineProps({canLogin: {type: Boolean,},canRegister: {type: Boolean,},laravelVersion: {type: String,required: true,},phpVersion: {type: String,required: true,},
});// 格式化消息时间
const formatMessageTime = (timestamp) => {const date = new Date(timestamp);const hours = date.getHours().toString().padStart(2, '0');const minutes = date.getMinutes().toString().padStart(2, '0');return `${hours}:${minutes}`;
};// 当前日期
const currentDate = computed(() => {const now = new Date();const year = now.getFullYear();const month = (now.getMonth() + 1).toString().padStart(2, '0');const day = now.getDate().toString().padStart(2, '0');return `${year}-${month}-${day}`;
});// 滚动到底部
const scrollToBottom = () => {if (messageBoxRef.value) {setTimeout(() => {messageBoxRef.value.scrollTop = messageBoxRef.value.scrollHeight;}, 100);}
};const sendMessage = () => {if (newMessage.value.trim()) {const messageData = {id: new Date().getTime(),receiverId: testReceiverId,author: user.name,uid: user.id,content: newMessage.value.trim(),timestamp: new Date().getTime()}// 发送消息到接收者的私有频道const sendChannel = `user.chat.${testReceiverId}_${user.id}`window.Echo.private(sendChannel).whisper('chat.message', {message: messageData,}).error(result => {if (result.type === 'AuthError' && result.status === 403) {messages.value = messages.value.filter(m => m.id !== messageData.id)// 认证失败不显示发送messages.value.push({id: new Date().getTime(),receiverId: 0,author: '系统',uid: 0,content: '授权认证失败,' + result.error,timestamp: new Date().getTime()})canSend.value = false}});messages.value.push(messageData)newMessage.value = ''scrollToBottom();}
};const receiveCoupon = () => {layer.msg('正在抢购中...')showLoading();axios.post(`/coupon/purchase/${coupon.id}`, {}).then(response => {if (response.status !== 200) {notify({title: '领取失败',text: '系统错误,' + response.statusText,type: 'error',});return}if (response.data.code !== 0) {notify({title: '领取失败',text: response.data.msg,type: 'error',});}})
}const getCoupon = (msg = null) => {// 调用接口获取优惠券axios.get('/coupon/random').then(response => {// 判断if (response.status === 200 && response.data.code === 0 && response.data.data) {coupon = response.data.dataif (msg) {messages.value.push({id: new Date().getTime(),author: 'system',type: msg.type,content: msg.message,timestamp: new Date().getTime()})scrollToBottom();}window.Echo.leaveChannel('purchase.' + coupon.id)window.Echo.private('purchase.' + coupon.id).listen('PurchaseResult', e => {if (coupon.id !== e.couponId) {return}if (e.status === 'success') {layer.closeAll()notify({title: '领取成功',text: '恭喜您抢购成功',type: 'success',});} else if (e.status === 'failed') {layer.closeAll()notify({title: '领取失败',text: e.message === 'received' ? '您已经领取过了' : e.message,type: 'error',});if (e.message === 'received') {getCoupon()}}})}})
}const litenEchoEvents = () => {// 监听发送给我的消息window.Echo.leaveChannel(channel)window.Echo.private(channel).listenForWhisper('chat.message', (data) => {messages.value.push({id: new Date().getTime(),author: data.message.author,uid: data.message.uid,content: data.message.content,timestamp: new Date().getTime()});scrollToBottom();}).error(result => {if (result.type === 'AuthError' && result.status === 403) {// 认证失败不显示发送messages.value.push({id: new Date().getTime(),receiverId: 0,author: '系统',type: 'msg',uid: 0,content: '授权认证失败,' + result.error,timestamp: new Date().getTime()})canSend.value = falsescrollToBottom();}});// 监听频道:purchase.couponId// 订阅监听const demoChannel = `demo-push.${user.id}`window.Echo.leaveChannel(demoChannel)window.Echo.channel(demoChannel).listen('DemoPushEvent', (e) => {// 处理接收到的消息// 如果是优惠券,调用获取优惠券方法if (e.type === 'coupon') {getCoupon(e)} else {messages.value.push({id: new Date().getTime(),author: 'system',type: e.type,content: e.message,timestamp: new Date().getTime()})scrollToBottom();}});
}const channel = `user.chat.${user.id}_${testReceiverId}`
litenEchoEvents()onMounted(() => {scrollToBottom();// 监听服务器断开window.Echo.connector.pusher.connection.bind('state_change', (states) => {if(states.current === "unavailable") {messages.value.push({id: new Date().getTime(),author: '系统',type: 'server-disconnect',content: '服务器连接已断开!请检查网络或稍后重试。',timestamp: new Date().getTime()});// 触发心态爆炸动画document.body.classList.add('rage-mode');// 3秒后移除爆炸动画setTimeout(() => {document.body.classList.remove('rage-mode');}, 3000);} else {document.body.classList.remove('rage-mode');}});
});
</script><template><Head title="聊天"/><AuthenticatedLayout><div class="chat-container"><!-- 聊天头部 --><div class="chat-header"><div class="contact-name">联系人</div></div><!-- 聊天消息区域 --><div ref="messageBoxRef" class="chat-messages"><!-- 日期显示 --><div class="date-divider"><span>{{ currentDate }}</span></div><!-- 消息列表 --><div v-for="message in messages" :key="message.id" class="message-wrapper"><!-- 系统消息 --><div v-if="message.author === 'system'" class="system-message"><div class="system-content">{{ message.content }}<button v-if="message.type === 'coupon'"class="coupon-button"type="button"@click="receiveCoupon">领取</button></div></div><div v-else-if="message.type === 'server-disconnect'" class="server-disconnect-message">⚠️ {{ message.content }}</div><!-- 用户消息 --><div v-else class="message" :class="{'message-self': message.uid === user.id}"><!-- 对方头像 --><div v-if="message.uid !== user.id" class="avatar"><div class="avatar-circle">{{ message.author.charAt(0) }}</div></div><div class="message-container" :class="{'self-container': message.uid === user.id}"><!-- 消息作者名称 - 仅对方消息显示 --><div v-if="message.uid !== user.id" class="message-author">{{ message.author }}</div><!-- 消息内容 --><div class="message-bubble" :class="{'self-bubble': message.uid === user.id}">{{ message.content }}</div><!-- 消息时间 --><div class="message-time" :class="{'self-time': message.uid === user.id}">{{ formatMessageTime(message.timestamp) }}</div></div><!-- 自己头像 --><div v-if="message.uid === user.id" class="avatar self-avatar"><div class="avatar-circle self-avatar-circle">{{ user.name.charAt(0) }}</div></div></div></div></div><!-- 输入区域 --><div class="chat-input-area"><div class="input-container"><inputv-model="newMessage"@keydown.enter="sendMessage"placeholder="发送消息..."class="message-input"/><button@click="sendMessage"v-if="canSend"class="send-button":class="{'send-active': newMessage.trim()}">发送</button></div></div></div></AuthenticatedLayout><footer class="footer">Laravel{{ laravelVersion }}-PHP{{ phpVersion }}</footer>
</template><style scoped>
.chat-container {max-width: 800px;height: 80vh;margin: 0 auto;display: flex;flex-direction: column;border: 1px solid #ededed;border-radius: 8px;overflow: hidden;background-color: #f5f5f5;box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}/* 聊天头部 */
.chat-header {display: flex;align-items: center;padding: 15px 20px;background-color: #f5f5f5;border-bottom: 1px solid #e0e0e0;z-index: 10;
}.contact-name {font-size: 16px;font-weight: 500;color: #333;
}/* 消息区域 */
.chat-messages {flex: 1;padding: 20px;overflow-y: auto;background-color: #ededed;
}/* 日期分割线 */
.date-divider {text-align: center;margin: 10px 0;
}.date-divider span {display: inline-block;padding: 5px 12px;background-color: rgba(0, 0, 0, 0.05);border-radius: 15px;font-size: 12px;color: #888;
}/* 消息样式 */
.message-wrapper {margin-bottom: 15px;display: flex;flex-direction: column;
}.message {display: flex;align-items: flex-start;
}.message-self {flex-direction: row-reverse;
}.avatar {margin: 0 10px;flex-shrink: 0;
}.avatar-circle {width: 40px;height: 40px;background-color: #ccc;border-radius: 4px;display: flex;align-items: center;justify-content: center;color: white;font-weight: bold;font-size: 16px;
}.self-avatar-circle {background-color: #91ed61;color: #333;
}.message-container {max-width: 65%;
}.self-container {align-items: flex-end;
}.message-author {font-size: 12px;color: #999;margin-bottom: 4px;padding-left: 10px;
}.message-bubble {background-color: white;padding: 10px 12px;border-radius: 3px;font-size: 14px;position: relative;word-wrap: break-word;line-height: 1.5;box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}.self-bubble {background-color: #95ec69;color: #000;
}.message-time {font-size: 11px;color: #b2b2b2;margin-top: 4px;padding-left: 10px;
}.self-time {text-align: right;padding-right: 10px;
}/* 系统消息 */
.system-message {display: flex;justify-content: center;margin: 10px 0;
}.system-content {background-color: rgba(0, 0, 0, 0.1);color: #666;padding: 5px 10px;border-radius: 4px;font-size: 12px;display: flex;align-items: center;
}.coupon-button {margin-left: 8px;background-color: #07c160;color: white;border: none;border-radius: 3px;padding: 3px 8px;font-size: 12px;cursor: pointer;
}/* 输入区域 */
.chat-input-area {padding: 10px 15px;background-color: #f5f5f5;border-top: 1px solid #e6e6e6;
}.input-container {display: flex;align-items: center;
}.message-input {flex: 1;border: 1px solid #e0e0e0;border-radius: 4px;padding: 9px 12px;font-size: 14px;background-color: white;outline: none;color: #333;
}.message-input:focus {border-color: #07c160;
}.send-button {margin-left: 10px;padding: 9px 16px;background-color: #f5f5f5;color: #b2b2b2;border: 1px solid #e0e0e0;border-radius: 4px;font-size: 14px;cursor: not-allowed;transition: all 0.2s;
}.send-active {background-color: #07c160;color: white;border-color: #07c160;cursor: pointer;
}.footer {text-align: center;padding: 10px;font-size: 11px;color: #999;
}/* 服务器断开消息,醒目提示 */
.server-disconnect-message {background-color: red;color: white;text-align: center;font-weight: bold;padding: 10px;border-radius: 5px;animation: flash 1s infinite alternate;
}/* 心态爆炸模式 */
@keyframes flash {0% { opacity: 1; }100% { opacity: 0.5; }
}/* 整个页面抖动 + 旋转,表达心态爆炸 */
.rage-mode {animation: rageShake 0.2s infinite alternate, rageRotate 0.5s linear infinite;
}@keyframes rageShake {0% { transform: translateX(-5px); }100% { transform: translateX(5px); }
}@keyframes rageRotate {0% { transform: rotate(0deg); }100% { transform: rotate(3deg); }
}/* 暗黑模式适配 */
@media (prefers-color-scheme: dark) {.chat-container {background-color: #2c2c2c;border-color: #3a3a3a;}.chat-header {background-color: #2c2c2c;border-color: #3a3a3a;}.contact-name {color: #e0e0e0;}.chat-messages {background-color: #1f1f1f;}.date-divider span {background-color: rgba(255, 255, 255, 0.1);color: #b2b2b2;}.message-bubble {background-color: #3a3a3a;color: #e0e0e0;}.self-bubble {background-color: #056f39;color: white;}.system-content {background-color: rgba(255, 255, 255, 0.1);color: #b2b2b2;}.chat-input-area {background-color: #2c2c2c;border-color: #3a3a3a;}.message-input {background-color: #3a3a3a;border-color: #4a4a4a;color: #e0e0e0;}.send-button {background-color: #2c2c2c;border-color: #3a3a3a;}
}
</style>