本文作者为奇舞团前端开发工程师
前面搭建了一个单人可用的简易画板,这次我们实现一个多人协作画板。代码库地址
一 基于websocket实现的多人协作
主要用到的技术是 websocket 。因为websocket采取的方式是让所有客户端连接服务端,服务器将不同客户端发送给自己的消息进行转发或者广播。使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
在开发方面,WebSocket API 也十分简单,我们只需要实例化 WebSocket,创建连接,然后服务端和客户端就可以相互发送和响应消息。
首先,我们创建客户端代码 client.ts。
export default class websocketManager{
static getInstance(): websocketManager {
if (websocketManager.instance == null) {
websocketManager.instance = new websocketManager();
}
return websocketManager.instance;
}
private static instance: any = null;
websocket = new WebSocket('ws://localhost:3000');
// 创建websocket链接
createWebSocket(drawFunc) {
this.websocket.onopen = function() {
console.log('开始链接')
}
this.websocket.onerror = (err) => {
console.log('websocket错误 ' + err)
// 需要重连
}
this.websocket.onclose = (err) => {
console.log('websocket 关闭 ' + err.reason)
}
this.websocket.onmessage = (event) => {
console.log('接收服务端返回的信息')
}
}
// 关闭websocket
closeWebSocket() {
this.websocket && this.websocket.close()
}
}
其次,我们基于nodejs建立下服务端代码 server.ts,用的 ws 这个库。
// 导入WebSocket模块:
const serverWebSocket = require('ws');
// 实例化:
const wss = new serverWebSocket.Server({
//端口号
port: 3000
});
wss.on('connection', function (ws) {
console.log('服务端连接');
ws.on('message', function (message) {
ws.send(message, (err) => {
if (err) {
console.log(`连接错误: ${err}`);
}
});
})
});
运行下node ./webSocket/server.ts
, 可以看到控制台和终端都响应了连接。
客户端new了一个websocket对象后,会向服务器对应端口发起一个get请求。这里绑定的是3000端口,默认情况下,websocket使用80端口。后续客户端和服务端会在这个预定的端口上进行通信。
请求报文中的 upgrade 字段 是告诉服务端需要将通信协议切换到websocket,如果服务端支持websocket协议,那么它就会将请求报文中的Sec-WebSocket-Key解析出来,然后进行相应的拼接加密编码,将最后的结果作为 Sec-WebSocket-Accept 的值返回给客户端,并将自己的通信协议切换到websocket,返回状态码101。
以上过程都是利用http通信完成的,称之为websocket协议握手。握手之后,客户端和服务端就建立了websocket连接,以后的通信走的都是websocket协议。
“Sec-WebSocket-Key”是 WebSocket 客户端发送的一个 base64 编码的密文,要求服务端必须返回一个对应加密的“Sec-WebSocket-Accept”应答,否则客户端会抛出“Error during WebSocket handshake”错误,并关闭连接。
建立连接之后,我们就可以进行数据传输了,websocket提供两种数据传输:文本数据和二进制数据。
回归到画板,我们开多个窗口来模拟下,每个链接后面我们拼一个用户id。然后一个用户在画的时候,每当笔抬起,就发送一次send请求,修改下绘制函数,这样其他用户能实时看到。服务端新增一个保存当前房间的大对象,里面是各个用户id下的绘图数据。
// client.ts add
this.websocket.onmessage = (event) => {
drawFunc(JSON.parse(event.data))
}
sendMessage(value) {
this.websocket.send(`${JSON.stringify(value)}`)
}
// pointer.ts add
function drawAll(userPathData) {
let canvasDom: any = document.getElementById('drawCanvas');
let curCtx = canvasDom!.getContext('2d');
let rect = canvasDom!.getBoundingClientRect();
curCtx.clearRect(rect.x, rect.y, rect.width, rect.height);
for (const key in userPathData) {
if (Object.prototype.hasOwnProperty.call(userPathData, key)) {
const pathData = userPathData[key];
pathData.map(item => {
if (Object.prototype.toString.call(item) === '[object Array]') {
item.map(info => draw(info, curCtx))
} else {
flowDraw(item, curCtx)
}
})
}
}
}
// 撤销函数更改
function undo() {
pathData.pop();
websocketManager.getInstance().sendMessage(pathData)
// let canvasDom: any = document.getElementById('drawCanvas');
// let curCtx = canvasDom!.getContext('2d');
// let rect = canvasDom!.getBoundingClientRect();
// curCtx.clearRect(rect.x, rect.y, rect.width, rect.height);
// pathData.map(item => {
// if (Object.prototype.toString.call(item) === '[object Array]') {
// item.map(info => draw(info, curCtx))
// } else {
// flowDraw(item, curCtx)
// }
// })
}
// 监听鼠标放开函数中取消绘制函数
function handleMouseMove(event) {
if (mouseButtonDown && !config.flowType) {
let singleData = {beginX: lastPt.x, beginY: lastPt.y, lastX: event.pageX, lastY: event.pageY, strokeStyle: config.strokeStyle, lineWidth: config.lineWidth, drawType: config.drawType, flowType: config.flowType};
singlePathData.push(singleData)
// draw(singleData)
lastPt = {
x: event.pageX,
y: event.pageY
}
}
if (mouseButtonDown && config.flowType) {
let flowPathData = {beginX: flowLastPt.x, beginY: flowLastPt.y, lastX: event.pageX, lastY: event.pageY, strokeStyle: config.strokeStyle, lineWidth: config.lineWidth, drawType: config.drawType, flowType: config.flowType};
tempDomDraw(flowPathData)
}
}
// useEffect函数中增加websocket逻辑
useEffect(() => {
...
websocketManager.getInstance().createWebSocket(drawAll);
return () => {
websocketManager.getInstance().closeWebSocket()
}
}, [])
// server.ts add
let allData = {};
wss.on('connection', function (ws) {
console.log('服务端连接');
ws.on('message', function (message) {
const {userID, pathData} = JSON.parse(message);
allData[userID] = pathData;
ws.send(JSON.stringify(allData), (err) => {
if (err) {
console.log(`连接错误: ${err}`);
}
});
})
});
二、基于share实现的文件共享
Navigator.share()
方法通过调用本机的共享机制。是 Web Share API 的一部分。不过这是一个实验中的功能,浏览器兼容性不是很好。语法很简单。
/*
data可用选项包括:
url: 要共享的 URL( USVString )
text: 要共享的文本( USVString )
title: 要共享的标题( USVString)
files: 要共享的文件(“FrozenArray”)
*/
const data = {
title: document.title,
text: '简易画板分享链接',
url: document.location.href
}
const sharePromise = window.navigator.share(data);
接下来我们共享下图片。share API 的files参数需要接收 file 格式的数组,其次生成file的构造函数方法,它接收的是UTF-8 格式的编码(一种针对Unicode的可变长度字符编码)格式的数组。但canvas.toDataURL("image/png")
生成的是Data URL,由四个部分组成:前缀 (data:)、指示数据类型的 MIME 类型、Base64编码标记以及数据本身。所以我们需要将base64编码的数据转化成UTF-8再转化成file。
data URL 也是 URL,网上有base64转化成UTF-8用的是decodeURI。但亲测这个方法会报错,原因是canvas生成的data URI相对来说很长,又经过了base64的编码,一些换行符、制表符、空格的格式化会有问题。
/* file 构造函数
`bits: 一个包含ArrayBuffer,ArrayBufferView,Blob,或者 DOMString 对象的 Array
*/
let file = new window.File([bits], filename[, options]);
// 生成canvas图片地址
let imgUrl:any = canvas.toDataURL("image/png");
// data:image/png;base64 字符需要单独提出来
var arr = imgUrl.split(',');
// 拿到图片格式 image/png
var mime = arr[0].match(/:(.*?);/)[1];
var suffix = mime.split('/')[1];
// 解析后面的文件流
var bstr = atob(arr[1]);
var n = bstr.length;
// 初始化Uint8Array数组
var u8arr = new Uint8Array(n);
while(n--) {
// 对应位置放上相应字符的unicode编码
u8arr[n] = bstr.charCodeAt(n);
}
我们把得到的file文件读取成路径形式,和咱们一开始的toDataURL得到的路径是一样的。
var reader = new FileReader();
reader.readAsDataURL(imgFile);
reader.onload = function() {
console.log(this.result == imgUrl) // true
}
navigator.share({
files: [imgFile]
})
.then(() => {
console.log('Share was successful.')
})
.catch((error) => console.log('Sharing failed', error));
参考资料
MDN websocket
ws
MDN share
- END -
关于奇舞团
奇舞团是 360 集团最大的大前端团队,代表集团参与 W3C 和 ECMA 会员(TC39)工作。奇舞团非常重视人才培养,有工程师、讲师、翻译官、业务接口人、团队 Leader 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。