上一小节介绍了如何实现简单的音视频通话,这只是最最基础的功能。一个完整的音视频通话系统是有相当多功能的,比如桌面共享、关闭/打开麦克风、发送文件、房间管理等。不过呢,一口气吃不成胖子。功能一点点迭代添加就好了。这节就来介绍如何实现传输文件,以及传输文件的两种方式。
webrtc不仅仅可以用来实现音视频通话,也可以实现数据传输。这里数据类型可以为string
、blob
、ArrayBuffer
和ArrayBufferView
(官方地址)。据此,我们可以实现发送消息,也可以实现传输文件。
发送消息的话很简单,直接字符串就行。但传输文件则有点不同,需要一点点的思考。
分析
首先传输文件的必要前提也是双方已经建立连接了,没连接成功指定是发送不了的。
要发送文件也有两种方式,一种是先询问对方是否需要接收,如果需要再发送;另一种则是直接发送,然后再提示对方有新文件是否需要接收。这两种方式区别不大,主要看场景吧。这里就先实现第一种,礼貌询问再发送,不做无用功。
然后考虑怎么发送文件,也有两种方式,一种是直接把整个文件发送,不做任何处理。这种方式适合体积小、数量少的文件,一旦量大了就容易出现发送失败的问题。另一种方式则是切片发送,即把文件分成一小块一小块,源源不断的发送。这种方式比较适合发送大体积文件,而且如果考虑的够细节,也可以实现网络恢复后继续发送,直至完成。
实现思路的话,也很简单。首先双方肯定是建立好连接了。发起方询问接收方是否接收文件,接收方回复。如果接收,则接收方监听onmessage
事件,在这个事件中可以拿到发起方发送的文件。而发起方则是通过DataChannel
的send
方法来发送文件。
这个onmessage
事件和send
方法则是新的API,如下:
API
在PeerConnection
对象中有一个createDataChannel
方法,用来创建出DataChannel
对象。这个对象是专门用来传输数据的,它在文档中的定义如下:
ini
interface RTCDataChannel : EventTarget {
readonly attribute USVString label;
readonly attribute boolean ordered;
readonly attribute unsigned short ? maxPacketLifeTime;
readonly attribute unsigned short ? maxRetransmits;
readonly attribute USVString protocol;
readonly attribute boolean negotiated;
readonly attribute unsigned short ? id;
readonly attribute RTCDataChannelState readyState;
readonly attribute unsigned long bufferedAmount;
[EnforceRange] attribute unsigned long bufferedAmountLowThreshold;
attribute EventHandler onopen;
attribute EventHandler onbufferedamountlow;
attribute EventHandler onerror;
attribute EventHandler onclosing;
attribute EventHandler onclose;
undefined close();
attribute EventHandler onmessage;
attribute BinaryType binaryType;
undefined send(USVString data);
undefined send(Blob data);
undefined send(ArrayBuffer data);
undefined send(ArrayBufferView data);
};
这里知道onopen
、onerror
、onclose
、onmessage
和send
这几个事件就好,下面会用到。
然后这个createDataChannel
方法也是有参数的,它的定义如下:
ini
RTCDataChannel createDataChannel(USVString label,
optional RTCDataChannelInit dataChannelDict = {});
label
参数相当于是DataChannel
的名字,是个字符串- 另一个参数是个对象,重点有以下几个属性(机翻,感觉大差不差):
ini
dictionary RTCDataChannelInit {
boolean ordered = true; // 如果设置为 false,则允许无序传送数据。默认值 true,保证数据按顺序传送。
[EnforceRange] unsigned short maxPacketLifeTime; // 限制通道在未确认的情况下传输或重新传输数据的时间(以毫秒为单位)。如果该值超过用户代理支持的最大值,则可能会限制该值。
[EnforceRange] unsigned short maxRetransmits; // 限制通道在未成功传送的情况下重新传输数据的次数。如果该值超过用户代理支持的最大值,则可能会限制该值。
USVString protocol = ""; // 用于该通道的子协议名称。
boolean negotiated = false; // 默认值 false 告诉用户代理在带内通告通道并指示其他对等点调度相应的 RTCDataChannel 对象。如果设置为 true,则由应用程序协商通道并在另一个对等方创建具有相同 id 的 RTCDataChannel 对象。
[EnforceRange] unsigned short id; // 当协商为 true 时设置通道 ID。协商时忽略是错误的。
};
使用的话,只传一个label
也可以用。
ini
channel = pc.createDataChannel('chat');
在PeerConnection
对象上还有一个ondatachannel
方法,用来监听DataChannel
对象。在这个方法中可以拿到DataChannel
对象。
几个api已经介绍完了,下面就来试试水,看看怎么用起来。
实现
这里双方建立连接的过程就不再赘述了,看上一节就行。还有询问对方是否接收文件,我感觉也不太需要,复用询问对方是否接收通话的事件,然后加个判断即可,这里也不再说了。
首先是创建出DataChannel
对象,并且监听相应的事件,代码如下:
ini
// 给 pc添加事件
const onPcEvent = (pc, userId, targetUserId) => {
// ......
// 创建消息通道,建立webRTC通信之后,就可以直接 p2p 的直接发送消息, 无需中转服务器
channel.value = pc.createDataChannel("chat");
// 用于处理当数据通道被添加到连接时触发
pc.ondatachannel = (e) => {
e.channel.onopen = () => {
console.log("通道打开");
};
e.channel.onclose = () => {
console.log("通道关闭");
};
e.channel.onmessage = (data) => {
// data为传输数据
// ...代码下面再添加
};
};
}
非常简单,之后发送数据的话,直接调用channel.value.send()
方法即可,接收数据则是在onmessage
事件中。
这里先看看html部分,有两种,一种是发送消息,一种是发送文件:
ini
<div class="top">
<div>
发送消息:
<el-input v-model="msg" placeholder="发送消息" />
</div>
<div>
远端消息:
<el-input v-model="remoteMsg" placeholder="" disabled />
</div>
<div>
<el-button @click="sendMessageUserRtcChannel">点击发送</el-button>
</div>
<div class="upload">
<el-upload
v-model:file-list="fileList"
class="upload-demo"
action=""
multiple
show-file-list
:http-request="httpRequest"
>
<el-button type="primary">点击上传</el-button>
<template #tip>
<div class="el-upload__tip">可上传文件或图片</div>
</template>
</el-upload>
</div>
</div>
代码很简单,看看就行。
下面来看看发送文件的第一种方式:
ini
// 修改一下onmessage事件
// 用于处理当数据通道被添加到连接时触发
pc.ondatachannel = (e) => {
e.channel.onopen = () => {
console.log("通道打开");
};
e.channel.onclose = () => {
console.log("通道关闭");
};
e.channel.onmessage = (data) => {
if (isFile.value) {
const blob = dataURItoBlob(data.data);
// 生成下载地址
const url = URL.createObjectURL(blob);
const elink = document.createElement("a");
elink.download = "下载";
elink.style.display = "none";
elink.href = url;
document.body.appendChild(elink);
elink.click();
document.body.removeChild(elink);
} else {
remoteMsg.value = data.data; // 接收到远端消息
}
};
};
// 自定义请求 发送文件
const httpRequest = () => {
const type = fileList.value[0].raw.type;
linkSocket.value.emit("sendFile", {
userId: props.userId,
targetUserId: remoteUserId.value,
content: "我将要发送文件了",
fileType: type,
});
};
const sendFile = () => {
if (!channel.value) return;
const file = fileList.value[0];
const fileReader = new FileReader(); // 创建文件读取对象
fileReader.readAsDataURL(file.raw);
fileReader.onload = (e) => {
channel.value.send(e.target.result); // 发送数据
};
};
// 接收到远程要发送文件的信息
const onSendFile = (fromUserId, content, targetUserId, type) => {
if (targetUserId !== props.userId) return; // 不是自己的文件
ElMessageBox.confirm(`即将接收到来自${fromUserId}的文件`, "info", {
confirmButtonText: "接收",
cancelButtonText: "不要",
type: "info",
center: true,
})
.then(() => {
isFile.value = true; // 将要接收的信息类型 设置为文件
fileType.value = type;
linkSocket.value.emit("receiveFile", {
userId: targetUserId,
targetUserId: fromUserId,
content: "我准备好了",
type: "OK",
});
})
.catch(() => {
linkSocket.value.emit("receiveFile", {
userId: targetUserId,
targetUserId: fromUserId,
content: "哥们我不要你的东西",
type: "NO",
});
});
};
// 发送文件了
const onReceiveFile = (fromUserId, content, targetUserId, type) => {
if (targetUserId !== props.userId) return; // 不是自己
if (type === "OK") {
ElMessage.success("对方同意接收你的文件");
sendFile();
} else {
ElMessage.error("对方拒绝了你的文件,并给你一个大嘴巴子");
}
};
首先上传文件后询问对方是否接收文件,如果接收的话,则将消息类型设置为文件,并且设置文件的类型,以便在接收到数据后重新生成文件。然后回应发起方,发起方收到消息后再读取文件数据,读取完之后送出去。然后接收方在onmessage
里面拿到数据并下载下来。
这个方式还是挺简单的,唯一麻烦的地方在于,需要先读取文件类型发送给对方。当然,也可以拼接在数据前面,用特殊字符来分割,就不需要再多一个事件了(突然想到的)。
再来看看第二种:
ini
import { useFileDialog } from "@vueuse/core";
<svg-icon name="上传图片" class="cursor-pointer text-20px" @click="handleAddImg" />
<el-button type="primary" @click="handleSendPublicMsg">发送</el-button>
const { files, open: handleAddImg } = useFileDialog();
// 读取文件 获取原始字节流
export const getFileAsArrayBuffer = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
}
// 发送文件
const handleSendMsg = async () => {
const len = upLoadImgsList.value.length;
if (len) {
// 分片上传
const filesObj = unref(files);
for (let i = 0; i < len; i++) {
const file = filesObj[i];
const metadata = {
name: file.name,
type: file.type,
size: file.size,
content: 'file-metadata'
};
// 先发送文件类型数据
channel.value.send(metadata);
const fileData = await getFileAsArrayBuffer(file);
const chunkSize = 64 * 1024; // 64KB
for (let start = 0; start < fileData.byteLength; start += chunkSize) {
const chunk = fileData.slice(start, start + chunkSize);
// 后发送文件片段数据
channel.value.send({ name: file.name, chunk, start });
}
}
}
// 重置
publicMsg.value = "";
upLoadImgsList.value = [];
};
// 接收方
// 用于处理当数据通道被添加到连接时触发
pc.ondatachannel = (e) => {
e.channel.onopen = () => {
console.log("通道打开");
};
e.channel.onclose = () => {
console.log("通道关闭");
};
e.channel.onmessage = (data) => {
if (isFile.value) {
// 上传图片 图片类型
if (data.content === 'file-metadata') {
const { name, type, size, msgId } = data;
fileBuffers[name] = { type, size, chunks: [], receivedSize: 0, msgId };
} else {
// 上传图片 图片数据 分片上传
const { name, chunk, start } = data;
if (fileBuffers[name]) {
fileBuffers[name].chunks.push(chunk);
fileBuffers[name].receivedSize += chunk.byteLength;
// Check if all chunks are received
if (fileBuffers[name].receivedSize === fileBuffers[name].size) {
const blob = new Blob(fileBuffers[name].chunks, {
type: fileBuffers[name].type,
});
const blobUrl = URL.createObjectURL(blob);
console.log(blobUrl, "useObjectUrl");
// 生成下载地址
const url = URL.createObjectURL(blob);
const elink = document.createElement("a");
elink.download = name;
elink.style.display = "none";
elink.href = url;
document.body.appendChild(elink);
elink.click();
document.body.removeChild(elink);
// Clean up
delete fileBuffers[name];
}
}
}
} else {
remoteMsg.value = data.data; // 接收到远端消息
}
};
};
这里的话,使用到了vueuse的上传文件方法,然后获取文件内容分片处理,接收方将接收到的数据按照顺序来拼接。这里最重要一点就是发送的数据是按照顺序来的,不会打乱数据顺序,不然这种方式就实现不了了。
对比两种方式,感觉差别不是很大。不过,更推荐第二种方式。
到这里就介绍完了两种传输文件的方式了,并没有什么特别难的地方。重点在于创建DataChannel
对象,并且知道如何发送数据(使用send
)和在哪里接收数据(监听onmessage
)。当然,一切的大前提是已经成功建立连接了。
小节
本小节主要介绍了如何实现传输文件。首先是创建DataChannel
对象,然后监听该对象的onmessage
来接收数据,调用该对象的send
方法来发送数据。之后就是实现了两种传输文件的方式,分别是直接发送和分片发送。这里推荐优先使用分片发送,量大管饱。
下一小节再来介绍如何切换麦克风和摄像头,以及如何使用桌面共享等小功能,冲!