使用 Laravel 和 React 构建实时聊天应用

LaravelReact 很棒,感谢 WebSocket 协议 和易于使用的 Laravel Echo 库,我们可以轻松构建实时应用程序。

Laravel

在本文中,我们将使用 Laravel Echo Server 作为具有 Laravel Echo 兼容性的 socket.io 服务器。

Laravel Echo ServerLaravel 8 通信将由 Redis 处理。

我们将启动我们的应用程序,而无需使用 Laravel BreezeInertia.js 构建身份验证系统,我们不需要为 ReactLaravel 通信编写 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

如果你的系统中没有 npmyarnnode ,你可以使用 sail npmsail yarnsail 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_idroom_idmessage 列添加到 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 模型中设置 messageroom_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

现在我们将设置 UserRoomMessage 对象属性,在 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 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。

原文地址:https://towardsdev.com/build-a-real-time...

译文地址:https://learnku.com/laravel/t/66293

本文为协同翻译文章,如您发现瑕疵请点击「改进」按钮提交优化建议
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 3

现在使用 Soketi 了

2年前 评论
zhanghaidi

websocket协议?

1年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!