Laravel 11 Reverb & Vue 3 构建实时聊天

作者: 温新

图书: 【Laravel 11 实战】

阅读: 258

时间: 2025-01-18 13:09:38

现如今,实时通信应用程序愈发成为影响用户体验的关键因素,如即时消息、即时客记支持和协作工具等。本篇文章将使用 Laravel Reverb 和 Vue 3 实现一个实时聊天的应用程序。

创建 Laravel 11 应用

创建应用的时候选择 Laravel Breeze 作为驱动且已经配置了 Tailwind CSS。

$ composer global require laravel/installer
# 创建项目
$ laravel new reverb-chat
 ┌ Would you like to install a starter kit? ────────────────────┐
 │ Laravel Breeze                                               │
 └──────────────────────────────────────────────────────────────┘
 ┌ Which Breeze stack would you like to install? ───────────────┐
 │ Blade with Alpine                                            │
 └──────────────────────────────────────────────────────────────┘
 ┌ Would you like dark mode support? ───────────────────────────┐
 │ No                                                           │
 └──────────────────────────────────────────────────────────────┘
 ┌ Which testing framework do you prefer? ──────────────────────┐
 │ Pest                                                         │
 └──────────────────────────────────────────────────────────────┘
 ┌ Would you like to initialize a Git repository? ──────────────┐
 │ No   
 $ cd reverb-chat

安装 Laravel Reverb

$ php artisan install:broadcasting

   INFO  Published 'broadcasting' configuration file.  
   INFO  Published 'channels' route file.  
 ┌ Would you like to install Laravel Reverb? ───────────────────┐
 │ Yes    

	INFO  Published 'broadcasting' configuration file.  
	INFO  Published 'channels' route file.  
 ┌ Would you like to install Laravel Reverb? ───────────────────┐
 │ Yes    

install:broadcasting 命令将创建如下两个文件:

  • 执行成功在将在 config 目录生成 broadcasting.php 配置文件
  • route 目录下生成 channels.php 文件

执行该命令,选择安装 reverb 服务,成功后,会在 config 目录下生成 reverb.php 配置文件。

创建模型 & 迁移文件

创建一个用于存储消息的数据表。

$ php artisan make:model Message -m

编写迁移文件:database/migrations/2024_09_16_151618_create_messages_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('messages', function (Blueprint $table) {
            $table->id();
            $table->foreignId('sender_id');
            $table->foreignId('receiver_id');
            $table->text('text');
            $table->timestamps();
        });
    }
    
    public function down(): void
    {
        Schema::dropIfExists('messages');
    }
};

编写模型文件:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Message extends Model
{
    use HasFactory;

    protected $fillable = [
        'sender_id',
        'receiver_id',
        'text'
    ];

    /**
     * 消息发送者
     *
     * @return BelongsTo
     */
    public function sender(): BelongsTo
    {
        return $this->belongsTo(User::class, 'sender_id');
    }

    /**
     * 消息接收者
     *
     * @return BelongsTo
     */
    public function receiver(): BelongsTo
    {
        return $this->belongsTo(User::class, 'receiver_id');
    }
}

创建路由

1)定义 web 路由

位置:routes/web.php

<?php

use App\Http\Controllers\ChatController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\ProfileController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth')->group(function () {
    ...

    // 仪表表
    Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
    // 聊天
    Route::get('/chat/{user}', [ChatController::class, 'show'])->name('chat');
    Route::get('/messages/{user}', [ChatController::class, 'index']);
    Route::post('/messages/{user}', [ChatController::class, 'sendMessage']);
});

require __DIR__.'/auth.php';

2)定义 channel 路由

位置:routes/channel.php

<?php

use Illuminate\Support\Facades\Broadcast;

// 定义聊天频道,格式为 'chat.{id}'
// 该频道用于聊天功能,允许用户接收消息
Broadcast::channel('chat.{id}', function ($user, $id) {
    // 检查当前用户的 ID 是否与频道中的 ID 相同
    return (int) $user->id === (int) $id;
});


// 定义一个存在性聊天频道,用于实时在线用户信息
Broadcast::channel('presence.chat', function ($user) {
    return ['id' => $user->id, 'name' => $user->name];
});

创建控制器

# 仪表板
$ php artisan make:controller DashboardController
# 聊天
$ php artisan make:controller ChatController

1)仪表控制器添加方法

位置:app/Http/Controllers/DashboardController.php

<?php

namespace App\Http\Controllers;

use App\Models\User;
class DashboardController extends Controller
{
    public function index()
    {
        // 查询所有用户并排除当前登录用户
        $users = User::query()->whereNot('id', auth()->id())->get();

        return view('dashboard', compact('users'));
    }
}

2)聊天控制器添加方法

位置:app/Http/Controllers/ChatController.php

<?php

namespace App\Http\Controllers;

use App\Events\MessageSent;
use App\Models\Message;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\Response;

class ChatController extends Controller
{
    /**
     * 获取与指定用户的聊天记录
     *
     * @param User $user 指定用户
     * @return Collection 返回消息集合
     */
    public function index(User $user): Collection
    {
        return Message::query()
            // 过滤条件:发送者为当前用户且接收者为指定用户
            ->where(function ($query) use ($user) {
                $query->where('sender_id', auth()->id())
                    ->where('receiver_id', $user->id);
            })
            // 过滤条件:发送者为指定用户且接收者为当前用户
            ->orWhere(function ($query) use ($user) {
                $query->where('sender_id', $user->id)
                    ->where('receiver_id', auth()->id());
            })
            ->with(['sender', 'receiver'])
            ->orderBy('id', 'asc')
            ->get();
    }

    /**
     * 显示与指定用户的聊天界面
     *
     * @param User $user 指定的聊天用户
     * @return View 返回聊天视图
     */
    public function show(User $user): View
    {
        return view('chat', [
            'user' => $user
        ]);
    }

    /**
     * 发送消息给指定用户
     *
     * @param Request $request 请求对象
     * @param User $user 指定用户
     * @return Response 返回JSON格式的消息响应
     */
    public function sendMessage(Request $request, User $user): Response
    {
        // 消息入库
        $message = Message::create([
            'sender_id'   => auth()->id(),
            'receiver_id' => $user->id,
            'text'        => $request->input('message')
        ]);

        // 发送广播事件,通知其他用户
        broadcast(new MessageSent($message));

        return response()->json($message);
    }
}

broadcast(new MessageSent($message)) 该广播会把 MessageSent 事件实时发送到所有连接的客户端,确保聊天界面立即使用新消息。

创建事件监听

1)生成事件文件

$ php artisan make:event MessageSent

2)编写事件方法

位置:app/Events/MessageSent.php

<?php

namespace App\Events;

use App\Models\Message;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class MessageSent implements ShouldBroadcastNow
{
    use Dispatchable;
    use InteractsWithSockets;
    use SerializesModels;

    /**
     * 接收消息实例
     *
     * @param Message $message 接收的消息实例
     */
    public function __construct(public Message $message)
    {
    }
    
    /**
     * 指定事件广播的频道
     *
     * @return PrivateChannel[] 返回包含私有频道的数组
     */
    public function broadcastOn(): array
    {
        return [
            // 返回一个私有频道,格式为 "chat.{receiver_id}"
            new PrivateChannel("chat.{$this->message->receiver_id}"),
        ];
    }
}

安装 Vue 3

1)安装 vue 3

$ pnpm install vue@latest
$ pnpm install --save-dev @vitejs/plugin-vue

2)配置 vite.config.js

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
    plugins: [
        laravel({
            input: [
                'resources/css/app.css',
                'resources/js/app.js',
            ],
            refresh: true,
        }),
        vue({
            template: {
                transformAssetUrls: {
                    base: null,
                    includeAbsolute: false,
                },
            },
        }),
    ],
    resolve: {
        alias: {
            vue: 'vue/dist/vue.esm-bundler.js',
        },
    },
});

3)指定 ID

位置:resources/views/layouts/app.blade.php

...
    
<body class="font-sans antialiased">
    <div class="min-h-screen bg-gray-100" id="app">
    
...

4)渲染 dashboard

位置:resources/views/dashboard.blade.php

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ __('Dashboard') }}
        </h2>
    </x-slot>

    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                <div class="p-6 text-gray-900">
                    {{ __("You're logged in!") }}
                </div>
            </div>
            <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg mt-6">
                <div class="p-6 text-gray-900">
                    <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
                        @foreach ($users as $user)
                            <a href="{{ route('chat', $user->id) }}" class="bg-gray-100 p-4 rounded-lg shadow-md block hover:bg-gray-200">
                                <h3 class="text-lg font-semibold">{{ $user->name }}</h3>
                                <p>{{ $user->email }}</p>
                            </a>
                        @endforeach
                    </div>
                </div>
            </div>
        </div>
    </div>
</x-app-layout>

创建 Vue 聊天组件

1)挂载 Vue

位置:resources/js/app.js

import './bootstrap';

import { createApp } from 'vue'
import ChatComponent from './components/ChatComponent.vue'

const app = createApp({});

app.component('chat-component', ChatComponent);
app.mount('#app');

2)创建聊天组件

位置:resources/js/components/ChatComponent.vue

<template>
    <div class="flex flex-col h-[500px]">

        <div class="flex items-center">
            <h1 class="text-lg font-semibold mr-2">{{ user.name }}</h1>
            <span :class="isUserOnline ? 'bg-green-500' : 'bg-gray-400'" class="inline-block h-2 w-2 rounded-full"></span>
        </div>


        <!-- Messages -->
        <div ref="messageContainer" class="overflow-y-auto p-4 mt-3 flex-grow border-t border-gray-200">
            <div class="space-y-4">
                <div
                    v-for="message in messages"
                    :key="message.id"
                    :class="{ 'text-right': message.sender_id === currentUser.id }"
                    class="mb-4"
                >
                    <div
                        :class="message.sender_id === currentUser.id ? 'bg-green-300 text-right' : 'bg-gray-400 text-yellow-800'"
                        class="inline-block px-5 py-2 rounded-lg"
                    >
                        <p>{{ message.text }}</p>
                        <span class="text-[10px]">

                            {{message.sender_id === currentUser.id  ? currentUser.name : ''}}
                            {{ formatTime(message.created_at) }}
                        </span>
                    </div>
                </div>
            </div>
        </div>

        <!-- Message Input -->
        <div class="border-t pt-4">
            <form @submit.prevent="sendMessage">
                <div class="flex items-center">
                    <input
                        v-model="newMessage"
                        @keydown="sendTypingEvent"
                        type="text"
                        class="flex-1 border p-3 rounded-lg"
                        placeholder="Type your message here..."
                    />
                    <button type="submit" class="ml-2 bg-indigo-500 p-3 rounded-lg shadow hover:bg-indigo-600 transition duration-300 flex items-center justify-center">
                        <i class="fas fa-paper-plane"></i>
                        <span class="ml-2">发送</span>
                    </button>
                </div>
            </form>
        </div>
    </div>
    <small v-if="isUserTyping" class="text-gray-600 mt-5">
        {{ user.name }} is typing...
    </small>
</template>


<script setup>
import { ref, onMounted, watch, nextTick } from 'vue';
import axios from 'axios';

// 定义组件接收的props
const props = defineProps({
    user: {
        type: Object,
        required: true
    },
    currentUser: {
        type: Object,
        required: true
    }
});

const messages = ref([]);   // 存储消息列表
const newMessage = ref(''); // 存储当前输入的消息
const messageContainer = ref(null)  // 引用消息容器元素
const isUserTyping = ref(false);    // 用户正在输入的状态
const isUserTypingTimer = ref(null);    // 定时器用于判断用户停止输入
const isUserOnline = ref(false);    // 用户在线状态


// 监听消息列表变化,自动滚动到最新消息
watch(
    messages,
    () => {
        nextTick(() => {
            messageContainer.value.scrollTo({
                top: messageContainer.value.scrollHeight,
                behavior: "smooth",
            });
        });
    },
    { deep: true } // 深度监听,处理嵌套结构
);

// 获取消息的异步函数
const fetchMessages = async () => {
    try {
        // 请求该用户的消息
        const response = await axios.get(`/messages/${props.user.id}`);
        // 更新消息列表
        messages.value = response.data;
    } catch (error) {
        console.error("Failed to fetch messages:", error);
    }
};

// 发送消息的异步函数
const sendMessage = async () => {
    if (newMessage.value.trim() !== '') {
        try {
            // 发送消息内容
            const response = await axios.post(`/messages/${props.user.id}`, {
                message: newMessage.value,
            });
            messages.value.push(response.data);
            newMessage.value = '';
        } catch (error) {
            console.error("Failed to send message:", error);
        }
    }
};

// 发送用户正在输入事件
const sendTypingEvent = () => {
    Echo.private(`chat.${props.user.id}`).whisper("typing", {
        userID: props.currentUser.id,
    });
};

// 格式化时间的函数
const formatTime = (datetime) => {
    const options = { hour: '2-digit', minute: '2-digit' };
    return new Date(datetime).toLocaleTimeString([], options);
};

onMounted(() => {
    fetchMessages();

    // 监听用户在线状态
    Echo.join(`presence.chat`)
        .here(users => {
            // 检查当前用户是否在线
            isUserOnline.value = users.some(user => user.id === props.user.id);
        })
        .joining(user => {
            // 用户加入时更新在线状态
            if (user.id === props.user.id) isUserOnline.value = true;
        })
        .leaving(user => {
            // 用户离开时更新在线状态
            if (user.id === props.user.id) isUserOnline.value = false;
        });

    // 监听消息发送事件
    Echo.private(`chat.${props.currentUser.id}`)
        .listen("MessageSent", (response) => {
            // 将接收到的新消息添加到消息列表
            messages.value.push(response.message);
        })
        .listenForWhisper("typing", (response) => {
            // 判断用户是否正在输入
            isUserTyping.value = response.userID === props.user.id;

            if (isUserTypingTimer.value) {
                clearTimeout(isUserTypingTimer.value);
            }

            // 设置新的定时器,1 秒后判断用户停止输入
            isUserTypingTimer.value = setTimeout(() => {
                isUserTyping.value = false;
            }, 1000);
        });
});
</script>
  • watch 用于监视 messages 变量中的更改。当messages发生变化时,nextTick 会确保 DOM 更新完成,然后再平滑地将消息容器滚动到底部。
  • fetchMessages 函数从服务器获取指定用户的消息并更新 messages 变量。如果发生错误,它会记录错误并提醒用户。
  • sendMessage 函数将 newMessage 的内容发送到服务器,并将响应添加到 messages 变量中。如果发生错误,它会记录错误并提醒用户。
  • sendTypingEvent 函数发送 “typing” 事件,以通知其他用户当前用户正在键入。这使用 Laravel Echo 来管理实时事件。
  • Echo.join 订阅状态频道以跟踪哪些用户在线。
  • Echo.private 订阅私有通道以侦听新消息和键入事件。当发送新消息时,它会被添加到 messages 变量中。当收到 typing 事件时,它会使用计时器更新 isUserTyping 状态以重置它。

3)使用组件

位置:resources/views/chat.blade.php

<x-app-layout>
    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                <div class="p-6">
                    <chat-component :user="{{ $user }}" :current-user="{{ auth()->user() }}"></chat-component>
                </div>
            </div>
        </div>
    </div>
</x-app-layout>

启动应用

$ php artisan serve
$ php artisan reverb:start
$ pnpm dev

测试聊天

现在还没有用户,我们可以多注册几个用户进行测试。

请登录后再评论