前言
可能大家都听说过WebRTC中可以传输文字文件,但是是怎么实现的我们今天就来看一看。
原理解析
开始之前我们需要认识一个新的APIRTCDataChannel,RTCDataChannel 接口代表在两者之间建立了一个双向数据通道的连接。不仅可以实现1对1的实时聊天,还可以进行文件传输。但是在传输文字和文件过程中还是有不一样的地方的,我们在发送的时候是支持多种数据类型的,包括string,Blob,ArrayBuffer,ArrayBufferView。
由于发送文本数据量较小,一般不会出现失败的问题,但是传输文件的时候,由于网络的原因,就可能出现失败的问题,为了解决这个问题一般我们都是采用断点续传的方式进行文件传输,那么如何进行断点续传呢。 首先我们传输文件的时候一般是要将文件分成多个块,在接收端可以每接受一个文件就告知发送端这个块接受完成了,当传输中断的时候,还可以在上次发生中断的地方进行重传。
知道了传输使用的API和基本原理后,我们来看看是怎么实现的吧。
实现
创建数据通道
首先我们需要知道createDataChannel的参数是啥,
第一个参数是一个名称,该字符串不能长于 65,535 字节。
第二个是可选选项,描述如下
interface RTCDataChannelInit {
id?: number;
maxPacketLifeTime?: number;
maxRetransmits?: number;
negotiated?: boolean;
ordered?: boolean;
protocol?: string;
}
ordered (可选):布尔值,指定数据传输是否需要保证顺序,默认为 true。
maxPacketLifeTime (可选):一个数值,指定数据包的最大生存时间,单位为毫秒。如果设置了该属性,则当数据包超过该时间时,它将被丢弃。
maxRetransmits (可选):一个数值,指定数据包的最大重传次数。如果设置了该属性,则当数据包超过该次数时,它将被丢弃。
protocol (可选):一个字符串,指定协商的子协议名称,默认为 ""。
negotiated (可选):布尔值,指定是否为协商的数据通道,即是否由 RTCDataChannel.createDataChannel() 方法创建,默认为 false。
id (可选):一个数值,指定协商的数据通道 ID,默认为随机生成的值。
首先我们要创建一个数据通道,
let localPeer: RTCPeerConnection;
let localDatachannel: RTCDataChannel;
localDatachannel = localPeer.createDataChannel('file', {
ordered: true, // 保证顺序
maxRetransmits: 50
});
上面设置了ordered,这样可以保证数据的有序到达,maxRetransmits设置为50后,可以在丢包后进行重传,最多重传50次。
这个时候有人就要问了,不能等到建立连接后再创建吗?其实是可以的,但是会导致重新进行ICE协商。
RTCDataChannel事件监听
在创建好RTCDataChannel对象后,我们需要对这个对象进行监听
// 当通道成功打开时触发此事件。此时您可以开始发送数据。
localDatachannel.onopen = (ev) => {
console.log('onopen', ev);
}
// 当通道关闭时触发此事件。这可能是由于连接中断或调用`close()`方法导致的。
localDatachannel.onclose = (ev) => {
console.log('onclose', ev);
}
// 当通道收到新数据时触发此事件。数据可以是字符串或二进制数据。
localDatachannel.onmessage = (ev) => {
console.log('onmessage', ev);
}
// 当通道发生错误时触发此事件。这可能是由于连接问题或其他原因导致的。
localDatachannel.onerror = (ev) => {
console.log('onerror', ev);
}
// 当发送缓冲区的大小低于其缓冲区阈值时触发此事件。这是一个提示,告诉您可以安全地发送更多数据。
localDatachannel.onbufferedamountlow = (ev) => {
console.log('onbufferedamountlow', ev);
}
建立链接
接下来就是要建立连接了,这个过程在之前的文章中有讲过了,这里就不再赘述了,大致的过程就是交换SDP协商ICE。
详见 从0搭建一个WebRTC,实现多房间多对多通话,并实现屏幕录制
发送文字
建立完连接后,我们就可以进行发送文字的尝试了,第一步是发送文字
const sendMessage = async () => {
localDatachannel.send(JSON.stringify({
text: textInput.value, // 输入的文本
uid: uid.value,
}));
}
对端的onmessage会触发,
remoteDatachannel.onmessage = (ev) => {
if (typeof ev.data === 'string') {
const data = JSON.parse(ev.data); // 接收到的文字
}
}
这个时候就是完成了文字的发送和接收了。
发送文件
发送文件就和发送文字不太一样了,首先我们需要让用户选择文件
<input type="file" accept @change="fileChange"/>
const fileChange = async (ev: ProgressEvent<HTMLInputElement>) => {
if (ev.target?.files) {
// 发送进度
sendPercentage.value = 0;
fileSelect = ev.target.files[0]
}
}
选择好文件后就要开始进行文件的传输了,
// 定义一个异步函数,用于传输文件
const startTran = async () => {
let offset = 0; // 偏移量,用于跟踪文件读取进度
localDatachannel.send(JSON.stringify({ // 发送文件信息,包括文件名和大小
fileInfo: {
size: fileSelect.size, // 文件大小
name: fileSelect.name // 文件名
}
}));
const fileReader = new FileReader(); // 创建一个FileReader对象,用于读取文件
// fileReader.readAsArrayBuffer(fileSelect); // 异步读取整个文件,但不会阻塞代码执行
fileReader.onload = (e) => { // 当文件加载完成时触发该函数
if (e.target?.result) { // 如果有数据读取成功
localDatachannel.send(e.target.result); // 将读取的数据通过RTCDataChannel发送
offset += e.target.result?.byteLength; // 更新偏移量,增加已读取的数据长度
if (offset < fileSelect.size) { // 如果还有未读取的数据
sendPercentage.value = parseInt(Math.ceil(offset / fileSelect.size * 100).toString()); // 计算传输进度百分比并更新UI
readSlice(offset); // 继续读取下一段数据
}
}
};
const readSlice = (len: number) => { // 读取指定范围的数据,并将其发送到RTCDataChannel
const slice = fileSelect.slice(offset, len + chunkSize); // 读取一定大小的数据片段
fileReader.readAsArrayBuffer(slice); // 异步读取数据片段,但不会阻塞代码执行
};
readSlice(0); // 从0开始读取文件数据
};
这里开始发送后,远端就要进行接收文件
remoteDatachannel.onmessage = (ev) => {
if (typeof ev.data === 'string') { // 如果接收到的数据是字符串
const data = JSON.parse(ev.data); // 解析JSON数据
if (data.fileInfo) { // 如果数据包含文件信息
remoteFileInfo.value = data.fileInfo; // 更新远程文件信息
return;
}
const sendUser = userLists.filter(item => item[0] === data.uid)[0]; // 获取发送方用户信息
messagePool.value.push({ // 将消息添加到消息池中
nick: sendUser[1].nick, // 发送方昵称
message: data.text, // 发送的消息内容
});
} else { // 如果接收到的数据是二进制数据
receiveFileBuffer.push(ev.data); // 将接收到的数据存储到接收文件缓冲区
receiveSize += ev.data.byteLength; // 更新接收文件的大小
percentage.value = parseInt(Math.ceil((receiveSize / remoteFileInfo.value!.size) * 100).toString()); // 更新传输进度
if (receiveSize === remoteFileInfo.value?.size) { // 如果当前接收的文件等于总大小,认为接收完成
isReceived.value = true; // 更新文件接收状态为true
}
}
};
到这里就是接收完了发送的文件,接下来就是要对接收的文件进行下载。
const mergeFile = () => {
// 如果文件没有完全接收,直接返回
if (!isReceived.value) return;
// 根据接收的文件数据创建 Blob 对象
let received = new Blob(receiveFileBuffer, { type: 'application/octet-stream' });
receiveFileBuffer = []; // 清空接收缓存
receiveSize = 0; // 重置接收文件大小
// 创建一个 <a> 标签
let a = document.createElement('a');
// 通过 URL.createObjectURL() 方法创建 Blob 对象的 URL
a.href = URL.createObjectURL(received);
// 设置下载文件的名称
a.download = remoteFileInfo.value?.name || 'name';
// 模拟用户点击下载链接,触发文件下载
a.click();
// 释放创建的 <a> 标签
a.remove();
// 重置接收状态和文件信息
isReceived.value = false;
remoteFileInfo.value = undefined;
}
接下来用一个gif来演示文件传输
总结
在本文中介绍了如何通过WebRTC的dataChannel进行文件传输,在实际的生产过程中还需要处理很多的边界和异常情况,比如网络中断了,传输被中断了,实现断点续传等等。
本文及以前的文字的源码,未来会更新在GitHub上面。