Laravel11 从0开发 Swoole-Reverb 扩展包(五) - Laravel Echo 介绍
前情提要
上一节我们完整的梳理了整个通信过程,接下来我们需要来看前端的处理过程。
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.data
if (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 = false
scrollToBottom();
}
});
// 监听频道: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">
<input
v-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>