使用 React、Node.js 和 Docker 构建 WebSocket 聊天应用程序
如果您想构建响应式或事件驱动型应用程序,WebSocket 是一项非常棒的技术。大多数即时通讯产品也使用这项技术。
本文将使用 React 和 Node 构建一个聊天应用程序。文章末尾还有一个可选(但非常实用)的部分,介绍如何将整个项目打包到 Docker 中。🚀
演示项目
以下是我们将要构建的内容的演示。
设置项目
首先,创建一个简单的 React 项目。
yarn create react-app react-chat-room
项目创建完成后,运行项目以确保一切正常。
cd react-chat-room
yarn start
您将在http://localhost:3000上运行类似的程序。
接下来,我们来设置Node服务器。在项目根目录下创建一个名为server的目录。
在这个目录下,创建一个index.js文件和一个package.json文件。
以下是文件内容package.json。
{
"private": true,
"name": "websocket-chat-room-server",
"description": "A React chat room application, powered by WebSocket",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node ."
},
"dependencies": {
"ws": "^8.5.0"
}
}
在index.js文件中,添加以下基本配置。我们只是启动ws服务器以确保一切正常运行。
const WebSocket = require('ws');
const server = new WebSocket.Server({
port: 8080
},
() => {
console.log('Server started on port 8080');
}
);
之后,运行以下命令以确保服务器正在运行。
yarn start
在服务器端编写聊天功能
Node 服务器处理所有通过 WebSocket 发送的请求。让我们构建一个简单的后端功能,用于通知所有聊天用户有新消息。
具体实现如下:
- 用户建立连接并加入房间。
- 他进入房间后就可以发送消息了。
- 服务器已收到该消息,并通过了一些验证检查。
- 消息验证通过后,服务器会通知聊天室中的所有用户该消息。
首先,我们来创建一组用户和一个发送消息的函数。
...
const users = new Set();
function sendMessage (message) {
users.forEach((user) => {
user.ws.send(JSON.stringify(message));
});
}
有了这些基本功能,让我们编写基本的交互ws方法来处理消息事件、连接事件和关闭事件。
server.on('connection', (ws) => {
const userRef = {
ws,
};
users.add(userRef);
ws.on('message', (message) => {
console.log(message);
try {
// Parsing the message
const data = JSON.parse(message);
// Checking if the message is a valid one
if (
typeof data.sender !== 'string' ||
typeof data.body !== 'string'
) {
console.error('Invalid message');
return;
}
// Sending the message
const messageToSend = {
sender: data.sender,
body: data.body,
sentAt: Date.now()
}
sendMessage(messageToSend);
} catch (e) {
console.error('Error passing message!', e)
}
});
ws.on('close', (code, reason) => {
users.delete(userRef);
console.log(`Connection closed: ${code} ${reason}!`);
});
});
WebSocket 服务器运行正常。现在我们可以用 React 来迁移聊天应用程序的 UI 了。
使用 React 编写聊天应用程序
React应用程序的工作流程如下:
- 用户默认会被重定向到一个页面,在该页面上输入用户名。
- 输入用户名后,用户将被重定向到聊天室,并可以开始与其他在线成员聊天。
首先,我们来安装所需的软件包,例如用于应用程序路由的 react-router 和用于样式设置的 tailwind。
yarn add react-router-dom tailwindcss
接下来,我们需要为 Tailwind 创建一个配置文件。
使用该命令npx tailwindcss-cli@latest init生成tailwind.config.js包含 Tailwind 最小配置的文件。
module.exports = {
purge: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {},
},
variants: {
extend: {},
},
plugins: [],
};
最后一步是将 Tailwind 添加到index.css文件中。
/*src/index.css*/
@tailwind base;
@tailwind components;
@tailwind utilities;
之后,创建src/components目录并添加一个名为 . 的新文件Layout.jsx。该文件将包含应用程序的基本布局,以便我们避免DRY 原则。
import React from "react";
function Layout({ children }) {
return (
<div className="w-full h-screen flex flex-col justify-center items-center space-y-6">
<h2 className="text-3xl font-bold">React Ws Chat</h2>
{children}
</div>
);
}
export default Layout;
在同一目录下,创建一个名为的文件SendIcon.js,并添加以下内容。
const sendIcon = (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 10L1 1L5 10L1 19L19 10Z"
stroke="black"
strokeWidth="2"
strokeLinejoin="round"
/>
</svg>
);
export default sendIcon;
编写身份验证页面
在 `<path>` 标签内src/pages,创建一个名为 `<filename>` 的新文件LoginPage.jsx。完成后,我们来添加处理表单提交的 JavaScript 逻辑。
import React from "react";
import { useNavigate } from "react-router-dom";
import Layout from "../components/Layout";
function LoginPage() {
const navigate = useNavigate();
const [username, setUsername] = React.useState("");
function handleSubmit () {
if (username) {
navigate(`/chat/${username}`);
}
}
return (
<Layout>
// Form here
</Layout>
)
}
export default LoginPage;
最后,这是 JSX 代码。
...
return (
<Layout>
<form class="w-full max-w-sm flex flex-col space-y-6">
<div class="flex flex-col items-center mb-6 space-y-6">
<label
class="block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4"
for="username"
>
Type the username you'll use in the chat
</label>
<input
class="bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500"
id="username"
type="text"
placeholder="Your name or nickname"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div class="md:flex md:items-center">
<div class="md:w-1/3"></div>
<div class="md:w-2/3">
<button
class="self-center shadow bg-purple-500 hover:bg-purple-400 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded"
type="button"
onClick={handleSubmit}
>
Log in the chat
</button>
</div>
</div>
</form>
</Layout>
);
...
让我们解释一下我们在这里要做的事情:
-
我们正在定义提交表单并跳转到聊天室所需的状态和功能。
-
我们还会确保该
username值不为空。
很好,让我们进入下一步,也就是这个项目最关键的部分。
编写聊天室组件
在 `<path>` 标签内src/pages,创建一个名为 `.htm` 的文件ChatPage.jsx。该文件将包含聊天室功能的所有逻辑和用户界面。
在开始编写代码之前,我们先来谈谈 WebSocket 连接是如何处理的。
- 用户被重定向到该
ChatPage.jsx页面后,ws连接即建立。 - 如果用户输入并发送消息,
message则会向服务器发送一个事件类型。 - 每当有其他用户发送消息时,都会向 React 应用程序发送一个事件,我们会更新屏幕上显示的消息列表。
我们js先来编写处理这个问题的逻辑。
import React, { useRef } from "react";
import Layout from "../components/Layout";
import { useParams } from "react-router-dom";
import { sendIcon } from "../components/SendIcon"
function ChatPage() {
const [messages, setMessages] = React.useState([]);
const [isConnectionOpen, setConnectionOpen] = React.useState(false);
const [messageBody, setMessageBody] = React.useState("");
const { username } = useParams();
const ws = useRef();
// sending message function
const sendMessage = () => {
if (messageBody) {
ws.current.send(
JSON.stringify({
sender: username,
body: messageBody,
})
);
setMessageBody("");
}
};
React.useEffect(() => {
ws.current = new WebSocket("ws://localhost:8080");
// Opening the ws connection
ws.current.onopen = () => {
console.log("Connection opened");
setConnectionOpen(true);
};
// Listening on ws new added messages
ws.current.onmessage = (event) => {
const data = JSON.parse(event.data);
setMessages((_messages) => [..._messages, data]);
};
return () => {
console.log("Cleaning up...");
ws.current.close();
};
}, []);
const scrollTarget = useRef(null);
React.useEffect(() => {
if (scrollTarget.current) {
scrollTarget.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages.length]);
return (
<Layout>
// Code going here
</Layout>
);
}
export default ChatPage;
我们先来添加消息列表的用户界面。
...
<div id="chat-view-container" className="flex flex-col w-1/3">
{messages.map((message, index) => (
<div key={index} className={`my-3 rounded py-3 w-1/3 text-white ${
message.sender === username ? "self-end bg-purple-600" : "bg-blue-600"
}`}>
<div className="flex items-center">
<div className="ml-2">
<div className="flex flex-row">
<div className="text-sm font-medium leading-5 text-gray-900">
{message.sender} at
</div>
<div className="ml-1">
<div className="text-sm font-bold leading-5 text-gray-900">
{new Date(message.sentAt).toLocaleTimeString(undefined, {
timeStyle: "short",
})}{" "}
</div>
</div>
</div>
<div className="mt-1 text-sm font-semibold leading-5">
{message.body}
</div>
</div>
</div>
</div>
))}
<div ref={scrollTarget} />
</div>
用户发送的消息将以紫色显示,其他用户发送的消息将以蓝色显示。
下一步,我们添加一个简单的输入框,用于输入消息并发送。
...
<footer className="w-1/3">
<p>
You are chatting as <span className="font-bold">{username}</span>
</p>
<div className="flex flex-row">
<input
id="message"
type="text"
className="w-full border-2 border-gray-200 focus:outline-none rounded-md p-2 hover:border-purple-400"
placeholder="Type your message here..."
value={messageBody}
onChange={(e) => setMessageBody(e.target.value)}
required
/>
<button
aria-label="Send"
onClick={sendMessage}
className="m-3"
disabled={!isConnectionOpen}
>
{sendIcon}
</button>
</div>
</footer>
这是该组件的最终代码ChatPage。
import React, { useRef } from "react";
import Layout from "../components/Layout";
import { useParams } from "react-router-dom";
import { sendIcon } from "../components/SendIcon"
function ChatPage() {
const [messages, setMessages] = React.useState([]);
const [isConnectionOpen, setConnectionOpen] = React.useState(false);
const [messageBody, setMessageBody] = React.useState("");
const { username } = useParams();
const ws = useRef();
// sending message function
const sendMessage = () => {
if (messageBody) {
ws.current.send(
JSON.stringify({
sender: username,
body: messageBody,
})
);
setMessageBody("");
}
};
React.useEffect(() => {
ws.current = new WebSocket("ws://localhost:8080");
ws.current.onopen = () => {
console.log("Connection opened");
setConnectionOpen(true);
};
ws.current.onmessage = (event) => {
const data = JSON.parse(event.data);
setMessages((_messages) => [..._messages, data]);
};
return () => {
console.log("Cleaning up...");
ws.current.close();
};
}, []);
const scrollTarget = useRef(null);
React.useEffect(() => {
if (scrollTarget.current) {
scrollTarget.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages.length]);
return (
<Layout>
<div id="chat-view-container" className="flex flex-col w-1/3">
{messages.map((message, index) => (
<div key={index} className={`my-3 rounded py-3 w-1/3 text-white ${
message.sender === username ? "self-end bg-purple-600" : "bg-blue-600"
}`}>
<div className="flex items-center">
<div className="ml-2">
<div className="flex flex-row">
<div className="text-sm font-medium leading-5 text-gray-900">
{message.sender} at
</div>
<div className="ml-1">
<div className="text-sm font-bold leading-5 text-gray-900">
{new Date(message.sentAt).toLocaleTimeString(undefined, {
timeStyle: "short",
})}{" "}
</div>
</div>
</div>
<div className="mt-1 text-sm font-semibold leading-5">
{message.body}
</div>
</div>
</div>
</div>
))}
<div ref={scrollTarget} />
</div>
<footer className="w-1/3">
<p>
You are chatting as <span className="font-bold">{username}</span>
</p>
<div className="flex flex-row">
<input
id="message"
type="text"
className="w-full border-2 border-gray-200 focus:outline-none rounded-md p-2 hover:border-purple-400"
placeholder="Type your message here..."
value={messageBody}
onChange={(e) => setMessageBody(e.target.value)}
required
/>
<button
aria-label="Send"
onClick={sendMessage}
className="m-3"
disabled={!isConnectionOpen}
>
{sendIcon}
</button>
</div>
</footer>
</Layout>
);
}
export default ChatPage;
太好了!我们开始注册路线吧。
添加路线
在文件中App.js添加以下内容。
import React from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { LoginPage, ChatPage } from "./pages";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<LoginPage />} />
<Route path="/chat/:username" element={<ChatPage />} />
</Routes>
</BrowserRouter>
);
}
export default App;
之后请确保您的应用程序正在运行,然后即可开始测试。
将应用程序容器化
在这个项目中运行多台服务器固然很好,但这需要大量的设置工作。例如,如果您想要部署它呢?这可能会相当复杂。
Docker是一个开放平台,用于在容器内开发、交付和运行应用程序。
为什么要使用 Docker?
它可以帮助您将应用程序与基础设施分离,并有助于更快地交付代码。
如果你是第一次使用 Docker,我强烈建议你快速浏览一下教程并阅读一些相关文档。
以下是一些对我帮助很大的资源:
首先,Dockerfile在项目根目录添加一个配置文件。这Dockerfile将负责处理 React 服务器。
FROM node:16-alpine
WORKDIR /app
COPY package.json ./
COPY yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
之后,还要Dockerfile在server目录中添加一个。
FROM node:16-alpine
WORKDIR /app/server
COPY package.json ./server
COPY yarn.lock ./server
RUN yarn install --frozen-lockfile
COPY . .
最后,在项目根目录下添加一个docker-compose.yaml文件。
version: "3.8"
services:
ws:
container_name: ws_server
restart: on-failure
build:
context: .
dockerfile: server/Dockerfile
volumes:
- ./server:/app/server
ports:
- "8080:8080"
command: >
sh -c "node ."
react-app:
container_name: react_app
restart: on-failure
build: .
volumes:
- ./src:/app/src
ports:
- "3000:3000"
command: >
sh -c "yarn start"
depends_on:
- ws
完成后,使用以下命令运行容器。
docker-compose up -d --build
应用程序将在常用端口运行。
瞧!我们的聊天应用已经成功容器化了。🚀
结论
在本文中,我们学习了如何使用 React、Node 和 Docker 构建聊天应用程序。
每篇文章都有改进的空间,所以欢迎在评论区提出您的建议或问题。😉
请点击此处查看本教程的代码。
本文使用bloggu.io发布。免费试用。
文章来源:https://dev.to/koladev/websocket-with-react-nodejs-and-docker-building-a-chat-application-3447

