发布于 2026-01-05 5 阅读
0

使用 Canvas 和 WebRTC 进行实时协作绘图(第 1 部分):SimplePeer、WebSockets 和 Canvas DEV 的全球展示挑战赛,由 Mux 呈现:展示你的项目!

使用 Canvas 和 WebRTC 进行实时协作绘图(第一部分):SimplePeer、WebSocket 和 Canvas

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

上周我花了一些时间和女儿一起学习使用绘图软件。我向她展示了计算思维的运作方式,首先要从分解问题的角度来思考(问题分解)。这是计算思维的四大支柱之一。

  • 问题分解
  • 模式识别
  • 数据表示/抽象
  • 算法

从那以后,我们很快就围绕着各种有趣的绘画、表情符号以及学习识别不良行为和何时纠正它们展开了讨论。如果你有孩子,这会是一个很有趣的学习练习:想出一个问题,然后反复探索解决之道。你可以随时想出新点子,这对孩子们来说是一个非常有趣的体验。

总之,我想在此基础上,添加一个使用 WebRTC 进行协作绘图的组件。我们将使用simplepeer来处理 WebRTC 层,因为它能大大简化实现。让我们开始吧!

设置

首先,和所有项目一样,我们需要进行设置,确保屏幕上有足够的绘图区域以及可用的工具。最终,我们希望能够在工具栏中选择工具,并在弹出窗口中选择和更改属性。现在,让我们先设置布局的样板。



<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Map</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@2.5.0/fonts/remixicon.css">
    <link rel="stylesheet" href="index.css">
</head>
<body>
    <div class="flush vstack">
        <div class="menubar hstack">
            <a class="icon-link center">
                <i class="ri-lg ri-landscape-line"></i>
            </a>
            <div class="spacer"></div>
        </div>
        <div class="spacer app">
            <canvas></canvas>
        </div>
    </div>
    <script type="text/javascript" src="draw.js"></script>
</body>
</html>


Enter fullscreen mode Exit fullscreen mode


/** index.css */
:root {
    --root-font-size: 16px;
    --standard-padding: 16px;

    --bg: #fafafa;
    --fg: #666;
    --menubar-bg: #fdfdfd;

    --menubar-shadow: 0 8px 6px -6px #f4f4f4;
}

/** Reset */
html, body, nav, ul, h1, h2, h3, h4, a, canvas {
    margin: 0px;
    padding: 0px;
    color: var(--text-color);
}
html, body {
    font-family: Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
    font-size: var(--root-font-size);
    background: var(--bg);
    height: 100%;
    width: 100%;
    overflow: hidden;
}
*, body, button, input, select, textarea, canvas {
    text-rendering: optimizeLegibility;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    outline: 0;
}

/** Utilities */
.hstack {
    display: flex;
    flex-direction: row;
}
.vstack {
    display: flex;
    flex-direction: column;
}
.center {
    display: flex;
    align-items: center;
}
.spacer {
    flex: 1;
}
.flush {
    height: 100%;
}
.icon-link {
    margin: 0px var(--standard-padding);
    font-size: 1rem;
}

/** Sections */
.menubar {
    padding: var(--standard-padding);
    box-shadow: var(--menubar-shadow);
    background: var(--menubar-bg);
}
.app {
    width: calc(100% - var(--sidebar-width));
}


Enter fullscreen mode Exit fullscreen mode

请注意,我上面添加的这些实用工具都是基本的Flexbox属性。我只是想用简单的间距来按行和列布局元素。我把它们分别命名为hstackvstackspacerflush(用于最大化高度)。

带有 RemixIcon 的图标集

此外,我还使用了remix icons。它是免费/开源的,可用于商业和个人用途。您可以通过 CDN 引用它,这些图标本身非常简洁,同时还提供了一些尺寸自定义选项。非常方便!

混音版

绘图设置

如果你看过我写的《使用 Canvas 绘制交互式图表》这篇文章,那么这段代码就和它非常相似。



const canvas = document.querySelector('canvas');
const context = canvas.getContext('2d');

var nodes = [];

function resize() {
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
    draw();
}

function draw() {
    context.clearRect(0, 0, canvas.width, canvas.height);
}

window.onresize = resize;
resize();


Enter fullscreen mode Exit fullscreen mode

一起画画

太棒了!我们的应用目前功能还很有限。让我们添加一些可以切换上下文的工具吧。

用形状作画

如果要在屏幕上绘制任何东西,我们需要某种画笔。由于我们没有实际的颜料铅笔颗粒,所以我们必须通过反复绘制形状来创建自己的“颗粒”。让我们看看这种方法在以下情况下会产生什么效果:



function move(e) {
    if (e.buttons) {
        context.fillStyle = 'green';
        context.beginPath();
        context.arc(e.x, e.y, 
    }
}
window.onmousemove = move;


Enter fullscreen mode Exit fullscreen mode

弧

这里,每次调用`beginPath`都会创建一个新路径——这将清空子路径列表,并在渲染上下文中开始一个新的路径。之所以使用`offsetX``offsetY`而不是 ` e.xand`,e.y是因为我们的画布位于文档的偏移元素节点内。

但请注意,在此处移动鼠标会导致鼠标事件之间出现间隙。我们实际上想要的是连接这些点的路径。为此,我们需要围绕最后一个点绘制一条线。或者,我们可以选择插值计算这些点之间的距离,并在它们之间绘制多个圆(这会使事情变得稍微复杂一些,因为现在绘制的圆弧数量取决于点之间步长的分辨率)。不过,我们还是使用带有lineCap的直线方法吧。



function move(e) {
    if (e.buttons) {
        if (!lastPoint) {
            lastPoint = { x: e.offsetX, y: e.offsetY };
            return;
        }
        context.beginPath();
        context.moveTo(lastPoint.x, lastPoint.y);
        context.lineTo(e.offsetX, e.offsetY);
        context.strokeStyle = 'green';
        context.lineWidth = 5;
        context.lineCap = 'round';
        context.stroke();
        lastPoint = { x: e.offsetX, y: e.offsetY };
    }
}

function key(e) {
    if (e.key === 'Backspace') {
        context.clearRect(0, 0, canvas.width, canvas.height);
    }
}

window.onkeydown = key;


Enter fullscreen mode Exit fullscreen mode

用线条重绘

现在我们可以用退格键清除屏幕,并且不再出现间隙,因为我们是在鼠标移动事件发生的点之间绘制路径。

力/压力灵敏度

我发现可以通过Safari特有的webkitmouseforcechanged事件来处理鼠标压力感应。这个方法也适用于pointermove 事件。可惜的是,压力感应值webkitForce只有在移动设备和 Safari 浏览器中才会正确设置并更新为合适的灵敏度。总之,如果你在桌面版 Safari 浏览器中打开应用,并且你的触控板支持压力感应,就可以实现这个功能!



var currentForce = 1;

function force(e) {
    currentForce = e.webkitForce || 1;
}

function move(e) {
    if (e.buttons) {
        if (!lastPoint) {
            lastPoint = { x: e.offsetX, y: e.offsetY };
            return;
        }
        context.beginPath();
        context.moveTo(lastPoint.x, lastPoint.y);
        context.lineTo(e.offsetX, e.offsetY);
        context.strokeStyle = 'green';
        context.lineWidth = Math.pow(currentForce, 4) * 2;
        context.lineCap = 'round';
        context.stroke();
        lastPoint = { x: e.offsetX, y: e.offsetY };
    }
}

window.onwebkitmouseforcechanged = force;


Enter fullscreen mode Exit fullscreen mode

力敏度

同步状态

到目前为止,我们还没有进行太多与他人实时绘图的尝试。正如我在一篇关于CRDT的文章中提到的,同步有两种方法:

  • 基于状态的同步(使用 CRDT)
  • 基于操作的同步(使用 CRDT 或操作转换)

我们将改为通过变更缓冲区来传输每次变更信息。我们会定期将此缓冲区的内容批量发送到网络中的其他节点,以便更新本地状态。

如果你对 WebRTC 的工作原理感兴趣,不妨看看这本《WebRTC for the Curious》。它是由 WebRTC 内部机制的早期开发者撰写的!对于理解 WebRTC API 底层的工作原理非常有帮助。

设置 WebSocket 服务器

为了与对等节点进行协商,我们需要通过服务器传递信号、提议和连接信息。我们将使用ExpressHTTP和WebSocket。我们希望服务器实现以下功能:

  • 接受传入连接
  • 广播可用连接
  • 处理 RTC 握手,包括报价答复冰冻候选人挂断等。

首先,将index.htmldraw.jsindex.css及相关公共文件的内容移动到一个新文件夹中/static。然后在根目录创建一个名为 `.node_modules` 的新文件index.js。运行以下命令初始化 Node 项目。



npm init -y


Enter fullscreen mode Exit fullscreen mode

你应该看到以下输出。



{
  "name": "map",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}


Enter fullscreen mode Exit fullscreen mode

现在,我们的项目需要一些依赖项。运行:



npm install --save ws express uuid


Enter fullscreen mode Exit fullscreen mode

这样应该就能保存了package.json。现在我们只需要配置服务器,使其响应 WebSocket 连接,并从/static 目录提供静态内容。更新index.js内容如下:



var express = require('express');
var http = require('http');
var ws = require('ws');
var uuid = require('uuid');

const app = express();
app.use(express.static(`${__dirname}/static`));
app.locals.connections = [];

const server = http.createServer(app);
const wss = new ws.Server({ server });

function broadcastConnections() {
    let ids = app.locals.connections.map(c => c._connId);
    app.locals.connections.forEach(c => {
        c.send(JSON.stringify({ type: 'ids', ids }));
    });
}

wss.on('connection', (ws) => {
    app.locals.connections.push(ws);
    ws._connId = `conn-${uuid.v4()}`;

    // send the local id for the connection
    ws.send(JSON.stringify({ type: 'connection', id: ws._connId }));

    // send the list of connection ids
    broadcastConnections();

    ws.on('close', () => {
        let index = app.locals.connections.indexOf(ws);
        app.locals.connections.splice(index, 1);

        // send the list of connection ids
        broadcastConnections();
    });

    ws.on('message', (message) => {
        for (let i = 0; i < app.locals.connections.length; i++) {
            if (app.locals.connections[i] !== ws) {
                app.locals.connections[i].send(message);
            }
        }
    });

});

app.get('/', (req, res) => {
    res.sendFile(path.join(__dirname, 'static/index.html'));
});

server.listen(process.env.PORT || 8081, () => {
    console.log(`Started server on port ${server.address().port}`);
});


Enter fullscreen mode Exit fullscreen mode

在上面的代码中,我们想要搭建一个新的 HTTP 服务器来封装 Express 应用。然后,我们搭建一个 WebSocket 服务器来封装这个 HTTP 服务器。当 WebSocket 服务器收到新的连接时,我们需要将该连接添加到本地列表中,并为其分配一个唯一的 ID 以便后续引用。

每当连接断开时,我们需要清理连接列表,并将可用连接列表发送给当前连接。我们会将该连接列表发送给传入的连接,以便告知其当前连接的设备。最后,每当我们收到消息时,只需将该消息广播给所有其他设备即可。这里并不复杂,我只是为了简化操作才使用广播。

你还会注意到这个app.get路由。我用它来确保渲染index.html该路由的默认页面。

正在连接到 WebSocket

现在我们已经通过 Express 设置好了 WebSocket 服务器,可以使用以下代码快速连接到它。将此代码添加到名为 `.webSocket.js` 的新文件中data.js。将其作为脚本引用添加到我们 `.webSocket.js` 文件的index.html底部data.js



<script type="text/javascript" src="/data.js"></script>


Enter fullscreen mode Exit fullscreen mode


const wsConnection = new WebSocket('ws:127.0.0.1:8081', 'json');
wsConnection.onopen = (e) => {
    console.log(`wsConnection open to 127.0.0.1:8081`, e);
};
wsConnection.onerror = (e) => {
    console.error(`wsConnection error `, e);
};
wsConnection.onmessage = (e) => {
    console.log(JSON.parse(e.data));
};


Enter fullscreen mode Exit fullscreen mode

Web Socket ID

太棒了!现在我们有了已连接设备的 ID 列表。您可以在另一个浏览器窗口中打开相同的内容,应该会看到 2 个连接 ID。您可以通过在控制台中输入以下命令轻松测试我们的 WebSocket 服务器是否正在广播每条消息。



wsConnection.send(JSON.stringify({ type: 'test', msg: 'hello world' }));


Enter fullscreen mode Exit fullscreen mode

WebRTC RTCPeerConnection

现在我们已经有了通过 WebSocket 广播消息的机制,接下来只需要设置一个 WebRTC RTCPeerConnection。为此,我选择了simplepeer。它大大简化了底层 API,而且如果你想将服务器配置为 peer wtrc ,它甚至可以在服务器端工作。让我们更新data.js文件,添加 peer 设置。

将以下代码添加到index.html文件中,以包含simplepeer



<script src="https://unpkg.com/simple-peer@9.7.2/simplepeer.min.js"></script>


Enter fullscreen mode Exit fullscreen mode

我们需要存储一些本地变量,用于首次连接时记录本地对等节点的连接 ID 和对等节点的连接本身。目前,我们暂不考虑实现全网状连接,只进行一次发起方广播。



var localId, peerIds;
var peerConnections = {};
var initiator = false;

wsConnection.onmessage = (e) => {
    let data = JSON.parse(e.data);
    switch (data.type) {
        case 'connection':
            localId = data.id;
            break;
        case 'ids':
            peerIds = data.ids;
            connect();
            break;
        case 'signal':
            signal(data.id, data.data);
            break;
    }
};

function onPeerData(id, data) {
    console.log(`data from ${id}`, data);
}

function connect() {
    // cleanup peer connections not in peer ids
    Object.keys(peerConnections).forEach(id => {
        if (!peerIds.includes(id)) {
            peerConnections[id].destroy();
            delete peerConnections[id];
        }
    });
    if (peerIds.length === 1) {
        initiator = true;
    }
    peerIds.forEach(id => {
        if (id === localId || peerConnections[id]) {
            return;
        }

        let peer = new SimplePeer({
            initiator: initiator
        });
        peer.on('error', console.error);
        peer.on('signal', data => {
            wsConnection.send(JSON.stringify({
                type: 'signal',
                id: localId,
                data
            }));
        });
        peer.on('data', (data) => onPeerData(id, data));
        peerConnections[id] = peer;
    });
}

function signal(id, data) {
    if (peerConnections[id]) {
        peerConnections[id].signal(data);
    }
}


Enter fullscreen mode Exit fullscreen mode

太棒了!现在我们已经建立了一种让用户之间相互沟通的方式。WebRTC 的底层原理很复杂,但其核心思想是这样的:

  • 首位用户加入

    1. 页面首次加载时,WebSocket将连接到WebSocket 服务器。WebSocket服务器接收到连接后,会返回该连接的唯一 ID。
    2. 随后,WebSocket 服务器会将连接 ID 广播给客户端。
    3. 既然如此peerIds.length === 1,我们就是第一个加入的!这意味着我们将作为发起者。最后,由于我们是第一个也是唯一的用户,因此目前还无法建立任何对等连接。
  • 第二位用户加入

    1. 当下一个用户加入时,将通过获取其连接 ID 来对该用户执行相同的流程。用户 1将收到此新连接以及该用户的 ID 列表。
  • 首位用户收到更新后的ID

    1. 一位新用户已加入,因此我们现在可以发起对等连接。当信号可用时(即可以创建RTCPeerConnection Offer),我们将通过 WebSocket 发送该 Offer,供对方用户接收。
  • 第二位用户收到报价

    1. 第二个用户将拥有一个本地 RTCPeerConnection,该连接已准备就绪,可以响应发起的请求。此处将收到以信号形式发送的 WebSocket 消息simplepeer将在调用createAnswer之前先设置远程描述
  • 首位用户收到答案

    1. 一旦第二个用户发送了对初始报价的回复,simplepeer就可以完成协商,并开始通过数据通道或视频/语音(如果我们使用轨道)传输任何信息。

启动 Web 服务器后,您可以通过打开两个单独的浏览器窗口来测试是否正常工作node .

传输抽奖信息

剩下的唯一工作就是传输绘制数据。为此,我们只需更新move函数,使其额外广播一个消息,然后该onPeerData函数需要将消息的结果绘制到画布上。现在就让我们开始吧。



function broadcast(data) {
    Object.values(peerConnections).forEach(peer => {
        peer.send(data);
    });
}

function onPeerData(id, data) {
    draw(JSON.parse(data));
}

function draw(data) {
    context.beginPath();
    context.moveTo(data.lastPoint.x, data.lastPoint.y);
    context.lineTo(data.x, data.y);
    context.strokeStyle = data.color;
    context.lineWidth = Math.pow(data.force || 1, 4) * 2;
    context.lineCap = 'round';
    context.stroke();
}

function move(e) {
    if (e.buttons) {
        if (!lastPoint) {
            lastPoint = { x: e.offsetX, y: e.offsetY };
            return;
        }

        draw({
            lastPoint,
            x: e.offsetX,
            y: e.offsetY,
            force: force,
            color: color || 'green'
        });

        broadcast(JSON.stringify({
            lastPoint,
            x: e.offsetX,
            y: e.offsetY,
            color: color || 'green',
            force: force
        }));

        lastPoint = { x: e.offsetX, y: e.offsetY };
    }
}


Enter fullscreen mode Exit fullscreen mode

就是这样!为了增加一些趣味性,我们随机化颜色来区分不同的同伴。



function randomColor() {
    let r = Math.random() * 255;
    let g = Math.random() * 255;
    let b = Math.random() * 255;
    return `rgb(${r}, ${g}, ${b})`;
}

var color = randomColor();


Enter fullscreen mode Exit fullscreen mode

一起画画

太棒了!🎉


如果你喜欢这篇文章,请关注并点赞。如果你有兴趣,也欢迎关注我的推特。再次感谢!

干杯!🍻

代码

如果你对本系列代码感兴趣,请查看下方我在 GitHub 上的代码库:

https://github.com/nyxtom/drawing-webrtc

再次感谢!

文章来源:https://dev.to/nyxtom/realtime-collaborative-drawing-with-canvas-and-webrtc-2d01