使用 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>
/** 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));
}
请注意,我上面添加的这些实用工具都是基本的Flexbox属性。我只是想用简单的间距来按行和列布局元素。我把它们分别命名为hstack、vstack、spacer和flush(用于最大化高度)。
带有 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();
太棒了!我们的应用目前功能还很有限。让我们添加一些可以切换上下文的工具吧。
用形状作画
如果要在屏幕上绘制任何东西,我们需要某种画笔。由于我们没有实际的颜料或铅笔颗粒,所以我们必须通过反复绘制形状来创建自己的“颗粒”。让我们看看这种方法在以下情况下会产生什么效果:
function move(e) {
if (e.buttons) {
context.fillStyle = 'green';
context.beginPath();
context.arc(e.x, e.y,
}
}
window.onmousemove = move;
这里,每次调用`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;
现在我们可以用退格键清除屏幕,并且不再出现间隙,因为我们是在鼠标移动事件发生的点之间绘制路径。
力/压力灵敏度
我发现可以通过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;
同步状态
到目前为止,我们还没有进行太多与他人实时绘图的尝试。正如我在一篇关于CRDT的文章中提到的,同步有两种方法:
- 基于状态的同步(使用 CRDT)
- 基于操作的同步(使用 CRDT 或操作转换)
我们将改为通过变更缓冲区来传输每次变更信息。我们会定期将此缓冲区的内容批量发送到网络中的其他节点,以便更新本地状态。
如果你对 WebRTC 的工作原理感兴趣,不妨看看这本《WebRTC for the Curious》。它是由 WebRTC 内部机制的早期开发者撰写的!对于理解 WebRTC API 底层的工作原理非常有帮助。
设置 WebSocket 服务器
为了与对等节点进行协商,我们需要通过服务器传递信号、提议和连接信息。我们将使用Express、HTTP和WebSocket库。我们希望服务器实现以下功能:
- 接受传入连接
- 广播可用连接
- 处理 RTC 握手,包括报价、答复、冰冻候选人、挂断等。
首先,将index.html、draw.js、index.css及相关公共文件的内容移动到一个新文件夹中/static。然后在根目录创建一个名为 `.node_modules` 的新文件index.js。运行以下命令初始化 Node 项目。
npm init -y
你应该看到以下输出。
{
"name": "map",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
现在,我们的项目需要一些依赖项。运行:
npm install --save ws express uuid
这样应该就能保存了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}`);
});
在上面的代码中,我们想要搭建一个新的 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>
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));
};
太棒了!现在我们有了已连接设备的 ID 列表。您可以在另一个浏览器窗口中打开相同的内容,应该会看到 2 个连接 ID。您可以通过在控制台中输入以下命令轻松测试我们的 WebSocket 服务器是否正在广播每条消息。
wsConnection.send(JSON.stringify({ type: 'test', msg: 'hello world' }));
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>
我们需要存储一些本地变量,用于首次连接时记录本地对等节点的连接 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);
}
}
太棒了!现在我们已经建立了一种让用户之间相互沟通的方式。WebRTC 的底层原理很复杂,但其核心思想是这样的:
-
首位用户加入
- 页面首次加载时,WebSocket将连接到WebSocket 服务器。WebSocket服务器接收到连接后,会返回该连接的唯一 ID。
- 随后,WebSocket 服务器会将连接 ID 广播给客户端。
- 既然如此
peerIds.length === 1,我们就是第一个加入的!这意味着我们将作为发起者。最后,由于我们是第一个也是唯一的用户,因此目前还无法建立任何对等连接。
-
第二位用户加入
- 当下一个用户加入时,将通过获取其连接 ID 来对该用户执行相同的流程。用户 1将收到此新连接以及该用户的 ID 列表。
-
首位用户收到更新后的ID
- 一位新用户已加入,因此我们现在可以发起对等连接。当信号可用时(即可以创建RTCPeerConnection Offer),我们将通过 WebSocket 发送该 Offer,供对方用户接收。
-
第二位用户收到报价
- 第二个用户将拥有一个本地 RTCPeerConnection,该连接已准备就绪,可以响应发起的请求。此处将收到以信号形式发送的 WebSocket 消息,simplepeer将在调用createAnswer之前先设置远程描述。
-
首位用户收到答案
- 一旦第二个用户发送了对初始报价的回复,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 };
}
}
就是这样!为了增加一些趣味性,我们随机化颜色来区分不同的同伴。
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();
太棒了!🎉
如果你喜欢这篇文章,请关注并点赞。如果你有兴趣,也欢迎关注我的推特。再次感谢!
干杯!🍻
代码
如果你对本系列代码感兴趣,请查看下方我在 GitHub 上的代码库:
https://github.com/nyxtom/drawing-webrtc
再次感谢!
文章来源:https://dev.to/nyxtom/realtime-collaborative-drawing-with-canvas-and-webrtc-2d01






