发布于 2026-01-06 1 阅读
0

使用 React、Node.js 和 Docker 构建 WebSocket 聊天应用程序

使用 React、Node.js 和 Docker 构建 WebSocket 聊天应用程序

如果您想构建响应式或事件驱动型应用程序,WebSocket 是一项非常棒的技术。大多数即时通讯产品也使用这项技术。

本文将使用 React 和 Node 构建一个聊天应用程序。文章末尾还有一个可选(但非常实用)的部分,介绍如何将整个项目打包到 Docker 中。🚀

演示项目

以下是我们将要构建的内容的演示。

演示

设置项目

首先,创建一个简单的 React 项目。



yarn create react-app react-chat-room


Enter fullscreen mode Exit fullscreen mode

项目创建完成后,运行项目以确保一切正常。



cd react-chat-room
yarn start


Enter fullscreen mode Exit fullscreen mode

您将在http://localhost:3000上运行类似的程序。

启动 React 应用程序

接下来,我们来设置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"
    }
}


Enter fullscreen mode Exit fullscreen mode

index.js文件中,添加以下基本配置。我们只是启动ws服务器以确保一切正常运行。



const WebSocket = require('ws');

const server = new WebSocket.Server({
        port: 8080
    },
    () => {
        console.log('Server started on port 8080');
    }
);


Enter fullscreen mode Exit fullscreen mode

之后,运行以下命令以确保服务器正在运行。



yarn start


Enter fullscreen mode Exit fullscreen mode

在服务器端编写聊天功能

Node 服务器处理所有通过 WebSocket 发送的请求。让我们构建一个简单的后端功能,用于通知所有聊天用户有新消息。
具体实现如下:

  • 用户建立连接并加入房间。
  • 他进入房间后就可以发送消息了。
  • 服务器已收到该消息,并通过了一些验证检查。
  • 消息验证通过后,服务器会通知聊天室中的所有用户该消息。

首先,我们来创建一组用户和一个发送消息的函数。



...
const users = new Set();

function sendMessage (message) {
    users.forEach((user) => {
        user.ws.send(JSON.stringify(message));
    });
}


Enter fullscreen mode Exit fullscreen mode

有了这些基本功能,让我们编写基本的交互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}!`);
    });
});


Enter fullscreen mode Exit fullscreen mode

WebSocket 服务器运行正常。现在我们可以用 React 来迁移聊天应用程序的 UI 了。

使用 React 编写聊天应用程序

React应用程序的工作流程如下:

  • 用户默认会被重定向到一个页面,在该页面上输入用户名。
  • 输入用户名后,用户将被重定向到聊天室,并可以开始与其他在线成员聊天。

首先,我们来安装所需的软件包,例如用于应用程序路由的 react-router 和用于样式设置的 tailwind。



yarn add react-router-dom tailwindcss


Enter fullscreen mode Exit fullscreen mode

接下来,我们需要为 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: [],
};


Enter fullscreen mode Exit fullscreen mode

最后一步是将 Tailwind 添加到index.css文件中。



/*src/index.css*/

@tailwind base;
@tailwind components;
@tailwind utilities;


Enter fullscreen mode Exit fullscreen mode

之后,创建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;


Enter fullscreen mode Exit fullscreen mode

在同一目录下,创建一个名为的文件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;



Enter fullscreen mode Exit fullscreen mode

编写身份验证页面

在 `<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;


Enter fullscreen mode Exit fullscreen mode

最后,这是 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>
  );
  ...


Enter fullscreen mode Exit fullscreen mode

让我们解释一下我们在这里要做的事情:

  • 我们正在定义提交表单并跳转到聊天室所需的状态和功能。

  • 我们还会确保该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;


Enter fullscreen mode Exit fullscreen mode

我们先来添加消息列表的用户界面。



...
      <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>


Enter fullscreen mode Exit fullscreen mode

用户发送的消息将以紫色显示,其他用户发送的消息将以蓝色显示。

下一步,我们添加一个简单的输入框,用于输入消息并发送。



...
      <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>


Enter fullscreen mode Exit fullscreen mode

这是该组件的最终代码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;


Enter fullscreen mode Exit fullscreen mode

太好了!我们开始注册路线吧。

添加路线

在文件中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;


Enter fullscreen mode Exit fullscreen mode

之后请确保您的应用程序正在运行,然后即可开始测试。

将应用程序容器化

在这个项目中运行多台服务器固然很好,但这需要大量的设置工作。例如,如果您想要部署它呢?这可能会相当复杂。

Docker是一个开放平台,用于在容器内开发、交付和运行应用程序。
为什么要使用 Docker?
它可以帮助您将应用程序与基础设施分离,并有助于更快地交付代码。

如果你是第一次使用 Docker,我强烈建议你快速浏览一下教程并阅读一些相关文档。

以下是一些对我帮助很大的资源:

首先,Dockerfile在项目根目录添加一个配置文件。这Dockerfile将负责处理 React 服务器。



FROM node:16-alpine

WORKDIR /app

COPY package.json ./

COPY yarn.lock ./

RUN yarn install --frozen-lockfile

COPY . .


Enter fullscreen mode Exit fullscreen mode

之后,还要Dockerfileserver目录中添加一个。



FROM node:16-alpine

WORKDIR /app/server

COPY package.json ./server

COPY yarn.lock ./server

RUN yarn install --frozen-lockfile

COPY . .


Enter fullscreen mode Exit fullscreen mode

最后,在项目根目录下添加一个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


Enter fullscreen mode Exit fullscreen mode

完成后,使用以下命令运行容器。



docker-compose up -d --build


Enter fullscreen mode Exit fullscreen mode

应用程序将在常用端口运行。

瞧!我们的聊天应用已经成功容器化了。🚀

结论

在本文中,我们学习了如何使用 React、Node 和 Docker 构建聊天应用程序。

每篇文章都有改进的空间,所以欢迎在评论区提出您的建议或问题。😉

请点击此处查看本教程的代码

本文使用bloggu.io发布。免费试用。

文章来源:https://dev.to/koladev/websocket-with-react-nodejs-and-docker-building-a-chat-application-3447