webrtc之传输文件

上一小节介绍了如何实现简单的音视频通话,这只是最最基础的功能。一个完整的音视频通话系统是有相当多功能的,比如桌面共享、关闭/打开麦克风、发送文件、房间管理等。不过呢,一口气吃不成胖子。功能一点点迭代添加就好了。这节就来介绍如何实现传输文件,以及传输文件的两种方式。

webrtc不仅仅可以用来实现音视频通话,也可以实现数据传输。这里数据类型可以为stringblobArrayBufferArrayBufferView(官方地址)。据此,我们可以实现发送消息,也可以实现传输文件。

发送消息的话很简单,直接字符串就行。但传输文件则有点不同,需要一点点的思考。

分析

首先传输文件的必要前提也是双方已经建立连接了,没连接成功指定是发送不了的。

要发送文件也有两种方式,一种是先询问对方是否需要接收,如果需要再发送;另一种则是直接发送,然后再提示对方有新文件是否需要接收。这两种方式区别不大,主要看场景吧。这里就先实现第一种,礼貌询问再发送,不做无用功。

然后考虑怎么发送文件,也有两种方式,一种是直接把整个文件发送,不做任何处理。这种方式适合体积小、数量少的文件,一旦量大了就容易出现发送失败的问题。另一种方式则是切片发送,即把文件分成一小块一小块,源源不断的发送。这种方式比较适合发送大体积文件,而且如果考虑的够细节,也可以实现网络恢复后继续发送,直至完成。

实现思路的话,也很简单。首先双方肯定是建立好连接了。发起方询问接收方是否接收文件,接收方回复。如果接收,则接收方监听onmessage事件,在这个事件中可以拿到发起方发送的文件。而发起方则是通过DataChannelsend方法来发送文件。

这个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);
};

这里知道onopenonerroroncloseonmessagesend这几个事件就好,下面会用到。

然后这个createDataChannel方法也是有参数的,它的定义如下:

ini 复制代码
 RTCDataChannel createDataChannel(USVString label,
    optional RTCDataChannelInit dataChannelDict = {});
  1. label参数相当于是DataChannel的名字,是个字符串
  2. 另一个参数是个对象,重点有以下几个属性(机翻,感觉大差不差):
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方法来发送数据。之后就是实现了两种传输文件的方式,分别是直接发送和分片发送。这里推荐优先使用分片发送,量大管饱。

下一小节再来介绍如何切换麦克风和摄像头,以及如何使用桌面共享等小功能,冲!

相关推荐
什么鬼昵称18 分钟前
Pikachu-csrf-CSRF(get)
前端·csrf
长天一色36 分钟前
【ECMAScript 从入门到进阶教程】第三部分:高级主题(高级函数与范式,元编程,正则表达式,性能优化)
服务器·开发语言·前端·javascript·性能优化·ecmascript
NiNg_1_2341 小时前
npm、yarn、pnpm之间的区别
前端·npm·node.js
秋殇与星河1 小时前
CSS总结
前端·css
BigYe程普1 小时前
我开发了一个出海全栈SaaS工具,还写了一套全栈开发教程
开发语言·前端·chrome·chatgpt·reactjs·个人开发
余生H2 小时前
前端的全栈混合之路Meteor篇:关于前后端分离及与各框架的对比
前端·javascript·node.js·全栈
程序员-珍2 小时前
使用openapi生成前端请求文件报错 ‘Token “Integer“ does not exist.‘
java·前端·spring boot·后端·restful·个人开发
axihaihai2 小时前
网站开发的发展(后端路由/前后端分离/前端路由)
前端
流烟默2 小时前
Vue中watch监听属性的一些应用总结
前端·javascript·vue.js·watch
2401_857297912 小时前
招联金融2025校招内推
java·前端·算法·金融·求职招聘