搭建简易画板(三)

183 阅读4分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

前面搭建了一个单人可用的简易画板,这次我们实现一个多人协作画板。 代码库地址

一 基于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, 可以看到控制台和终端都响应了连接。

image.png

image.png

客户端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}`);

            }

        });

    })

});

websocket.gif

二、基于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);

image.png

image.png

接下来我们共享下图片。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);

}

image.png 我们把得到的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