在这篇文章中,我们将使用Socket.io和HarperDB来构建一个带有聊天室的全栈式实时聊天应用程序。
这将是一个很好的项目,可以学习如何把全栈应用放在一起,以及如何创建一个后端可以与前端实时通信的应用。
通常情况下,使用HTTP请求,服务器不能实时推送数据给客户端。但使用Socket.io,服务器就能将服务器上发生的一些事件的实时信息推送给客户端。
我们要建立的应用程序将有两个页面。
一个是加入聊天室页面:
和一个聊天室页面:
以下是我们将用来建立这个应用程序的内容。
- 前台。React(一个用于构建交互式应用程序的前端JavaScript框架)
- 后台。Node和Express(Express是非常流行的NodeJS框架,允许我们轻松创建API和后端)
- 数据库 。HarperDB(一个数据+应用平台,允许你使用SQL或NoSQL来查询数据。HarperDB也有一个内置的API,使我们不必编写大量的后端代码)
- 实时通信。Socket.io(见下文!)。
这里是源代码(记得给它打星⭐)。
什么是Socket.IO?
Socket.IO允许服务器在服务器上发生事件时,实时向客户端推送信息。
例如,如果你在玩一个多人游戏,一个事件可能是你的 "朋友 "对你打进了一个精彩的球。
有了Socket.IO,你就会(几乎)立即知道丢球的情况。
如果没有Socket.IO,客户端将不得不进行多次轮询的AJAX调用,以验证服务器上是否发生了事件。例如,客户端可以使用JavaScript来检查服务器上每5秒发生的事件。
Socket.IO意味着客户端不必进行多次轮询的AJAX调用来验证服务器上是否发生了某些事件。相反,服务器在获得信息后会立即将其发送给客户端。好多了。👌
因此,Socket.IO允许我们轻松地建立实时应用程序,如聊天应用程序和多人游戏。
项目设置
1.如何设置我们的文件夹
在你选择的文本编辑器中开始一个新项目(对我来说是VS Code),并在根部创建两个文件夹,称为客户端和服务器:
我们将在客户端文件夹中创建我们的前端React应用程序,并在服务器文件夹中创建我们的Node/Express后端。
2.如何安装我们的客户端依赖项
在项目的根部打开一个终端(在VS Code中,你可以通过按Ctrl+'或进入终端->**新终端来完成)。
接下来,我们将安装React到我们的客户端目录:
$ npx create-react-app client
在React安装完毕后,改变目录进入客户端文件夹,并安装以下依赖项:
$ cd client
$ npm i react-router-dom socket.io-client
React-router-dom将允许我们为不同的React组件设置路由--基本上是创建不同的页面。
Socket.io-client是socket.io的客户端版本,它允许我们向服务器 "发射 "事件。一旦被服务器接收,我们就可以使用socket.io的服务器版本来做一些事情,比如向与发送者在同一个房间的用户发送消息,或者将一个用户加入到一个socket房间。
稍后当我们用代码实现这些想法时,你会对此有更好的理解。
3.如何启动React应用程序
让我们通过在客户端目录下运行以下命令来检查,确保一切正常:
$ npm start
Webpack将构建React应用并将其提供给http://localhost:3000。
现在让我们来设置我们的HarperDB数据库,我们将用它来永久保存用户发送的信息。
如何设置HarperDB
首先,在HarperDB创建一个账户。
然后创建一个新的HarperDB云实例:
为了方便起见,选择云实例:
选择云提供商(我选择了AWS):
命名你的云实例,并创建你的实例凭证:
HarperDB有一个慷慨的免费层,我们可以为这个项目使用,所以选择它:
检查你的细节是否正确,然后创建实例。
创建实例需要几分钟的时间,所以让我们开始制作我们的第一个React组件吧!
如何建立 "加入一个房间 "页面
我们的主页最终会是这样的:
用户将输入一个用户名,从下拉菜单中选择一个聊天室,然后点击 "加入房间"。然后用户将被带到聊天室页面。
因此,让我们来制作这个主页。
1.如何创建HTML表格并添加样式
在src/pages/home/index.js创建一个新文件*。*
我们将使用CSS模块为我们的应用程序添加基本的样式,所以创建一个新文件:src/pages/home/styles.module.css。
我们的文件夹结构现在应该是这样的:
现在我们来创建基本的表单HTML:
// client/src/pages/home/index.js
import styles from './styles.module.css';
const Home = () => {
return (
<div className={styles.container}>
<div className={styles.formContainer}>
<h1>{`<>DevRooms</>`}</h1>
<input className={styles.input} placeholder='Username...' />
<select className={styles.input}>
<option>-- Select Room --</option>
<option value='javascript'>JavaScript</option>
<option value='node'>Node</option>
<option value='express'>Express</option>
<option value='react'>React</option>
</select>
<button className='btn btn-secondary'>Join Room</button>
</div>
</div>
);
};
export default Home;
上面,我们有一个简单的文本输入来获取用户名,还有一个带有一些默认选项的选择下拉框,供用户选择加入的聊天室。
现在让我们把这个组件导入App.js,并使用react-router-dom包为该组件设置一个路由。这将是我们的主页,所以路径将是"/"。
// client/src/App.js
import './App.css';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Home from './pages/home';
function App() {
return (
<Router>
<div className='App'>
<Routes>
<Route path='/' element={<Home />} />
</Routes>
</div>
</Router>
);
}
export default App;
现在让我们添加一些基本样式,使我们的应用程序看起来更有吸引力。
/* client/src/App.css */
html * {
font-family: Arial;
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
overflow: hidden;
background: rgb(63, 73, 204);
}
::-webkit-scrollbar {
width: 20px;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar-thumb {
background-color: #d6dee1;
border-radius: 20px;
border: 6px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: #a8bbbf;
}
.btn {
padding: 14px 14px;
border-radius: 6px;
font-weight: bold;
font-size: 1.1rem;
cursor: pointer;
border: none;
}
.btn-outline {
color: rgb(153, 217, 234);
border: 1px solid rgb(153, 217, 234);
background: rgb(63, 73, 204);
}
.btn-primary {
background: rgb(153, 217, 234);
color: rgb(0, 24, 111);
}
.btn-secondary {
background: rgb(0, 24, 111);
color: #fff;
}
让我们也为我们的主页组件添加特定的样式。
/* client/src/pages/home/styles.module.css */
.container {
height: 100vh;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
background: rgb(63, 73, 204);
}
.formContainer {
width: 400px;
margin: 0 auto 0 auto;
padding: 32px;
background: lightblue;
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
gap: 28px;
}
.input {
width: 100%;
padding: 12px;
border-radius: 6px;
border: 1px solid rgb(63, 73, 204);
font-size: 0.9rem;
}
.input option {
margin-top: 20px;
}
让我们也通过添加一个样式属性使 "加入房间 "按钮变成全宽。
// client/src/pages/home/index.js
<button className='btn btn-secondary' style={{ width: '100%' }}>Join Room</button>
我们的主页现在看起来很牢固。
2.如何为 "加入房间 "表单添加功能
现在我们有了一个基本的表单和样式,所以是时候添加一些功能了。
以下是我们希望在用户点击 "加入房间 "按钮时发生的情况。
- 检查用户名和房间字段是否已填写。
- 如果是,我们向服务器发出一个套接字事件。
- 将用户重定向到聊天页面(我们将在后面创建)。
我们将需要创建一些状态来存储用户名和房间 的值。我们还需要创建一个套接字实例。
我们可以直接在我们的主页组件中创建这些状态,但我们的聊天页面也需要访问用户名、房间 和套接字。所以我们将把状态提升到App.js,在那里我们可以把这些变量传递给主页和聊天页面组件。
所以,让我们在App.js中创建我们的状态并设置一个套接字,然后将这些变量作为道具传递给<首页>组件。我们还将传递set state函数,这样我们就可以从改变状态。
// client/src/App.js
import './App.css';
import { useState } from 'react'; // Add this
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import io from 'socket.io-client'; // Add this
import Home from './pages/home';
const socket = io.connect('http://localhost:4000'); // Add this -- our server will run on port 4000, so we connect to it from here
function App() {
const [username, setUsername] = useState(''); // Add this
const [room, setRoom] = useState(''); // Add this
return (
<Router>
<div className='App'>
<Routes>
<Route
path='/'
element={
<Home
username={username} // Add this
setUsername={setUsername} // Add this
room={room} // Add this
setRoom={setRoom} // Add this
socket={socket} // Add this
/>
}
/>
</Routes>
</div>
</Router>
);
}
export default App;
现在我们可以在我们的主页组件中访问这些prop。我们将使用析构法来获得这些道具。
// client/src/pages/home/index.js
import styles from './style.module.css';
const Home = ({ username, setUsername, room, setRoom, socket }) => {
return (
// ...
);
};
export default Home;
当用户输入他们的用户名或选择一个房间时,我们需要更新用户名 和房间 的状态变量。
// client/src/pages/home/index.js
// ...
const Home = ({ username, setUsername, room, setRoom, socket }) => {
return (
<div className={styles.container}>
// ...
<input
className={styles.input}
placeholder='Username...'
onChange={(e) => setUsername(e.target.value)} // Add this
/>
<select
className={styles.input}
onChange={(e) => setRoom(e.target.value)} // Add this
>
// ...
</select>
// ...
</div>
);
};
export default Home;
现在我们捕捉到了用户输入的数据,我们可以为用户点击 "加入房间 "按钮时创建一个joinRoom() 回调函数。
// client/src/pages/home/index.js
// ...
const Home = ({ username, setUsername, room, setRoom, socket }) => {
// Add this
const joinRoom = () => {
if (room !== '' && username !== '') {
socket.emit('join_room', { username, room });
}
};
return (
<div className={styles.container}>
// ...
<button
className='btn btn-secondary'
style={{ width: '100%' }}
onClick={joinRoom} // Add this
>
Join Room
</button>
// ...
</div>
);
};
export default Home;
以上,当用户点击按钮时,一个名为join_room的套接字事件被发出,同时还有一个包含用户的用户名和选定房间的对象。这个事件将在稍后被我们的服务器接收,在那里我们将施展一些魔法。
为了完成我们的主页组件,我们需要在joinRoom()函数的底部添加一个重定向,将用户带到/chat页面。
// client/src/pages/home/index.js
// ...
import { useNavigate } from 'react-router-dom'; // Add this
const Home = ({ username, setUsername, room, setRoom, socket }) => {
const navigate = useNavigate(); // Add this
const joinRoom = () => {
if (room !== '' && username !== '') {
socket.emit('join_room', { username, room });
}
// Redirect to /chat
navigate('/chat', { replace: true }); // Add this
};
// ...
测试一下:输入一个用户名并选择一个房间,然后点击加入房间。你应该被带到路线http://localhost:3000/chat--目前是一个空页面。
但在我们创建聊天页面前端之前,让我们在服务器上运行一些东西。
如何设置服务器
在服务器上,我们要监听从前端发出的套接字事件。目前,我们只有一个从React发出的join_room事件,所以我们将首先添加这个事件监听器。
但在此之前,我们需要安装我们的服务器依赖,并让服务器启动和运行。
1.如何安装服务器的依赖性
打开一个新的终端(在VS代码中:终端->新终端),改变目录进入我们的服务器文件夹,初始化一个package.json文件,并安装以下依赖项。
$ cd server
$ npm init -y
$ npm i axios cors express socket.io dotenv
- Axios是一个常用的包,可以方便地对API进行请求。
- Cors允许我们的客户端向其他来源发出请求--这对socket.io的正常工作是必要的。如果你以前没有听说过CORS,请看什么是CORS。
- Express是一个NodeJS框架,允许我们用更少的代码更容易地编写我们的后端。
- Socket.io是一个允许客户端和服务器实时通信的库--这在标准HTTP请求中是不可能的。
- Dotenv是一个模块,它允许我们安全地存储私钥和密码,并在需要时将它们载入我们的代码中。
我们还将安装nodemon作为一个开发依赖,这样我们就不必在每次对代码进行修改时重新启动我们的服务器--节省我们的时间和精力。
$ npm i -D nodemon
2.如何启动我们的服务器
在我们的服务器根目录下创建一个名为index.js的文件夹,并添加以下代码来启动和运行一个服务器。
// server/index.js
const express = require('express');
const app = express();
const http = require('http');
const cors = require('cors');
app.use(cors()); // Add cors middleware
const server = http.createServer(app);
server.listen(4000, () => 'Server is running on port 4000');
打开我们服务器上的package.json文件,并添加一个脚本,让我们在开发中使用nodemon。
{
...
"scripts": {
"dev": "nodemon index.js"
},
...
}
现在,让我们通过运行以下命令来启动我们的服务器。
$ npm run dev
我们可以通过添加一个get请求处理程序来快速检查我们的服务器是否正常运行。
// server/index.js
const express = require('express');
const app = express();
http = require('http');
const cors = require('cors');
app.use(cors()); // Add cors middleware
const server = http.createServer(app);
// Add this
app.get('/', (req, res) => {
res.send('Hello world');
});
server.listen(4000, () => 'Server is running on port 3000');
我们的服务器已经启动并运行了。现在是时候做一些服务器端的Socket.io的事情了!
如何在服务器上创建我们的第一个Socket.io事件监听器
还记得我们从客户端发出了一个join_room事件吗?那么,我们很快就会在服务器上监听该事件,并将用户添加到一个套接字房间。
但首先,我们需要监听客户端通过socket.io-client连接到服务器的情况。
// server/index.js
const express = require('express');
const app = express();
http = require('http');
const cors = require('cors');
const { Server } = require('socket.io'); // Add this
app.use(cors()); // Add cors middleware
const server = http.createServer(app); // Add this
// Add this
// Create an io server and allow for CORS from http://localhost:3000 with GET and POST methods
const io = new Server(server, {
cors: {
origin: 'http://localhost:3000',
methods: ['GET', 'POST'],
},
});
// Add this
// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
console.log(`User connected ${socket.id}`);
// We can write our socket event listeners in here...
});
server.listen(4000, () => 'Server is running on port 3000');
现在,当客户端从前端连接时,后端会捕捉到连接事件,并将该特定客户端的唯一套接字ID记录到User connected 。
让我们测试一下服务器现在是否从客户端捕捉到了连接事件。转到你的React应用程序http://localhost:3000/,刷新页面。
你应该在你的服务器终端控制台看到以下日志。
太棒了,我们的客户端已经通过socket.io连接到我们的服务器。我们的客户端和服务器现在可以进行实时通信了!
房间如何在Socket.io中工作
来自Socket.io的文档。
"一个房间是一个任意的通道,套接字可以
join和leave。它可以用来向一个客户端的子集广播事件。"
因此,我们可以将用户加入到一个房间,然后服务器可以向该房间的所有用户发送消息--允许用户实时地相互发送消息。爽啊!
如何将用户加入Socket.io房间
一旦用户通过Socket.io连接,我们可以在服务器上添加我们的Socket事件监听器,以监听从客户端发出的事件。此外,我们还可以在服务器上发出事件,并在客户端监听这些事件。
现在让我们来监听join_room事件,捕获数据(用户名和房间),并将用户添加到一个socket房间。
// server/index.js
// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
console.log(`User connected ${socket.id}`);
// Add this
// Add a user to a room
socket.on('join_room', (data) => {
const { username, room } = data; // Data sent from client when join_room event emitted
socket.join(room); // Join the user to a socket room
});
});
如何向房间里的用户发送消息
现在让我们向房间里的所有用户发送一条消息,除了刚刚加入的用户之外,通知他们有新的用户加入。
// server/index.js
const CHAT_BOT = 'ChatBot'; // Add this
// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
console.log(`User connected ${socket.id}`);
// Add a user to a room
socket.on('join_room', (data) => {
const { username, room } = data; // Data sent from client when join_room event emitted
socket.join(room); // Join the user to a socket room
// Add this
let __createdtime__ = Date.now(); // Current timestamp
// Send message to all users currently in the room, apart from the user that just joined
socket.to(room).emit('receive_message', {
message: `${username} has joined the chat room`,
username: CHAT_BOT,
__createdtime__,
});
});
});
上面,我们向当前用户刚刚加入的房间里的所有客户端发送一个receive_message事件,同时发送一些数据:消息,发送消息的用户名,以及消息的发送时间。
我们稍后将在我们的React应用程序中添加一个事件监听器来捕获这个事件,并在屏幕上输出消息。
让我们也向新加入的用户发送一条欢迎信息。
// server/index.js
io.on('connection', (socket) => {
// ...
// Add this
// Send welcome msg to user that just joined chat only
socket.emit('receive_message', {
message: `Welcome ${username}`,
username: CHAT_BOT,
__createdtime__,
});
});
});
当我们向Socket.io房间添加用户时,Socket.io只存储每个用户的socket id。但我们将需要房间里每个人的用户名,以及房间名称。所以,让我们把这些数据存储在服务器上的变量中。
// server/index.js
// ...
const CHAT_BOT = 'ChatBot';
// Add this
let chatRoom = ''; // E.g. javascript, node,...
let allUsers = []; // All users in current chat room
// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
// ...
// Add this
// Save the new user to the room
chatRoom = room;
allUsers.push({ id: socket.id, username, room });
chatRoomUsers = allUsers.filter((user) => user.room === room);
socket.to(room).emit('chatroom_users', chatRoomUsers);
socket.emit('chatroom_users', chatRoomUsers);
});
});
上面,我们还通过chatroom_users事件向客户端发送一个包含所有chatRoomUsers的数组,所以我们可以在前端列出房间里的所有用户名。
在我们向服务器添加更多代码之前,让我们回到前端并创建聊天页面--这样我们就可以测试出我们是否收到了receive_message事件。
如何建立聊天页面
在你的客户端文件夹中,创建两个新文件。
- src/pages/chat/index.js
- src/pages/chat/styles.module.css
让我们添加一些我们将在聊天页面和组件中使用的样式。
/* client/src/pages/chat/styles.module.css */
.chatContainer {
max-width: 1100px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 4fr;
gap: 20px;
}
/* Room and users component */
.roomAndUsersColumn {
border-right: 1px solid #dfdfdf;
}
.roomTitle {
margin-bottom: 60px;
text-transform: uppercase;
font-size: 2rem;
color: #fff;
}
.usersTitle {
font-size: 1.2rem;
color: #fff;
}
.usersList {
list-style-type: none;
padding-left: 0;
margin-bottom: 60px;
color: rgb(153, 217, 234);
}
.usersList li {
margin-bottom: 12px;
}
/* Messages */
.messagesColumn {
height: 85vh;
overflow: auto;
padding: 10px 10px 10px 40px;
}
.message {
background: rgb(0, 24, 111);
border-radius: 6px;
margin-bottom: 24px;
max-width: 600px;
padding: 12px;
}
.msgMeta {
color: rgb(153, 217, 234);
font-size: 0.75rem;
}
.msgText {
color: #fff;
}
/* Message input and button */
.sendMessageContainer {
padding: 16px 20px 20px 16px;
}
.messageInput {
padding: 14px;
margin-right: 16px;
width: 60%;
border-radius: 6px;
border: 1px solid rgb(153, 217, 234);
font-size: 0.9rem;
}
现在,让我们看看我们的聊天页面最终会是什么样子。
把这个页面的所有代码和逻辑添加到一个文件中可能会变得混乱和难以管理,所以让我们利用我们正在使用一个很棒的前端框架(React) , 把我们的页面分成几个组件。
聊天页面组件。
A: 包含房间名称,该房间的用户列表,以及一个将用户从房间中移除的 "离开 "按钮。
B: 发送的信息。初次呈现时,将从数据库中获取该房间发送的最后100条消息并显示给用户。
C: 一个输入和按钮,用来输入和发送消息。
我们将首先创建组件B,这样我们就可以向用户显示消息。
如何创建消息组件(B)
在 src/pages/chat/messages.js 创建一个新文件,并添加以下代码。
// client/src/pages/chat/messages.js
import styles from './styles.module.css';
import { useState, useEffect } from 'react';
const Messages = ({ socket }) => {
const [messagesRecieved, setMessagesReceived] = useState([]);
// Runs whenever a socket event is recieved from the server
useEffect(() => {
socket.on('receive_message', (data) => {
console.log(data);
setMessagesReceived((state) => [
...state,
{
message: data.message,
username: data.username,
__createdtime__: data.__createdtime__,
},
]);
});
// Remove event listener on component unmount
return () => socket.off('receive_message');
}, [socket]);
// dd/mm/yyyy, hh:mm:ss
function formatDateFromTimestamp(timestamp) {
const date = new Date(timestamp);
return date.toLocaleString();
}
return (
<div className={styles.messagesColumn}>
{messagesRecieved.map((msg, i) => (
<div className={styles.message} key={i}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span className={styles.msgMeta}>{msg.username}</span>
<span className={styles.msgMeta}>
{formatDateFromTimestamp(msg.__createdtime__)}
</span>
</div>
<p className={styles.msgText}>{msg.message}</p>
<br />
</div>
))}
</div>
);
};
export default Messages;
在上面,我们有一个useEffect 钩子,每当收到一个套接字事件就会运行。然后我们得到传入receive_message事件监听器的消息数据。从那里,我们设置messagesReceived状态,这是一个消息对象数组,包含消息、发送者的用户名和消息发送的日期。
让我们把新的消息组件导入聊天页面,然后在App.js中为聊天页面创建一个路由。
// client/src/pages/chat/index.js
import styles from './styles.module.css';
import MessagesReceived from './messages';
const Chat = ({ socket }) => {
return (
<div className={styles.chatContainer}>
<div>
<MessagesReceived socket={socket} />
</div>
</div>
);
};
export default Chat;
// client/src/App.js
import './App.css';
import { useState } from 'react';
import Home from './pages/home';
import Chat from './pages/chat';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import io from 'socket.io-client';
const socket = io.connect('http://localhost:4000');
function App() {
const [username, setUsername] = useState('');
const [room, setRoom] = useState('');
return (
<Router>
<div className='App'>
<Routes>
<Route
path='/'
element={
<Home
username={username}
setUsername={setUsername}
room={room}
setRoom={setRoom}
socket={socket}
/>
}
/>
{/* Add this */}
<Route
path='/chat'
element={<Chat username={username} room={room} socket={socket} />}
/>
</Routes>
</div>
</Router>
);
}
export default App;
让我们来测试一下:到主页去,加入一个房间。
我们应该被带到聊天页面,并收到ChatBot的欢迎信息。
用户现在可以看到他们收到的信息。棒极了
下一步:设置我们的数据库,以便我们可以永久保存消息。
如何在HarperDB中创建一个模式和表
回到你的HarperDB仪表板,点击 "浏览"。然后创建一个名为 "realtime_chat_app "的新模式。一个模式就是一组表的简单组合。
在该模式中,创建一个名为 "messages "的表,其哈希属性为 "id"。
我们现在有了存储消息的地方,所以让我们来创建SendMessage组件。
如何创建发送消息组件(C)
创建 src/pages/chat/send-message.js 文件,并添加以下代码。
// client/src/pages/chat/send-message.js
import styles from './styles.module.css';
import React, { useState } from 'react';
const SendMessage = ({ socket, username, room }) => {
const [message, setMessage] = useState('');
const sendMessage = () => {
if (message !== '') {
const __createdtime__ = Date.now();
// Send message to server. We can't specify who we send the message to from the frontend. We can only send to server. Server can then send message to rest of users in room
socket.emit('send_message', { username, room, message, __createdtime__ });
setMessage('');
}
};
return (
<div className={styles.sendMessageContainer}>
<input
className={styles.messageInput}
placeholder='Message...'
onChange={(e) => setMessage(e.target.value)}
value={message}
/>
<button className='btn btn-primary' onClick={sendMessage}>
Send Message
</button>
</div>
);
};
export default SendMessage;
在上面,当用户点击 "发送消息 "按钮时,一个send_message套接字事件连同一个消息对象被发射到服务器上。我们将很快在服务器上处理这个事件。
将SendMessage 导入我们的聊天页面。
// src/pages/chat/index.js
import styles from './styles.module.css';
import MessagesReceived from './messages';
import SendMessage from './send-message';
const Chat = ({ username, room, socket }) => {
return (
<div className={styles.chatContainer}>
<div>
<MessagesReceived socket={socket} />
<SendMessage socket={socket} username={username} room={room} />
</div>
</div>
);
};
export default Chat;
聊天页面现在看起来像这样。
接下来我们需要设置HarperDB的环境变量,这样我们就可以开始与数据库进行交互。
如何设置HarperDB环境变量
为了使你能够在HarperDB中保存消息,你需要你的HarperDB实例URL和你的API密码。
在你的HarperDB仪表板上,点击你的实例,然后进入 "配置"。你会发现你的实例URL和你的实例API Auth Header--也就是你的 "超级用户 "密码,它允许你对数据库进行任何请求--仅供你参考
我们将把这些变量存储在一个.env文件中。**警告:不要把.env文件推送到GitHub上!**这个文件不应该是公开可见的。这些变量是通过幕后的服务器载入的。
创建以下文件并添加你的 HarperDB URL 和密码。
// server/.env
HARPERDB_URL="<your url goes here>"
HARPERDB_PW="Basic <your password here>"
我们还将创建一个.gitignore文件,以防止.env与node_modules文件夹一起被推送到GitHub。
// server/.gitignore
.env
node_modules
注意:善于使用Git和GitHub是所有开发者100%必须的。如果你需要提高你的Git游戏,请查看我的Git工作流程文章。
或者,如果你发现自己经常需要查找相同的Git命令,并且想要一个快速查找、修改和复制/粘贴命令的方法--请查看我流行的Git命令小抄PDF和实体Git小抄海报。
最后,让我们把环境变量加载到我们的服务器中,在主服务器文件的顶部添加这段代码。
// server/index.js
require('dotenv').config();
console.log(process.env.HARPERDB_URL); // remove this after you've confirmed it working
const express = require('express');
// ...
如何允许用户用Socket.io相互发送消息
在服务器上,我们将监听send_message事件,然后将消息发送给房间内的所有用户。
// server/index.js
const express = require('express');
// ...
const harperSaveMessage = require('./services/harper-save-message'); // Add this
// ...
// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
// ...
// Add this
socket.on('send_message', (data) => {
const { message, username, room, __createdtime__ } = data;
io.in(room).emit('receive_message', data); // Send to all users in room, including sender
harperSaveMessage(message, username, room, __createdtime__) // Save message in db
.then((response) => console.log(response))
.catch((err) => console.log(err));
});
});
server.listen(4000, () => 'Server is running on port 3000');
我们现在需要创建harperSaveMessage函数。在server/services/harper-save-message.js创建一个新文件,并添加以下内容。
// server/services/harper-save-message.js
var axios = require('axios');
function harperSaveMessage(message, username, room) {
const dbUrl = process.env.HARPERDB_URL;
const dbPw = process.env.HARPERDB_PW;
if (!dbUrl || !dbPw) return null;
var data = JSON.stringify({
operation: 'insert',
schema: 'realtime_chat_app',
table: 'messages',
records: [
{
message,
username,
room,
},
],
});
var config = {
method: 'post',
url: dbUrl,
headers: {
'Content-Type': 'application/json',
Authorization: dbPw,
},
data: data,
};
return new Promise((resolve, reject) => {
axios(config)
.then(function (response) {
resolve(JSON.stringify(response.data));
})
.catch(function (error) {
reject(error);
});
});
}
module.exports = harperSaveMessage;
以上,保存数据可能需要一点时间,所以我们要返回一个承诺,如果数据保存成功,这个承诺将被解决,如果不成功,则拒绝。
如果你想知道我从哪里得到上面的代码,HarperDB在他们的工作室仪表板上提供了一个很棒的"代码示例"部分,这让生活变得更加简单。
是时候测试了!以用户身份加入一个房间,然后发送一个信息。然后进入HarperDB,点击 "浏览",然后点击 "消息 "表。你应该在数据库中看到你的信息。
Cool 😎。那么接下来怎么办?好吧,如果当用户加入房间时,房间里发送的最后100条消息被加载,那就太好了,不是吗?
如何从HarperDB获取消息
在服务器上,让我们创建一个函数来获取在特定房间里发送的最后100条消息(注意HarperDB也允许我们使用SQL查询👌)。
// server/services/harper-get-messages.js
let axios = require('axios');
function harperGetMessages(room) {
const dbUrl = process.env.HARPERDB_URL;
const dbPw = process.env.HARPERDB_PW;
if (!dbUrl || !dbPw) return null;
let data = JSON.stringify({
operation: 'sql',
sql: `SELECT * FROM realtime_chat_app.messages WHERE room = '${room}' LIMIT 100`,
});
let config = {
method: 'post',
url: dbUrl,
headers: {
'Content-Type': 'application/json',
Authorization: dbPw,
},
data: data,
};
return new Promise((resolve, reject) => {
axios(config)
.then(function (response) {
resolve(JSON.stringify(response.data));
})
.catch(function (error) {
reject(error);
});
});
}
module.exports = harperGetMessages;
每当有用户加入一个房间,我们就会调用这个函数。
// server/index.js
// ...
const harperSaveMessage = require('./services/harper-save-message');
const harperGetMessages = require('./services/harper-get-messages'); // Add this
// ...
// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
console.log(`User connected ${socket.id}`);
// Add a user to a room
socket.on('join_room', (data) => {
// ...
// Add this
// Get last 100 messages sent in the chat room
harperGetMessages(room)
.then((last100Messages) => {
// console.log('latest messages', last100Messages);
socket.emit('last_100_messages', last100Messages);
})
.catch((err) => console.log(err));
});
// ...
以上,如果消息被成功获取,我们将发出一个名为last_100_messages的Socket.io事件。现在我们将在前台监听这个事件。
如何在客户端显示最后100条消息
下面,我们添加一个useEffect钩子,其中包含一个socket.io的last_100_messages 事件的监听器*。* 在这里,消息按照日期顺序排序,最近的在最下面,并且更新messagesReceived 状态。
当messagesReceived 被更新时,一个useEffect运行,将messageColumn div滚动到最新的消息。这改善了我们应用程序的用户体验 👍。
// client/src/pages/chat/messages.js
import styles from './styles.module.css';
import { useState, useEffect, useRef } from 'react';
const Messages = ({ socket }) => {
const [messagesRecieved, setMessagesReceived] = useState([]);
const messagesColumnRef = useRef(null); // Add this
// Runs whenever a socket event is recieved from the server
useEffect(() => {
socket.on('receive_message', (data) => {
console.log(data);
setMessagesReceived((state) => [
...state,
{
message: data.message,
username: data.username,
__createdtime__: data.__createdtime__,
},
]);
});
// Remove event listener on component unmount
return () => socket.off('receive_message');
}, [socket]);
// Add this
useEffect(() => {
// Last 100 messages sent in the chat room (fetched from the db in backend)
socket.on('last_100_messages', (last100Messages) => {
console.log('Last 100 messages:', JSON.parse(last100Messages));
last100Messages = JSON.parse(last100Messages);
// Sort these messages by __createdtime__
last100Messages = sortMessagesByDate(last100Messages);
setMessagesReceived((state) => [...last100Messages, ...state]);
});
return () => socket.off('last_100_messages');
}, [socket]);
// Add this
// Scroll to the most recent message
useEffect(() => {
messagesColumnRef.current.scrollTop =
messagesColumnRef.current.scrollHeight;
}, [messagesRecieved]);
// Add this
function sortMessagesByDate(messages) {
return messages.sort(
(a, b) => parseInt(a.__createdtime__) - parseInt(b.__createdtime__)
);
}
// dd/mm/yyyy, hh:mm:ss
function formatDateFromTimestamp(timestamp) {
const date = new Date(timestamp);
return date.toLocaleString();
}
return (
// Add ref to this div
<div className={styles.messagesColumn} ref={messagesColumnRef}>
{messagesRecieved.map((msg, i) => (
<div className={styles.message} key={i}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span className={styles.msgMeta}>{msg.username}</span>
<span className={styles.msgMeta}>
{formatDateFromTimestamp(msg.__createdtime__)}
</span>
</div>
<p className={styles.msgText}>{msg.message}</p>
<br />
</div>
))}
</div>
);
};
export default Messages;
如何显示房间和用户(A)
我们已经制作了组件B和C,所以让我们通过制作A来结束一切。
在服务器上,当一个用户加入一个房间时,我们发出一个chatroom_users事件,将房间里的所有用户发送给该房间的所有客户端。让我们在一个叫RoomAndUsers的组件中监听这个事件*。*
下面还有一个 "离开 "按钮,当按下它时,会导致向服务器发出一个leave_room事件。然后它将用户重定向到主页。
// client/src/pages/chat/room-and-users.js
import styles from './styles.module.css';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
const RoomAndUsers = ({ socket, username, room }) => {
const [roomUsers, setRoomUsers] = useState([]);
const navigate = useNavigate();
useEffect(() => {
socket.on('chatroom_users', (data) => {
console.log(data);
setRoomUsers(data);
});
return () => socket.off('chatroom_users');
}, [socket]);
const leaveRoom = () => {
const __createdtime__ = Date.now();
socket.emit('leave_room', { username, room, __createdtime__ });
// Redirect to home page
navigate('/', { replace: true });
};
return (
<div className={styles.roomAndUsersColumn}>
<h2 className={styles.roomTitle}>{room}</h2>
<div>
{roomUsers.length > 0 && <h5 className={styles.usersTitle}>Users:</h5>}
<ul className={styles.usersList}>
{roomUsers.map((user) => (
<li
style={{
fontWeight: `${user.username === username ? 'bold' : 'normal'}`,
}}
key={user.id}
>
{user.username}
</li>
))}
</ul>
</div>
<button className='btn btn-outline' onClick={leaveRoom}>
Leave
</button>
</div>
);
};
export default RoomAndUsers;
让我们把这个组件导入到聊天页面。
// client/src/pages/chat/index.js
import styles from './styles.module.css';
import RoomAndUsersColumn from './room-and-users'; // Add this
import SendMessage from './send-message';
import MessagesReceived from './messages';
const Chat = ({ username, room, socket }) => {
return (
<div className={styles.chatContainer}>
{/* Add this */}
<RoomAndUsersColumn socket={socket} username={username} room={room} />
<div>
<MessagesReceived socket={socket} />
<SendMessage socket={socket} username={username} room={room} />
</div>
</div>
);
};
export default Chat;
如何从Socket.io房间移除用户
Socket.io提供了一个 *leave()*方法,你可以用它来把用户从Socket.io房间中移除。我们还在服务器内存中的一个数组中记录了我们的用户,所以我们也将从这个数组中删除用户。
// server/index.js
const leaveRoom = require('./utils/leave-room'); // Add this
// ...
// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
// ...
// Add this
socket.on('leave_room', (data) => {
const { username, room } = data;
socket.leave(room);
const __createdtime__ = Date.now();
// Remove user from memory
allUsers = leaveRoom(socket.id, allUsers);
socket.to(room).emit('chatroom_users', allUsers);
socket.to(room).emit('receive_message', {
username: CHAT_BOT,
message: `${username} has left the chat`,
__createdtime__,
});
console.log(`${username} has left the chat`);
});
});
server.listen(4000, () => 'Server is running on port 3000');
我们现在需要创建*leaveRoom()*函数。
// server/utils/leave-room.js
function leaveRoom(userID, chatRoomUsers) {
return chatRoomUsers.filter((user) => user.id != userID);
}
module.exports = leaveRoom;
你问,为什么要把这个简短的函数放在一个单独的utils文件夹里?因为我们以后还会用到它,我们不想重复自己的工作(保持我们的代码干燥)。
让我们来测试一下:并排打开两个窗口,在这两个窗口中加入聊天。
然后点击窗口2的离开按钮。
该用户被从聊天中移除,并向其他用户发送一条消息--通知他们他们已经离开。很好!
如何添加Socket.io断开连接事件监听器
如果用户以某种方式与服务器断开连接,比如他们的网络中断了怎么办?Socket.io为此提供了一个内置的断开连接 事件监听器。让我们把它添加到我们的服务器中,当用户断开连接时从内存中删除。
// server/index.js
// ...
// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
// ...
// Add this
socket.on('disconnect', () => {
console.log('User disconnected from the chat');
const user = allUsers.find((user) => user.id == socket.id);
if (user?.username) {
allUsers = leaveRoom(socket.id, allUsers);
socket.to(chatRoom).emit('chatroom_users', allUsers);
socket.to(chatRoom).emit('receive_message', {
message: `${user.username} has disconnected from the chat.`,
});
}
});
});
server.listen(4000, () => 'Server is running on port 3000');
这就是你所拥有的--你刚刚用React前端、Node/Express后端和HarperDB数据库建立了一个全栈式实时聊天应用程序。干得好!
下一次,我打算看看HarperDB的自定义函数,它使用户能够在HarperDB中定义自己的API端点。这意味着我们可以在一个地方建立我们的整个应用程序在这篇文章中可以看到HarperDB是如何折叠堆栈的例子。
给你一个挑战💪
如果你刷新聊天页面,用户的用户名和房间就会丢失。看看你能不能防止用户刷新页面时丢失这些信息。线索:本地存储可能是有用的!