使用 React 和 Socket.io 创建一个点赞系统 🥳 🔝

2025-05-25

使用 React 和 Socket.io 创建一个点赞系统 🥳 🔝

这篇文章是关于什么的?

点赞已经成为了解访客需求的绝佳方式。你可以利用ProductHunt这样的网站,以及GleapUpvotyProdcamp等公开路线图,让用户分享他们的想法(以投票的形式)。
甚至连Reddit,这个最受欢迎的社交媒体平台之一,也允许人们为你的帖子点赞或点踩。我们也打算用图片来构建类似的功能!

点赞

在本文中,您将学习如何创建一个点赞应用程序,让用户使用 Websockets 上传图片并为自己喜欢的照片点赞。您还将学习如何通过 EmailJS 发送电子邮件,在图片获得点赞时通知用户。

变形金刚

为什么选择 Socket.io (Websockets)?

Websockets 允许我们与服务器进行双向通信。这意味着,如果我们点赞了,我们无需刷新页面或使用长轮询即可通知其他用户新的点赞。

Socket.io 是一个流行的 JavaScript 库,它允许我们在软件应用程序和 Node.js 服务器之间创建实时双向通信。它经过优化,能够以最小的延迟处理大量数据,并提供更强大的功能,例如回退到 HTTP 长轮询或自动重新连接。

Novu——第一个开源通知基础设施

简单介绍一下我们。Novu 是第一个开源通知基础设施。我们主要负责管理所有产品通知。这些通知可以是应用内通知(类似 Facebook 的铃铛图标 - Websockets)、电子邮件、短信等等。

诺武

如果你能给我们一颗星,我会非常高兴!也请在评论区告诉我❤️
https://github.com/novuhq/novu

如何使用 React 和 Socket.io 创建实时连接

在这里,我们将为图片点赞应用程序搭建项目环境。您还将学习如何将 Socket.io 添加到 React 和 Node.js 应用程序中,以及如何通过 Socket.io 连接两个开发服务器进行实时通信。

创建包含两个名为客户端和服务器的子文件夹的项目文件夹。

mkdir upvote-app
cd upvote-app
mkdir client server
Enter fullscreen mode Exit fullscreen mode

通过终端导航到客户端文件夹并创建一个新的 React.js 项目。

cd client
npx create-react-app ./
Enter fullscreen mode Exit fullscreen mode

安装 Socket.io 客户端 API、React Toastify 和 React Router。React  Router是一个 JavaScript 库,它使我们能够在 React 应用程序中的页面之间导航,而 React Toastify用于向用户显示彩色通知。

npm install socket.io-client react-router-dom react-toastify
Enter fullscreen mode Exit fullscreen mode

从 React 应用中删除 logo 和测试文件等冗余文件,并更新 App.js 文件Hello World以如下所示显示。

function App() {
    return (
        <div>
            <p>Hello World!</p>
        </div>
    );
}
export default App;
Enter fullscreen mode Exit fullscreen mode

将 Socket.io 客户端 API 添加到 React 应用程序,如下所示:

import { io } from "socket.io-client";

//👇🏻 http://localhost:4000 is where the server host URL.
const socket = io.connect("http://localhost:4000");

function App() {
    return (
        <div>
            <p>Hello World!</p>
        </div>
    );
}
export default App;
Enter fullscreen mode Exit fullscreen mode

导航到服务器文件夹并创建一个package.json文件。

cd server & npm init -y
Enter fullscreen mode Exit fullscreen mode

安装 Express.js、CORS、Nodemon 和 Socket.io 服务器 API。

npm install express cors nodemon socket.io react-icons
Enter fullscreen mode Exit fullscreen mode

Express.js 是一个快速、简约的框架,它提供了多种用于在 Node.js 中构建 Web 应用程序的功能。CORS 是一个 Node.js ,允许不同域之间进行通信。

Nodemon 是一个 Node.js 工具,它在检测到文件更改后会自动重启服务器,而 Socket.io 允许我们在服务器上配置实时连接。

创建一个 index.js 文件 - Web 服务器的入口点。

touch index.js
Enter fullscreen mode Exit fullscreen mode

使用 Express.js 设置 Node.js 服务器。当您http://localhost:4000/api在浏览器中访问时,下面的代码片段会返回一个 JSON 对象。

//👇🏻index.js
const express = require("express");
const app = express();
const PORT = 4000;

app.use(express.urlencoded({ extended: true }));
app.use(express.json());

app.get("/api", (req, res) => {
    res.json({
        message: "Hello world",
    });
});

app.listen(PORT, () => {
    console.log(`Server listening on ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

导入 HTTP 和 CORS 库以允许客户端和服务器域之间进行数据传输。

const express = require("express");
const app = express();
const PORT = 4000;

//👇🏻 New imports
const http = require("http").Server(app);
const cors = require("cors");

app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(cors());

app.get("/api", (req, res) => {
    res.json({
        message: "Hello world",
    });
});

http.listen(PORT, () => {
    console.log(`Server listening on ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

接下来,将 Socket.io 添加到项目中,以创建实时连接。在app.get()代码块之前,复制以下代码。

//👇🏻 New imports
.....
const socketIO = require('socket.io')(http, {
    cors: {
        origin: "http://localhost:3000"
    }
});

//👇🏻 Add this before the app.get() block
socketIO.on('connection', (socket) => {
    console.log(`⚡: ${socket.id} user just connected!`);

    socket.on('disconnect', () => {
      socket.disconnect()
      console.log('🔥: A user disconnected');
    });
});
Enter fullscreen mode Exit fullscreen mode

从上面的代码片段中,该socket.io("connection")函数与 React 应用程序建立连接,然后为每个套接字创建一个唯一的 ID,并在用户访问网页时将该 ID 记录到控制台。

当您刷新或关闭网页时,套接字会触发断开事件,表明用户已与套接字断开连接。

通过将启动命令添加到package.json文件中的脚本列表中来配置 Nodemon。下面的代码片段使用 Nodemon 启动服务器。

//👇🏻 In server/package.json"
"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "nodemon index.js"
}
Enter fullscreen mode Exit fullscreen mode

您现在可以使用以下命令通过 Nodemon 运行服务器。

npm start
Enter fullscreen mode Exit fullscreen mode

构建用户界面

在这里,我们将创建点赞应用的用户界面,以便用户登录、上传图片以及为自己选择的任何图片点赞。
构建点赞应用时需要遵循两条规则:

  • 用户只能投票一次。
  • 用户不能对自己的图像进行点赞。

在本教程的后面,我将指导您如何构建这样一个高效的投票系统。

导航到client/src文件夹并创建一个包含Login.jsRegister.jsPhotos.jsUploadPhoto.jsMyPhotosSharePhoto.js文件的组件文件夹。

cd client
mkdir components
cd components
touch Login.js Register.js Photos.js UploadPhoto.js MyPhoto.js SharePhoto.js
Enter fullscreen mode Exit fullscreen mode

从上面的代码片段来看:

  • Login组件是应用程序的主页。它提示用户登录应用程序。
  • Register组件允许新用户在登录应用程序之前创建一个帐户。
  • Photos组件是用户认证后显示的主页。用户可以在此页面上查看所有可用的图片并进行点赞。
  • 只有UploadPhoto经过身份验证的用户才能看到,并允许用户将图像上传到 Web 应用程序上的照片列表。
  • MyPhoto页面允许用户仅查看他们上传的图像并与朋友分享他们的个人资料链接。
  • SharePhoto组件是一个动态路由,显示用户上传的所有图像。

更新App.js文件以通过 React Router 在不同的路由上呈现新创建的组件,如下所示:

import React from "react";
//👇🏻 React Router configuration & routes
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Photos from "./components/Photos";
import Login from "./components/Login";
import Register from "./components/Register";
import UploadPhoto from "./components/UploadPhoto";
import MyPhotos from "./components/MyPhotos";
import SharePhoto from "./components/SharePhoto";
//👇🏻 React Toastify configuration
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
//👇🏻 Websockets configuration
import { io } from "socket.io-client";

const App = () => {
    const socket = io.connect("http://localhost:4000");

    return (
        <>
            <BrowserRouter>
                <Routes>
                    <Route path='/' element={<Login socket={socket} />} />
                    <Route path='/register' element={<Register socket={socket} />} />
                    <Route path='/photos' element={<Photos socket={socket} />} />
                    <Route
                        path='/photo/upload'
                        element={<UploadPhoto socket={socket} />}
                    />
                    <Route path='/user/photos' element={<MyPhotos socket={socket} />} />
                    <Route path='/share/:user' element={<SharePhoto socket={socket} />} />
                </Routes>
            </BrowserRouter>
            <ToastContainer />
        </>
    );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

导航到src/index.css文件并复制以下代码。它包含设计此项目所需的所有 CSS。

@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap");
* {
    margin: 0;
    box-sizing: border-box;
    font-family: "Space Grotesk", sans-serif;
    color: #2b3a55;
}
body {
    padding: 0;
    margin: 0;
}
button {
    border: none;
    outline: none;
    cursor: pointer;
}
input {
    padding: 10px 15px;
}
.navbar {
    width: 100%;
    min-height: 10vh;
    padding: 20px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    background-color: #f2e5e5;
    position: sticky;
    top: 0;
    z-index: 10;
}
.nav__BtnGroup a,
.nav__BtnGroup button {
    padding: 15px;
    width: 200px;
    font-size: 16px;
    cursor: pointer;
    outline: none;
    background-color: #fff;
    border: none;
    border-radius: 3px;
    text-decoration: none;
}
.nav__BtnGroup a {
    margin-right: 10px;
}
.nav__BtnGroup a:hover,
.nav__BtnGroup button:hover {
    background-color: #ce7777;
    color: #fff;
}
.login,
.register {
    width: 100%;
    min-height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-direction: column;
}
.login__form,
.register__form {
    width: 500px;
    display: flex;
    flex-direction: column;
}
.input {
    padding: 12px 10px;
    margin-bottom: 20px;
    border-radius: 4px;
    border: 1px solid #e8c4c4;
}
.loginBtn,
.registerBtn {
    padding: 15px;
    font-size: 16px;
    cursor: pointer;
    background-color: #f2e5e5;
    color: #2b3a55;
    border: none;
    outline: none;
    border-radius: 5px;
    margin-bottom: 20px;
}
.loginBtn:hover,
.registerBtn:hover {
    background-color: #ce7777;
    color: #fff;
}
.link {
    color: #ce7777;
    cursor: pointer;
    text-decoration: none;
}
.photoContainer {
    width: 100%;
    min-height: 90vh;
    padding: 20px;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-wrap: wrap;
}
.photoContainer > * {
    margin: 15px;
}
.photo {
    width: 300px;
    height: 350px;
    position: relative;
    border-radius: 10px;
    box-shadow: 0 2px 8px 0 rgba(99, 99, 99, 0.2);
}
.imageContainer {
    width: 100%;
    position: relative;
    height: 100%;
}
.photo__image {
    width: 100%;
    height: 100%;
    position: absolute;
    object-fit: cover;
    border-radius: 10px;
}
.upvoteIcon {
    background-color: #fff;
    padding: 10px 20px;
    position: absolute;
    bottom: 5px;
    right: 5px;
    cursor: pointer;
    border-radius: 5px;
    display: flex;
    flex-direction: column;
    align-items: center;
}
.upvoteIcon:hover {
    background-color: #f2e5e5;
}

.uploadContainer {
    width: 100%;
    min-height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
}
.uploadText {
    width: 80%;
}
.uploadText form {
    display: flex;
    flex-direction: column;
}

.uploadText > h2 {
    margin-bottom: 20px;
}
.uploadBtn {
    margin-top: 20px;
    padding: 10px;
    background-color: #ce7777;
    color: #fff;
}
.copyDiv {
    width: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
}
.copyContainer {
    margin-top: 20px;
    padding: 15px;
    cursor: pointer;
    background-color: #ce7777;
    border-radius: 5px;
}
Enter fullscreen mode Exit fullscreen mode

登录页面

将以下代码复制到Login组件中。应用程序将接受用户的用户名和密码。

import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useNavigate } from "react-router-dom";

const Login = ({ socket }) => {
    const navigate = useNavigate();
    const [username, setUsername] = useState("");
    const [password, setPassword] = useState("");

    const handleSignIn = (e) => {
        if (username.trim() && password.trim()) {
            e.preventDefault();
            console.log({ username, password });
            setPassword("");
            setUsername("");
        }
    };
    return (
        <div className='login'>
            <h2 style={{ marginBottom: "30px" }}>Login</h2>
            <form className='login__form' method='POST' onSubmit={handleSignIn}>
                <label htmlFor='username'>Username</label>
                <input
                    type='text'
                    className='input'
                    name='username'
                    id='username'
                    required
                    value={username}
                    onChange={(e) => setUsername(e.target.value)}
                />
                <label htmlFor='password'>Password</label>
                <input
                    type='password'
                    className='input'
                    name='password'
                    id='password'
                    required
                    minLength={6}
                    value={password}
                    onChange={(e) => setPassword(e.target.value)}
                />
                <button className='loginBtn'>LOG IN</button>
                <p style={{ textAlign: "center" }}>
                    Don't have an account?{" "}
                    <Link className='link' to='/register'>
                        Create one
                    </Link>
                </p>
            </form>
        </div>
    );
};

export default Login;
Enter fullscreen mode Exit fullscreen mode

注册页面

注册页面

将以下代码复制到Register.js文件中以接受用户的电子邮件、用户名和密码。

import React, { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { useNavigate } from "react-router-dom";

const Register = ({ socket }) => {
    const navigate = useNavigate();
    const [username, setUsername] = useState("");
    const [password, setPassword] = useState("");
    const [email, setEmail] = useState("");

    const handleRegister = (e) => {
        e.preventDefault();
        if (username.trim() && password.trim() && email.trim()) {
            console.log({ username, email, password });
            setPassword("");
            setUsername("");
            setEmail("");
        }
    };
    return (
        <div className='register'>
            <h2 style={{ marginBottom: "30px" }}>Register</h2>
            <form className='register__form' method='POST' onSubmit={handleRegister}>
                <label htmlFor='email'>Email Address</label>
                <input
                    type='email'
                    className='input'
                    name='email'
                    id='email'
                    required
                    value={email}
                    onChange={(e) => setEmail(e.target.value)}
                />

                <label htmlFor='username'>Username</label>
                <input
                    type='text'
                    className='input'
                    name='username'
                    id='username'
                    required
                    value={username}
                    onChange={(e) => setUsername(e.target.value)}
                />

                <label htmlFor='password'>Password</label>
                <input
                    type='password'
                    className='input'
                    name='password'
                    id='password'
                    required
                    minLength={6}
                    value={password}
                    onChange={(e) => setPassword(e.target.value)}
                />
                <button className='registerBtn'>REGISTER</button>
                <p style={{ textAlign: "center" }}>
                    Already have an account?{" "}
                    <Link className='link' to='/'>
                        Sign in
                    </Link>
                </p>
            </form>
        </div>
    );
};

export default Register;
Enter fullscreen mode Exit fullscreen mode

注册页面

照片组件

该组件分为两个子组件,即导航和包含可用图像的主容器。

照片组件

将下面的代码复制到Photos.js文件中。

import React, { useEffect, useState } from "react";
import Nav from "./Nav";
import PhotoContainer from "./PhotoContainer";

const Home = ({ socket }) => {
    const [photos, setPhotos] = useState([
        {
            id: "1",
            image_url:
                "https://raw.githubusercontent.com/novuhq/blog/main/upvote-app-with-react-and-nodejs/server/images/dog1.jpg",
            vote_count: 0,
        },
        {
            id: "2",
            image_url:
                "https://raw.githubusercontent.com/novuhq/blog/main/upvote-app-with-react-and-nodejs/server/images/dog2.jpg",
            vote_count: 0,
        },
    ]);

    return (
        <div>
            <Nav />
            <PhotoContainer photos={photos} socket={socket} />
        </div>
    );
};

export default Home;
Enter fullscreen mode Exit fullscreen mode

创建 Nav 和 PhotoContainer 子组件。

touch Nav.js PhotoContainer.js
Enter fullscreen mode Exit fullscreen mode

将下面的代码复制到Nav.js文件中。

import React from "react";
import { Link } from "react-router-dom";

const Nav = () => {
    return (
        <nav className='navbar'>
            <h3>PhotoShare</h3>
            <div className='nav__BtnGroup'>
                <Link to='/user/photos' style={{ marginRight: "10px" }}>
                    My Photos
                </Link>
                <Link to='/photo/upload'>Upload Photo</Link>
            </div>
        </nav>
    );
};

export default Nav;
Enter fullscreen mode Exit fullscreen mode

更新PhotoContainer.js文件如下:

import React, { useEffect } from "react";
import { MdOutlineArrowUpward } from "react-icons/md";

const PhotoContainer = ({ photos, socket }) => {
    const handleUpvote = (id) => {
        console.log("Upvote", id);
    };

    return (
        <main className='photoContainer'>
            {photos.map((photo) => (
                <div className='photo' key={photo.id}>
                    <div className='imageContainer'>
                        <img
                            src={photo.image_url}
                            alt={photo.id}
                            className='photo__image'
                        />
                    </div>

                    <button className='upvoteIcon' onClick={() => handleUpvote(photo.id)}>
                        <MdOutlineArrowUpward
                            style={{ fontSize: "20px", marginBottom: "5px" }}
                        />
                        <p style={{ fontSize: "12px", color: "#ce7777" }}>
                            {photo.vote_count}
                        </p>
                    </button>
                </div>
            ))}
        </main>
    );
};

export default PhotoContainer;
Enter fullscreen mode Exit fullscreen mode

UploadPhoto 组件

更新UploadPhoto.js以包含以下代码:

import React, { useState } from "react";
import { useNavigate } from "react-router-dom";

const UploadPhoto = ({ socket }) => {
    const navigate = useNavigate();
    const [photoURL, setPhotoURL] = useState("");

    const handleSubmit = (e) => {
        e.preventDefault();
        console.log(photoURL);
    };

    return (
        <main className='uploadContainer'>
            <div className='uploadText'>
                <h2>Upload Image</h2>
                <form method='POST' onSubmit={handleSubmit}>
                    <label>Paste the image URL</label>
                    <input
                        type='text'
                        name='fileImage'
                        value={photoURL}
                        onChange={(e) => setPhotoURL(e.target.value)}
                    />
                    <button className='uploadBtn'>UPLOAD</button>
                </form>
            </div>
        </main>
    );
};

export default UploadPhoto;
Enter fullscreen mode Exit fullscreen mode

MyPhotos 组件

MyPhotos 组件

在这里,你将学习如何使用React-copy-to-clipboard库,通过单击按钮复制和粘贴内容 。在该MyPhotos组件中,用户可以复制自己的个人资料 URL 并与他人共享。

React-copy-to-clipboard是一个包,允许我们通过单击 React 中的按钮来复制和粘贴内容。

复制到剪贴板

 通过运行以下代码安装 React-copy-toclipboard库。

npm install react-copy-to-clipboard
Enter fullscreen mode Exit fullscreen mode

将下面的代码复制到MyPhotos.js文件中。

import React, { useEffect, useState } from "react";
//👇🏻 React Router configs
import { Link } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import PhotoContainer from "./PhotoContainer";
//👇🏻 React-copy-to-clipboard config
import { CopyToClipboard } from "react-copy-to-clipboard";

const MyPhotos = ({ socket }) => {
    const navigate = useNavigate();
    const [photos, setPhotos] = useState([]);
    const [userLink, setUserLink] = useState("");

    //👇🏻 navigates users to the homepage (for now)
    const handleSignOut = () => {
        localStorage.removeItem("_id");
        localStorage.removeItem("_myEmail");
        navigate("/");
    };

    //👇🏻 This function runs immediately the content is copied
    const copyToClipBoard = () => alert(`Copied ✅`);

    return (
        <div>
            <nav className='navbar'>
                <h3>PhotoShare</h3>

                <div className='nav__BtnGroup'>
                    <Link to='/photo/upload'>Upload Photo</Link>
                    <button onClick={handleSignOut}>Sign out</button>
                </div>
            </nav>

            <div className='copyDiv'>
                <CopyToClipboard
                    text={userLink}
                    onCopy={copyToClipBoard}
                    className='copyContainer'
                >
                    <span className='shareLink'>Copy your share link</span>
                </CopyToClipboard>
            </div>

            <PhotoContainer socket={socket} photos={photos} />
        </div>
    );
};

export default MyPhotos;
Enter fullscreen mode Exit fullscreen mode

从上面的代码片段中,是React-copy-to-clipboardCopyToClipboard提供的一个组件 ,它接受两个 props:- 内容和复制内容后运行的函数 - textonCopy

上面的代码片段代表了页面布局。我们将在后续部分中创建该功能。

SharePhoto 组件

将下面的代码复制到SharePhoto.js文件中。

import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import Nav from "./Nav";
import PhotoContainer from "./PhotoContainer";

const SharePhoto = ({ socket }) => {
    const navigate = useNavigate();
    const [photos, setPhotos] = useState([]);
    //👇🏻 This accepts the username from the URL (/share/:user)
    const { user } = useParams();

    return (
        <div>
            <Nav />
            <PhotoContainer socket={socket} photos={photos} />
        </div>
    );
};

export default SharePhoto;
Enter fullscreen mode Exit fullscreen mode

恭喜!🥂 您已完成点赞应用程序的用户界面。
在本文的剩余部分,您将学习如何在 React.js 应用程序和 Node.js 服务器之间发送数据。

如何使用 Socket.io 创建身份验证流程

在这里,我将指导您通过 Socket.io 创建身份验证流程。用户可以创建帐户,并登录和退出应用程序。

用户注册工作流程

更新组件handleRegister内的功能Register.js,将用户的电子邮件、用户名和密码发送到服务器。

const handleRegister = (e) => {
    e.preventDefault();
    if (username.trim() && password.trim() && email.trim()) {
        //👇🏻 triggers a register event
        socket.emit("register", { username, email, password });
        setPassword("");
        setUsername("");
        setEmail("");
    }
};
Enter fullscreen mode Exit fullscreen mode

监听服务器上的事件,如下所示:

socketIO.on("connection", (socket) => {
    console.log(`⚡: ${socket.id} user just connected!`);

    //👇🏻 Create a listener to the event
    socket.on("register", (data) => {
        /*
        👇🏻 data will be an object containing the data sent from the React app
        */
        console.log(data);
    });
    socket.on("disconnect", () => {
        socket.disconnect();
        console.log("🔥: A user disconnected");
    });
});
Enter fullscreen mode Exit fullscreen mode

我们已经成功检索了服务器上 React 应用发送的数据。接下来,让我们保存用户的详细信息。
首先,创建一个空数组来保存所有用户详细信息,并创建一个生成随机字符串作为 ID 的函数。

//👇🏻 outside the socket.io block
const database = [];
const generateID = () => Math.random().toString(36).substring(2, 10);
Enter fullscreen mode Exit fullscreen mode

更新事件监听器以保存用户的详细信息。

const database = [];
const generateID = () => Math.random().toString(36).substring(2, 10);

socket.on("register", (data) => {
    //👇🏻 Destructure the user details from the object
    const { username, email, password } = data;

    //👇🏻 Filters the database (array) to check if there is no existing user with the same email or username
    let result = database.filter(
        (user) => user.email === email || user.username === username
    );
    //👇🏻 If none, saves the data to the array. (the empty images array is required for the image uploads)
    if (result.length === 0) {
        database.push({
            id: generateID(),
            username,
            password,
            email,
            images: [],
        });
        //👇🏻 returns an event stating that the registration was successful
        return socket.emit("registerSuccess", "Account created successfully!");
    }
    //👇🏻 This runs only when there is an error/the user already exists
    socket.emit("registerError", "User already exists");
});
Enter fullscreen mode Exit fullscreen mode

接下来,监听用户在 Web 应用程序上注册时可能触发的两个事件。

//👇🏻 Import toast from React Toastify
import { toast } from "react-toastify";

//👇🏻 Add a useEffect hook that listens to both both events
useEffect(() => {
    socket.on("registerSuccess", (data) => {
        toast.success(data);
        //👇🏻 navigates to the login page
        navigate("/");
    });
    socket.on("registerError", (error) => {
        toast.error(error);
    });
}, [socket, navigate]);
Enter fullscreen mode Exit fullscreen mode

登录工作流程

更新组件handleSignIn内的功能Login.js,将用户的用户名和密码发送到后端服务器。

const handleSignIn = (e) => {
    if (username.trim() && password.trim()) {
        e.preventDefault();
        //👇🏻 triggers a login event
        socket.emit("login", { username, password });
        setPassword("");
        setUsername("");
    }
};
Enter fullscreen mode Exit fullscreen mode

在服务器上创建该事件的监听器。

socketIO.on("connection", (socket) => {
    //...other functions

    socket.on("login", (data) => {
        //👇🏻 data - contains the username and password
        console.log(data)
    })
}
Enter fullscreen mode Exit fullscreen mode

更新事件监听器以将用户登录到 Web 应用程序,如下所示:

socket.on("login", (data) => {
    //👇🏻 Destructures the credentials from the object
    const { username, password } = data;

    //👇🏻 Filters the array for existing objects with the same email and password

    let result = database.filter(
        (user) => user.username === username && user.password === password
    );
    //👇🏻 If there is none, it returns this error message
    if (result.length !== 1) {
        return socket.emit("loginError", "Incorrect credentials");
    }
    //👇🏻 Returns the user's email & id if the user exists
    socket.emit("loginSuccess", {
        message: "Login successfully",
        data: {
            _id: result[0].id,
            _email: result[0].email,
        },
    });
});
Enter fullscreen mode Exit fullscreen mode

听听Login.js文件中可能发生的两件事。

useEffect(() => {
    socket.on("loginSuccess", (data) => {
        toast.success(data.message);
        //👇🏻 Saves the user's id and email to local storage for easy identification & for making authorized requests
        localStorage.setItem("_id", data.data._id);
        localStorage.setItem("_myEmail", data.data._email);
        //👇🏻 Redirects the user to the Photos component
        navigate("/photos");
    });
    //👇🏻 Notifies the user of the error message
    socket.on("loginError", (error) => {
        toast.error(error);
    });
}, [socket, navigate]);
Enter fullscreen mode Exit fullscreen mode

恭喜!我们已经为应用程序创建了身份验证流程。
最后,让我们通过仅允许经过身份验证的用户查看页面来保护剩余的路由。将下面的代码片段复制到UploadPhotoMyPhotosSharePhotoPhotos组件中。

useEffect(() => {
    function authenticateUser() {
        const id = localStorage.getItem("_id");
        /*
        👇🏻 If ID is false, redirects the user to the login page
        */
        if (!id) {
            navigate("/");
        }
    }
    authenticateUser();
}, [navigate]);
Enter fullscreen mode Exit fullscreen mode

使用 Socket.io 添加和显示图像

在本节中,您将学习如何将图像上传到服务器并通过 Socket.io 在 React 应用程序中显示它们。

通过Socket.io上传图像到服务器

导航到UploadPhoto组件并更新handleSubmit功能如下:

const handleSubmit = (e) => {
    e.preventDefault();
    //👇🏻 Gets the id and email from the local storage
    const id = localStorage.getItem("_id");
    const email = localStorage.getItem("_myEmail");
    /*
    👇🏻 triggers an event to the server 
    containing the user's credentials and the image url 
    */
    socket.emit("uploadPhoto", { id, email, photoURL });
};
Enter fullscreen mode Exit fullscreen mode

在服务器上创建将图像添加到数据库的事件监听器。

socket.on("uploadPhoto", (data) => {
    //👇🏻 Gets the id, email, and image URL
    const { id, email, photoURL } = data;
    //👇🏻 Search the database for the user
    let result = database.filter((user) => user.id === id);
    //👇🏻 creates the data structure for the image
    const newImage = {
        id: generateID(),
        image_url: photoURL,
        vote_count: 0,
        votedUsers: [],
        _ref: email,
    };
    //👇🏻 adds the new image to the images array
    result[0]?.images.unshift(newImage);
    //👇🏻 sends a new event containing the server response
    socket.emit("uploadPhotoMessage", "Upload Successful!");
});
Enter fullscreen mode Exit fullscreen mode

在 React 应用程序中监听服务器的响应。

useEffect(() => {
    socket.on("uploadPhotoMessage", (data) => {
        //👇🏻 Displays the server's response
        toast.success(data);
        navigate("/photos");
    });
}, [socket, navigate]);
Enter fullscreen mode Exit fullscreen mode

在 React 应用程序中显示图像

在这里,我们将更新PhotosMyPhotosSharePhoto组件以显示图像。

  • Photos组件显示应用程序内所有可用的照片。
  • MyPhotos组件仅显示用户上传的图像。
  • SharePhoto组件显示用户通过其用户名上传的图像。

照片组件

在文件中添加一个 useEffect 钩子Photos.js,当组件安装时从服务器检索所有图像。

useEffect(() => {
    //👇🏻 search can be anything
    socket.emit("allPhotos", "search");
}, [socket]);
Enter fullscreen mode Exit fullscreen mode

监听事件并返回服务器上所有可用的图像。

socket.on("allPhotos", (data) => {
    //👇🏻 an array to contain all the images
    let images = [];
    //👇🏻 loop through the items in the database
    for (let i = 0; i < database.length; i++) {
        //👇🏻 collect the images into the array
        images = images.concat(database[i]?.images);
    }
    //👇🏻 sends all the images through another event
    socket.emit("allPhotosMessage", {
        message: "Photos retrieved successfully",
        photos: images,
    });
});
Enter fullscreen mode Exit fullscreen mode

更新组件内的 useEffect 钩子Photos以检索图像,如下所示:

useEffect(() => {
    socket.emit("allPhotos", "search");
    //👇🏻 retrieve all the images from the server
    socket.on("allPhotosMessage", (data) => {
        setPhotos(data.photos);
    });
}, [socket]);
Enter fullscreen mode Exit fullscreen mode

MyPhotos 组件

更新组件内的 useEffect 钩子MyPhotos以触发通过 Socket.io 将用户 ID 发送到服务器的事件。

useEffect(() => {
    function authenticateUser() {
        const id = localStorage.getItem("_id");
        if (!id) {
            navigate("/");
        } else {
            //👇🏻 sends the user id to the server
            socket.emit("getMyPhotos", id);
        }
    }
    authenticateUser();
}, [navigate, socket]);
Enter fullscreen mode Exit fullscreen mode

监听服务器上的事件并返回用户的图像。

socket.on("getMyPhotos", (id) => {
    //👇🏻 Filter the database items
    let result = database.filter((db) => db.id === id);
    //👇🏻 Returns the images and the username
    socket.emit("getMyPhotosMessage", {
        data: result[0]?.images,
        username: result[0]?.username,
    });
});
Enter fullscreen mode Exit fullscreen mode

按照以下步骤从服务器检索图像和用户名:

useEffect(() => {
    socket.on("getMyPhotosMessage", (data) => {
        //👇🏻 sets the user's images
        setPhotos(data.data);
        //👇🏻 sets the user's profile link
        setUserLink(`http://localhost:3000/share/${data.username}`);
    });
}, [socket]);
Enter fullscreen mode Exit fullscreen mode

SharePhoto 组件

更新组件useEffect内的钩子SharePhoto,以便在组件安装时请求用户的图像。

useEffect(() => {
    function authenticateUser() {
        const id = localStorage.getItem("_id");
        if (!id) {
            navigate("/");
        } else {
            //👇🏻 user - is the username from the profile link
            socket.emit("sharePhoto", user);
        }
    }
    authenticateUser();
}, [socket, navigate, user]);
Enter fullscreen mode Exit fullscreen mode

监听服务器上的事件并返回用户的图像。

socket.on("sharePhoto", (name) => {
    //👇🏻 Filters the database via the username
    let result = database.filter((db) => db.username === name);
    //👇🏻 Returns the images via another event
    socket.emit("sharePhotoMessage", result[0]?.images);
});
Enter fullscreen mode Exit fullscreen mode

从服务器检索图像如下:

useEffect(() => {
    socket.on("sharePhotoMessage", (data) => setPhotos(data));
}, [socket]);
Enter fullscreen mode Exit fullscreen mode

恭喜你完成了这么多!😊 你已经学会了如何操作数据库中的数据,以及如何检索每条路线的商品。在接下来的部分中,我将指导你如何为图片点赞。

如何为图片点赞

现在,我将指导您在 Web 应用程序中为图片点赞。请记住,用户无法为照片点赞,并且只能点赞一次。

更新handleUpvote其中的功能PhotoContainer.js以将用户和图像 ID 发送到服务器。

const handleUpvote = (id) => {
    socket.emit("photoUpvote", {
        userID: localStorage.getItem("_id"),
        photoID: id,
    });
};
Enter fullscreen mode Exit fullscreen mode

监听服务器上的事件并通过其 ID 对选定的图像进行点赞。

socket.on("photoUpvote", (data) => 
    const { userID, photoID } = data;
    let images = [];
    //👇🏻 saves all the images not belonging to the user into the images array
    for (let i = 0; i < database.length; i++) {
        //👇🏻 ensures that only other users' images are separated into the images array
        if (!(database[i].id === userID)) {
            images = images.concat(database[i]?.images);
        }
    }
    //👇🏻 Filter the images array for the image selected for upvote
    const item = images.filter((image) => image.id === photoID);
    /*
    👇🏻 Returns this error if the selected image doesn't belong to other users
    */
    if (item.length < 1) {
        return socket.emit("upvoteError", {
            error_message: "You cannot upvote your photos",
        });
    }
    //👇🏻 Gets the list of voted users from the selected image
    const voters = item[0]?.votedUsers;
    //👇🏻 Checks if the user has not upvoted the image before
    const authenticateUpvote = voters.filter((voter) => voter === userID);
    //👇🏻 If true (the first time the user is upvoting the image)
    if (!authenticateUpvote.length) {
        //👇🏻 increases the vote count
        item[0].vote_count += 1;
        //👇🏻 adds the user ID to the list of voters
        voters.push(userID);
        //👇🏻 triggers this event to reflect the change in vote count
        socket.emit("allPhotosMessage", {
            message: "Photos retrieved successfully",
            photos: images,
        });
        //👇🏻 Returns the upvote response
        return socket.emit("upvoteSuccess", {
            message: "Upvote successful",
            item,
        });
    }
    /*
    👇🏻 nullifies duplicate votes. (if the user ID already exists in the array of voted users)
    */
    socket.emit("upvoteError", {
        error_message: "Duplicate votes are not allowed",
    });
});
Enter fullscreen mode Exit fullscreen mode

监听文件中服务器可能的响应PhotoContainer.js

useEffect(() => {
    socket.on("upvoteSuccess", (data) => {
        toast.success(data.message);
        //👇🏻 logs the email of the user who owns the image.
        console.log(data.item[0]._ref);
    });
    socket.on("upvoteError", (data) => {
        toast.error(data.error_message);
    });
}, [socket]);
Enter fullscreen mode Exit fullscreen mode

从上面的代码片段来看,data.item[0]._ref这是必需的,因为我们希望在用户赞同他们的图像时向他们发送电子邮件通知。

如何在 React 中通过 EmailJS 发送电子邮件

EmailJS 是一个 JavaScript 库,它使我们能够仅通过客户端技术发送电子邮件,而无需服务器。使用 EmailJS,您可以发送文本和电子邮件模板,并在电子邮件中添加附件。

在这里,我将指导您将 EmailJS 添加到 React.js 应用程序,以及如何在用户的图像获得投票时向用户发送电子邮件。

通过运行以下代码将 EmailJS 安装到 React 应用程序:

npm install @emailjs/browser
Enter fullscreen mode Exit fullscreen mode

在此创建一个 EmailJS 帐户  并将电子邮件服务提供商添加到您的帐户。

创建一个电子邮件模板,如下图所示:

EmailJS

更新PhotoContainer.js文件以便在用户的图像获得投票时向用户发送电子邮件模板。

import emailjs from "@emailjs/browser";

const PhotoContainer = ({ photos, socket }) => {
    const handleUpvote = (id) => {
        socket.emit("photoUpvote", {
            userID: localStorage.getItem("_id"),
            photoID: id,
        });
    };

    //👇🏻 The function sends email to the user - (to_email key)
    const sendEmail = (email) => {
        emailjs
            .send(
                "YOUR_SERVICE_ID",
                "YOUR_TEMPLATE_ID",
                {
                    to_email: email,
                    from_email: localStorage.getItem("_myEmail"),
                },
                "YOUR_PUBLIC_KEY"
            )
            .then(
                (result) => {
                    console.log(result.text);
                },
                (error) => {
                    console.log(error.text);
                }
            );
    };

    useEffect(() => {
        socket.on("upvoteSuccess", (data) => {
            toast.success(data.message);
            //👇🏻 Pass the image owner email into the function
            sendEmail(data.item[0]._ref);
        });
        socket.on("upvoteError", (data) => {
            toast.error(data.error_message);
        });
    }, [socket]);
    return <div>...</div>;
};
Enter fullscreen mode Exit fullscreen mode

您可以从 EmailJS 仪表板的帐户部分获取 EmailJS 公钥。

恭喜!您已完成本教程的项目。

结论

到目前为止,您已经学习了以下内容:

  • 如何在 React 和 Node.js 应用程序中设置 Socket.io,
  • 使用 Socket.io 和 React 创建身份验证,
  • 通过 Socket.io 在服务器和客户端之间进行通信,并且
  • 使用 EmailJS 发送电子邮件。

本教程将引导您完成一个使用 Socket.io 和 React 构建的项目。您可以随意添加身份验证库和实时数据库来改进应用程序。

本教程的源代码可以在这里找到:

https://github.com/novuhq/blog/tree/main/upvote-app-with-react-and-nodejs

感谢您的阅读!

帮帮我!

如果您觉得这篇文章帮助您更好地理解了 WebSocket!请给我们一个 Star,我会非常高兴!也请在评论区告诉我❤️
https://github.com/novuhq/novu

图片描述

文章来源:https://dev.to/novu/creating-an-upvote-system-with-react-and-socketio-5aoj
PREV
如何使用 Socket.io 和 React.js 构建实时拍卖系统
NEXT
使用 ChatGPT、React 和 Node.js 创建网站聚合器 🚀