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>

image

posted @ 2025-03-13 15:19  wanzij  阅读(21)  评论(0)    收藏  举报
TOP