使用 ReactJS、Socket.io 和 NodeJS 构建协作白板🤝

2025-06-11

使用 ReactJS、Socket.io 和 NodeJS 构建协作白板🤝

各位读者好!希望你们一切顺利。在本文中,我们将构建一个类似于 Google Meet 白板的协作白板。我们将使用HTML CanvasReactJS创建白板,并使用socket.io实现实时通信。

项目实施后的样子如下。我们开始吧。

项目白板

在项目目录中,我们将创建文件夹,并使用命令创建前端npm create vite@latest并选择“react”和“typescript”。

创建画布

在前端创建一个 Board 组件。该组件将包含我们的 Canvas 及其功能。



import React, { useRef } from 'react';

const Board = () => {

    const canvasRef = useRef<HTMLCanvasElement>(null);


    return (
        <canvas
            ref={canvasRef}
            width={600}
            height={400}
            style={{ backgroundColor: 'white' }}
        />
    );
};


export default Board;


Enter fullscreen mode Exit fullscreen mode

在这里,我们使用useRef hook(现在称为canvasRef )创建画布当前状态的引用。我们将背景颜色保持为白色,以创建白板环境,它可以是任何您想要的颜色,也可以完全没有颜色。

在画布上绘画

现在,我们创建了画布,让我们看看如何在其上绘图/书写。



    useEffect(() => {


        // Variables to store drawing state
        let isDrawing = false;
        let lastX = 0;
        let lastY = 0;


        const startDrawing = (e: { offsetX: number; offsetY: number; }) => {
            isDrawing = true;


            [lastX, lastY] = [e.offsetX, e.offsetY];
        };


        // Function to draw
        const draw = (e: { offsetX: number; offsetY: number; }) => {
            if (!isDrawing) return;


            const canvas = canvasRef.current;
            const ctx = canvas.getContext('2d');
            if (ctx) {
                ctx.beginPath();
                ctx.moveTo(lastX, lastY);
                ctx.lineTo(e.offsetX, e.offsetY);
                ctx.stroke();
            }


            [lastX, lastY] = [e.offsetX, e.offsetY];
        };


        // Function to end drawing
        const endDrawing = () => {
            isDrawing = false;
        };


        const canvas: HTMLCanvasElement | null = canvasRef.current;
        const ctx = canvasRef.current?.getContext('2d');


        // Set initial drawing styles
        if (ctx) {
            ctx.strokeStyle = black;
            ctx.lineWidth = 5;


            ctx.lineCap = 'round';
            ctx.lineJoin = 'round';


        }
        // Event listeners for drawing
        canvas.addEventListener('mousedown', startDrawing);
        canvas.addEventListener('mousemove', draw);
        canvas.addEventListener('mouseup', endDrawing);
        canvas.addEventListener('mouseout', endDrawing);


        return () => {
            // Clean up event listeners when component unmounts
            canvas.removeEventListener('mousedown', startDrawing);
            canvas.removeEventListener('mousemove', draw);
            canvas.removeEventListener('mouseup', endDrawing);
            canvas.removeEventListener('mouseout', endDrawing);
        };
    }, []);



Enter fullscreen mode Exit fullscreen mode

我们使用 JavaScript 事件监听器来管理画布上的各种鼠标事件。鼠标右键单击画布后,立即执行startDrawing()函数;鼠标移动后,将调用draw()函数;鼠标离开画布后,将调用endDrawing()函数。让我们详细了解一下。

我们使用该ctx变量获取当前画布引用并对其执行操作。我们保留了ctx变量的一些默认属性,例如strokeStyle为黑色、lineWidth为 5、lineCap为圆角、lineJoin为圆形。

我们在 Canvas 上设置了事件监听器,并在组件卸载或在下一个效果执行前运行后清理事件监听器。我们使用isDrawinglastXlastY变量来控制绘制状态。最初, isDrawing 设置为 false,直到鼠标按下。它将isDrawing的状态更改为true。当鼠标移动时,该ctx变量会根据光标在画布上的位置创建笔触。当我们停止右键单击或将光标移出画布时,将调用endDrawing()函数,将isDrawing设置为 false。这样,我们就可以在画布上自由绘图了 :)

后端

现在,让我们添加允许多个用户同时使用白板的功能。进入后端目录,首先执行命令,然后在index.js
npm install socket.io文件中编写以下内容



const { Server } = require('socket.io');
const io = new Server({
    cors: "http://localhost:5173/"
})
io.on('connection', function (socket) {


    socket.on('canvasImage', (data) => {
        socket.broadcast.emit('canvasImage', data);
    });
});


io.listen(5000);


Enter fullscreen mode Exit fullscreen mode

这里,我们初始化了一个名为 io 的新 Socket.IO 服务器实例。cors 选项设置为允许来自指定 URL(http://localhost:5173/)的连接,该 URL 是我们的前端 URL。如果不设置该选项,前端将无法与后端共享资源。

io.on(‘connection’)每当客户端连接到 Socket.IO 服务器时,都会设置一个事件监听器。在这个监听器中,我们还有另一个事件监听器,用于监听用户从前端发出的“canvasImage”事件。
用户从前端发出事件时,该事件将包含画布的最新副本。我们将使用此事件接收数据,然后将接收到的canvasImagesocket.broadcast.emit(‘canvasImage’, data)数据广播给所有连接的客户端(发送原始“canvasImage”事件的客户端除外)。最后,同样重要的是,我们使用在端口5000上运行的命令启动服务器node index.js

客户端套接字连接

Board.tsx中,我们现在将进行一些更改。在此之前,我们将在前端目录中安装“socket.io-client”包。



import React, { useRef, useEffect, useState } from 'react';
import io from 'socket.io-client';

const [socket, setSocket] = useState(null);

    useEffect(() => {
        const newSocket = io('http://localhost:5000');
        console.log(newSocket, "Connected to socket");
        setSocket(newSocket);
    }, []);


    useEffect(() => {

        if (socket) {
            // Event listener for receiving canvas data from the socket
            socket.on('canvasImage', (data) => {
                // Create an image object from the data URL
                const image = new Image();
                image.src = data;


                const canvas = canvasRef.current;
                // eslint-disable-next-line react-hooks/exhaustive-deps
                const ctx = canvas.getContext('2d');
                // Draw the image onto the canvas
                image.onload = () => {
                    ctx.drawImage(image, 0, 0);
                };
            });
        }
    }, [socket]);


Enter fullscreen mode Exit fullscreen mode

我们将创建一个名为 socket 的新 useState 钩子来管理套接字。
const newSocket = io('http://localhost:5000')与服务器和客户端建立连接。

为了获取所有使用白板的用户的更新,我们使用 useEffect 钩子监听来自服务器的“canvasImage”事件。一旦收到事件,我们就会在当前画布上绘制该图像。

那么,我们每次绘制时都要将图像发送到服务器吗?不,我们在绘制时(例如,当我们抬起右键时)才将图像发送到服务器。以下是更新后的endDrawing()函数。



        const endDrawing = () => {
            const canvas = canvasRef.current;
            const dataURL = canvas.toDataURL(); // Get the data URL of the canvas content


            // Send the dataURL or image data to the socket
            // console.log('drawing ended')
            if (socket) {
                socket.emit('canvasImage', dataURL);
                console.log('drawing ended')
            }
            isDrawing = false;
        };


Enter fullscreen mode Exit fullscreen mode

简单!这样,协作白板就完成了。不过,如果你想要调整画笔的颜色和大小,请稍等,我们接下来会讲到。

调整颜色和大小

我们将使用两种状态来控制这两个画笔属性。在App.tsx中,我们将对输入使用onChange()函数来更新每个状态。



import { useEffect, useState } from 'react';
import './App.css';
import Board from './component/Board';

const CanvasDrawing = () => {

  const [brushColor, setBrushColor] = useState('black');
  const [brushSize, setBrushSize] = useState<number>(5);

  return (
    <div className="App" >
      <h1>Collaborative Whiteboard</h1>
      <div>
        <Board brushColor={brushColor} brushSize={brushSize} />
        <div className='tools' >
          <div>
            <span>Color: </span>
            <input type="color" value={brushColor} onChange={(e) => setBrushColor(e.target.value)} />
          </div>
          <div>
            <span>Size: </span>
            <input type="range" color='#fac176'
              min="1" max="100" value={brushSize} onChange={(e) => setBrushSize(Number(e.target.value))} />
            <span>{brushSize}</span>
          </div>
        </div>
      </div>
    </div>
  );
};

export default CanvasDrawing;


Enter fullscreen mode Exit fullscreen mode

让我们在 App.css 中添加一些 CSS,使其看起来更漂亮。



.App {
    background-color: #4158D0;
    background-image: linear-gradient(43deg, #4158D0 0%, #C850C0 46%, #FFCC70 100%);
    height: 100vh;
    width: 100%;
    padding: 3em 1em;
    display: flex;
    flex-direction: column;
    align-items: center;
}


.tools {
    display: flex;
    flex-direction: row;
    justify-content: space-around;
    align-items: center;
    width: 100%;
    padding: 1em;
    background-color: black;
    color: white;
}


h1 {
    margin-bottom: 1rem;
    text-align: center;
}


input {
    margin: 0 5px;
}


span {
    font-size: 1.3em;
}




@media screen and (max-width: 600px) {
    .tools {
        flex-direction: column;
        align-items: start;
    }


}


Enter fullscreen mode Exit fullscreen mode

索引.css



@import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap');


* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
    font-family: 'Roboto', sans-serif;
}


Enter fullscreen mode Exit fullscreen mode

我们将brushSizebrushColor状态作为道具传递给 Board 组件。



interface MyBoard {
    brushColor: string;
    brushSize: number;
}


const Board: React.FC<MyBoard> = (props) => {

    const { brushColor, brushSize } = props;

    useEffect(() => {

// Variables to store drawing state
        let isDrawing = false;
        let lastX = 0;
        let lastY = 0;
        const startDrawing = (e: { offsetX: number; offsetY: number; }) => {
            isDrawing = true;


            console.log(`drawing started`, brushColor, brushSize);
            [lastX, lastY] = [e.offsetX, e.offsetY];
        };


        // Function to draw
        const draw = (e: { offsetX: number; offsetY: number; }) => {
            if (!isDrawing) return;


            const canvas = canvasRef.current;
            const ctx = canvas.getContext('2d');
            if (ctx) {
                ctx.beginPath();
                ctx.moveTo(lastX, lastY);
                ctx.lineTo(e.offsetX, e.offsetY);
                ctx.stroke();
            }


            [lastX, lastY] = [e.offsetX, e.offsetY];
        };


        // Function to end drawing
        const endDrawing = () => {
            const canvas = canvasRef.current;
            const dataURL = canvas.toDataURL(); // Get the data URL of the canvas content


            // Send the dataURL or image data to the socket
            // console.log('drawing ended')
            if (socket) {
                socket.emit('canvasImage', dataURL);
                console.log('drawing ended')
            }
            isDrawing = false;
        };


        const canvas: HTMLCanvasElement | null = canvasRef.current;
        const ctx = canvasRef.current?.getContext('2d');


        // Set initial drawing styles
        if (ctx) {
            ctx.strokeStyle = brushColor;
            ctx.lineWidth = brushSize;


            ctx.lineCap = 'round';
            ctx.lineJoin = 'round';


        }
        // Event listeners for drawing
        canvas.addEventListener('mousedown', startDrawing);
        canvas.addEventListener('mousemove', draw);
        canvas.addEventListener('mouseup', endDrawing);
        canvas.addEventListener('mouseout', endDrawing);


        return () => {
            // Clean up event listeners when component unmounts
            canvas.removeEventListener('mousedown', startDrawing);
            canvas.removeEventListener('mousemove', draw);
            canvas.removeEventListener('mouseup', endDrawing);
            canvas.removeEventListener('mouseout', endDrawing);
        };
    }, [brushColor, brushSize, socket]);


Enter fullscreen mode Exit fullscreen mode

在 useEffect 中,我们正在进行更改ctx.strokeStyle = brushColor;ctx.lineWidth = brushSize;在依赖数组中添加 brushColor、brushSize、socket。

响应式画布

我们将使用一些 JavaScript 代码来实现画布的响应式显示。window 对象会获取设备的宽度和高度,并据此显示画布。



    const [windowSize, setWindowSize] = useState([
        window.innerWidth,
        window.innerHeight,
    ]);


    useEffect(() => {
        const handleWindowResize = () => {
            setWindowSize([window.innerWidth, window.innerHeight]);
        };


        window.addEventListener('resize', handleWindowResize);


        return () => {
            window.removeEventListener('resize', handleWindowResize);
        };
    }, []);




    return (
        <canvas
            ref={canvasRef}
            width={windowSize[0] > 600 ? 600 : 300}
            height={windowSize[1] > 400 ? 400 : 200}
            style={{ backgroundColor: 'white' }}
        />
    );


Enter fullscreen mode Exit fullscreen mode

我们使用“resize”事件监听器来更新 windowSize 的宽高状态。至此,我们的 Web 应用就完成了。

希望你喜欢这个项目。
感谢阅读!

如果你有任何疑问或者想查看源代码,可以在这里查看。

与我联系-

鏂囩珷鏉ユ簮锛�https://dev.to/fidalmathew/building-a-collaborative-whiteboard-app-using-reactjs-socketio-and-nodejs-2o71
PREV
💻 2025 年每个前端开发人员都应该准备的 40 个 JavaScript 面试问题 🔥
NEXT
日本软件开发人员薪资:终极指南