Laravel 11 Reverb & Vue 3 构建实时聊天
现如今,实时通信应用程序愈发成为影响用户体验的关键因素,如即时消息、即时客记支持和协作工具等。本篇文章将使用 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
测试聊天
现在还没有用户,我们可以多注册几个用户进行测试。