使用 Laravel 和 React 构建实时聊天应用
Laravel 和 React 很棒,感谢 WebSocket 协议 和易于使用的 Laravel Echo 库,我们可以轻松构建实时应用程序。
在本文中,我们将使用 Laravel Echo Server 作为具有 Laravel Echo 兼容性的 socket.io 服务器。
Laravel Echo Server 和 Laravel 8 通信将由 Redis 处理。
我们将启动我们的应用程序,而无需使用 Laravel Breeze 和 Inertia.js 构建身份验证系统,我们不需要为 React 和 Laravel 通信编写 API。除此之外,我们将使用 TailwindCSS 进行样式设置,这也是 Breeze React stack 的默认设置。
你可以在底部找到完整的演示 repo 和生产演示链接。
Laravel 设置入门
第 1 步:按照 官方文档 中的指导创建一个新的 Laravel 项目。
Docker(Laravel Sail) 是开发 Laravel 应用程序的最佳和最快的方法。只需将“example-app”更改为你喜欢的任何内容,然后在终端中运行以下命令:
curl -s https://laravel.build/example-app | bash
然后 cd
到文件夹并运行:
./vendor/bin/sail up -d
第一次运行 sail up
命令可能需要一点时间来构建 Docker 镜像。
你可以使用 http://localhost 访问 Laravel 欢迎页面
为 Sail 设置 Shell 别名:
alias sail='[ -f sail ] && bash sail || bash vendor/bin/sail'
通过设置别名,你无需输入 ./vendor/bin。
如果你的系统中没有 npm、yarn 或 node ,你可以使用 sail npm
、sail yarn
、sail node
。
第 2 步:安装 Breeze 软件包
Laravel Breeze 是 Laravel 所有身份验证功能 的最小、简单实现,包括登录、注册、密码重置、电子邮件验证和密码确认。 Laravel Breeze 的默认视图层由简单的 Blade 模板 组成,并带有 Tailwind CSS 样式。
运行:
sail composer require laravel/breeze --dev
使用 React 脚手架提取包:
sail artisan breeze:install react
现在运行以下命令来安装包和迁移:
npm install && npm run devsail artisan migrate
第 3 步:Docker 配置
打开 docker-compose.yml
并添加以下内容。
networks:
- sail
laravel-echo-server:
image: 'oanhnn/laravel-echo-server'
ports:
- '6001:6001'
volumes:
- '${PWD}/laravel-echo-server.json:/app/laravel-echo-server.json'
networks:
- sail**networks:
sail:
driver: bridge
创建 laravel-echo-server.json
到根文件夹并将其粘贴到开发环境中。你应该稍后进行更改以进行生产。
{
"authHost": "[http://laravel.test](http://laravel.test/)",
"authEndpoint": "/broadcasting/auth",
"clients": [],
"database": "redis",
"databaseConfig": {
"redis": {
"port": "6379",
"host": "redis",
"keyPrefix": "laravel_database_"
}
},
"devMode": true,
"host": null,
"port": "6001",
"protocol": "http",
"socketio": {},
"sslCertPath": "",
"sslKeyPath": "",
"subscribers": {
"http": true,
"redis": true
},
"apiOriginAllow": {
"allowCors": true,
"allowOrigin": "[http://localhost](http://localhost/)",
"allowMethods": "GET, POST",
"allowHeaders": "Origin, Content-Type, X-Auth-Token, X-Requested-With, Accept, Authorization, X-CSRF-TOKEN, X-Socket-Id"
}
}
删除容器并创建一个新容器:
sail down && sail up -d
第 4 步:Laravel 基本配置
在 config/app.php
中取消注释 App\Providers\BroadcastServiceProvider::class
在 .env 环境变量文件中将 BROADCAST_DRIVER
设置为 redis
。
第 5 步:安装客户端扩展包
npm install --save laravel-echo socket.io-client@2.4.0
将此代码添加到 /resources/js/bootstrap.js
import Echo from 'laravel-echo';window.io = require('socket.io-client');window.Echo = new Echo({
broadcaster: 'socket.io',
host: window.location.hostname + ':6001',
});
测试 Echo 服务器
使用 artisan 创建测试事件:
sail artisan make:event TestEvent
我们将创建一个公共频道(无需身份验证)用于手动测试并返回 ['title' => 'Test Title', 'message' => 'Test Message']
数组。
TestEvent 事件应如下所示:
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TestEvent implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 创建一个新的事件实例。
*
* @return void
*/
public function __construct()
{
//
}
/**
* 获取事件应该广播的频道。
*
* @return \Illuminate\Broadcasting\Channel|array
*/
public function broadcastOn()
{
return new Channel('test-channel');
}
/**
* 获取要广播的数据。
*
* @return array
*/
public function broadcastWith()
{
return [
'title' => 'Test Title',
'message' => 'Test Message',
];
}
}
现在为广播添加一个测试路由:
Route::get('add-notification', function () {
broadcast(new \App\Events\TestEvent);
return 'Test';
});
将此添加到 /resources/js/bootstrap.js
(仅用于临时,测试后删除)
window.Echo.channel('test-channel').listen('TestEvent', event => {
console.log(event);
});
编译前端资产(Mix):
npm run dev
访问 http://localhost
,在另一个浏览器选项卡上访问 http://localhost/add-notification
。
你应该会在欢迎页面控制台中看到 broadcastWith()
返回数据。
构建聊天应用
第 1 步:创建模型和迁移
使用 artisan 创建一个消息模型:
sail artisan make:model Message --migration
转到 database/migrations
文件夹并将 user_id
、room_id
和 message
列添加到 messages 表 迁移文件中:
$table->integer('user_id')->unsigned();
$table->integer('room_id')->unsigned();
$table->text('message');
使用 artisan 命令创建一个 Room 模型 :
sail artisan make:model Room --migration
在同一文件夹中,将 name
列添加到 rooms 表 迁移文件中:
$table->string('name')->unique();
如果需要,你可以删除时间戳。
执行迁移任务。如果出现错误,你还可以使用 sail artisan cache:clear
清除缓存。
sail artisan migrate
现在我们将通过向模型添加方法来建立关系。
在 Message 模型中设置 message
和 room_id
为可填充,并添加 user()
和 room()
方法。
protected $fillable = ['message', 'room_id'];public function user()
{
return $this->belongsTo(User::class);
}
public function room()
{
return $this->belongsTo(Room::class);
}
在 Room 模型中,设置 name
为可填充并添加 messages()
方法。
protected $fillable = ['name'];public function messages()
{
return $this->hasMany(Message::class);
}
将此方法添加到 User 模型:
public function messages()
{
return $this->hasMany(Message::class);
}
第 2 步:MessageSent 事件
使用 artisan 命令创建一个 MessageSent 事件:
sail artisan make:event MessageSent
现在我们将设置 User、Room 和 Message 对象属性,在 broadcastOn()
方法中返回 Presence 通道来满足身份验证要求,而不是传递所有模型对象数据,我们将出于安全原因,将特定的广播数据传递给客户端。
MessageSent 事件应如下所示:
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use App\Models\User;
use App\Models\Room;
use App\Models\Message;
class MessageSent implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @var \App\Models\User
*/
protected $user;
/**
* @var \App\Models\Room
*/
protected $room;
/**
* @var \App\Models\Message
*/
protected $message;
/**
* 创建一个新的事件实例。
*
* @return void
*/
public function __construct(User $user, Room $room, Message $message)
{
$this->user = $user;
$this->room = $room;
$this->message = $message;
}
/**
* 获取事件应该广播的频道。
*
* @return \Illuminate\Broadcasting\Channel|array
*/
public function broadcastOn()
{
return new PresenceChannel("room.{$this->room->name}");
}
/**
* 使用特定数据进行广播。
*
* @return array
*/
public function broadcastWith()
{
return array_merge($this->message->toArray(), ['user' => $this->user->only('id', 'name')]);
}
}
第 3 步:使用 tinker 生成一些数据库数据
你可以使用以下命令运行 tinker:
sail artisan tinker
运行以下命令创建五个用户:
\App\Models\User::factory(5)->create();
运行以下命令创建房间:
DB::table('rooms')->insert(array_map(function ($room) {
return ['name' => $room];
}, ['general', 'room1', 'room2', 'room3', 'room4']));
}
第 4 步:创建控制器并设置路由配置
使用 artisan 命令创建一个 ChatsController:
sail artisan make:controller ChatsController
在具有 renderChat()
方法的控制器中,我们正在渲染一个包含已保存消息、房间数据的聊天页面; 使用 sendMessage()
方法,我们正在向数据库创建新的消息数据。
已完成的 ChatsController 应如下所示:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Inertia\Inertia;
use App\Models\Room;
use App\Events\MessageSent;
use Illuminate\Support\Facades\Response;
class ChatsController extends Controller
{
/**
* 使用 Inertia 渲染聊天页面。
*
* @return \Inertia\Response
*/
public function renderChat(Request $request, $room = null)
{
$rooms = Room::all()->pluck('name')->toArray();
if ($room === 'general') return redirect()->route('chat');
if (!$room) $room = "general";
if (!in_array($room, $rooms)) abort(404);
return Inertia::render('Chat', [
'chatData' => [
'messages' => Room::where('name', $room)->first()->messages()->with('user')->get(),
'rooms' => $rooms,
'room' => $room
]
]);
}
/**
* 处理消息发布请求。
*
* @return \Illuminate\Http\JsonResponse
*/
public function sendMessage(Request $request)
{
$request->validate([
'room' => ['required', 'string', 'max:50'],
'message' => ['required', 'string', 'max:140'],
]);
$user = $request->user();
$room = Room::where('name', $request->room)->firstOrFail();
$message = $user->messages()->create([
'message' => $request->message,
'room_id' => $room->id
]);
broadcast(new MessageSent($user, $room, $message))->toOthers();
return Response::json(['ok' => true]);
}
}
/broadcasting/auth
是认证请求通道路由。
我们将只允许登录用户(使用在线通道),并且所有登录用户都可以获得身份验证。
routes/channel.php
Broadcast::channel('room.{id}', function ($user) {
return $user->only('id', 'name');
});
为聊天页面和消息 POST 请求添加路由:
routes/web.php
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/chat/{room?}', [ChatsController::class, 'renderChat'])->name('chat');
Route::post('/message', [ChatsController::class, 'sendMessage'])->name('message');
});
第 5 步:设置客户端
我们将使用 Laravel Echo 库的方法来监听事件。
我们将使用 join()
加入出席频道,使用 here()
获取活跃用户,使用 joining()
处理用户加入事件,使用 leaving()
处理用户离开事件,使用 listen()
处理实时消息,使用 listenForWhisper()
处理打字事件
我们需要在 componentDidMount()
生命周期方法中调用这些方法。
基本上,我们将在第一次渲染时获取房间之前的所有消息,消息发送事件将由套接字实时处理,然后我们将其发布到数据库。
转到 resources/js/Components
并创建Chat.js。聊天组件应如下所示:
import React from "react";
import { Link } from "@inertiajs/inertia-react";
class Chat extends React.Component {
constructor(props) {
super(props);
this.state = {
activeUsers: [],
messages: [],
messageInput: "",
typingUser: null,
typingTimer: null,
};
}
componentDidMount() {
this.setState({ messages: this.props.chatData.messages });
this.messagesEndRef = React.createRef(null);
window.Echo.join(`room.${this.props.chatData.room}`)
.here((users) => {
this.setState({ activeUsers: users });
})
.joining((user) => {
this.setState({
activeUsers: [...this.state.activeUsers, user],
});
})
.leaving((user) => {
this.setState({
activeUsers: this.state.activeUsers.filter(
(u) => u.id != user.id
),
});
})
.error((error) => {
console.log("echo:error", error);
})
.listen("MessageSent", (e) => {
this.setState({
messages: [...this.state.messages, e],
});
})
.listenForWhisper("typing", (user) => {
this.setState({ typingUser: user });
if (this.state.typingTimer) {
clearTimeout(this.state.typingTimer);
}
this.setState({
typingTimer: setTimeout(() => {
this.setState({ typingUser: null });
}, 3000),
});
});
}
componentWillUnmount() {
window.Echo.leave(`room.${this.props.chatData.room}`);
}
componentDidUpdate(prevProps, prevState) {
if (prevState.messages !== this.state.messages) {
this.scrollToBottom(
prevState.messages !== undefined &&
prevState.messages.length !== 0
? "smooth"
: "auto"
);
}
}
formatDate = (date) => {
let d = new Date(date),
month = "" + (d.getMonth() + 1),
day = "" + d.getDate(),
year = d.getFullYear(),
hours = "" + d.getHours(),
min = "" + d.getMinutes();
if (month.length < 2) month = "0" + month;
if (day.length < 2) day = "0" + day;
if (hours.length < 2) hours = "0" + hours;
if (min.length < 2) min = "0" + min;
return [day, month, year].join(".") + " " + hours + ":" + min;
};
addMessage(message) {
axios.post("/message", {
message: message,
room: this.props.chatData.room,
});
this.setState({
messages: [
...this.state.messages,
{
user: {
id: this.props.auth.user.id,
name: this.props.auth.user.name,
},
user_id: this.props.auth.user.id,
created_at: new Date().toISOString(),
message: message,
},
],
});
}
scrollToBottom = (behavior = "smooth") => {
this.messagesEndRef.current?.scrollIntoView({ behavior: behavior });
};
handleKeyDown = (event) => {
if (event.key === "Enter") {
this.addMessage(event.target.value);
this.setState({ messageInput: "" });
} else {
window.Echo.join(`room.${this.props.chatData.room}`).whisper(
"typing",
this.props.auth.user
);
}
};
handleChange = (event) => {
this.setState({ messageInput: event.target.value });
};
render() {
return (
<div className="flex items-start dark:bg-gray-800 dark:text-gray-300 text-sm md:text-base ">
<div className="w-1/3 mx-2 my-4 md:px-4">
<div>
<span>
Active Users ({this.state.activeUsers.length})
</span>
<div className="overflow-y-scroll h-24 max-w-xs my-2 bg-gray-200 dark:bg-gray-600 rounded-md p-2">
{this.state.activeUsers.map((user) => (
<li
className="list-none"
key={user.id}
>
{user.name}
</li>
))}
</div>
</div>
<div className="my-8">
<span>Rooms</span>
<div className="rounded-md">
{this.props.chatData.rooms.map((room, i) => {
return (
<Link
key={i}
href={`/chat/${room}`}
>
<li className="list-none p-2 bg-gray-200 dark:bg-gray-600 my-2 rounded-md cursor-pointer">
# {room}
</li>
</Link>
);
})}
</div>
</div>
</div>
<div className=" w-full">
<div className="py-2 px-4 sticky border-b-2 border-gray-300">
<div className="flex gap-2 items-center">
<span className="text-2xl">#</span>
<h2 className="text-xl">
{this.props.chatData.room}
</h2>
</div>
</div>
<div className="h-[30rem] overflow-y-scroll">
<div className="flex items-start flex-col">
{this.state.messages.map((message, i) => (
<div
key={i}
className={`px-4 py-2 m-2 max-w-xs bg-gray-200 dark:bg-gray-600 rounded-xl ${
message.user_id !==
this.props.auth.user.id
? "rounded-bl-none"
: "rounded-br-none self-end"
} `}
>
<div className="flex gap-4 items-center">
<span className="text-md font-bold">
{message.user.name}
</span>
<span className="text-xs text-gray-600 dark:text-gray-400 font-semibold opacity-70">
{this.formatDate(
message.created_at
)}
</span>
</div>
<div className=" text-sm">
<span className="max-w-full break-words">
{message.message}
</span>
</div>
</div>
))}
<div ref={this.messagesEndRef} />
</div>
</div>
<div className="w-full">
<div className="p-2">
<input
onKeyDown={this.handleKeyDown}
onChange={this.handleChange}
className="rounded-md border-none w-full bg-gray-200 dark:bg-gray-600 dark:text-gray-300 dark:placeholder:text-gray-300"
type="text"
placeholder="Enter message"
value={this.state.messageInput}
/>
<div className="h-6">
{this.state.typingUser ? (
<span className="text-sm">
{this.state.typingUser.name} is
typing...
</span>
) : (
""
)}
</div>
</div>
</div>
</div>
</div>
);
}
}
export default Chat;
访问 resources/js/Pages/
并创建 Chat.js::
import Chat from "@/Components/Chat";
import Authenticated from "@/Layouts/Authenticated";
import { Head } from "@inertiajs/inertia-react";
export default function ChatPage(props) {
return (
<Authenticated
auth={props.auth}
errors={props.errors}
header={
<h2 className="font-semibold text-xl text-gray-800 leading-tight">
Chat
</h2>
}
>
<Head title="Chat" />
<div className="py-2">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<Chat auth={props.auth} chatData={props.chatData} />
</div>
</div>
</Authenticated>
);
}
编译前端资源(Mix):
npm run dev
现在你可以通过访问 localhost/chat 访问聊天页面
生产演示:laravel-chat-app.ml/
完成的项目在 GitHub — sinanbekar/laravel-realtime-chat-app:Laravel 实时聊天应用演示。一步一步...。
写在最后
在本文中,我讨论了如何使用 Laravel 和现代技术轻松设置实时聊天应用程序。
当然,这是最简单的实现。你需要为大型生产和安全性进行更好的配置和调整。
如果你需要有关代码或应用程序设置的进一步帮助,欢迎通过文章评论或 GitHub Issue 探讨:)
本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。
现在使用 Soketi 了
websocket协议?
mark