25、项目实战:视频会议系统(四):DataChannel实现聊天与文件传输、会议控制(静音、摄像头开关、踢人)

各位同学,欢迎来到实战系列的第四篇。前面几章我们把视频会议的媒体通路打通了,信令也跑通了。但一个真正的会议系统,光有画面和声音是不够的。你想想看,开会时大家要发个链接、传个文档,或者有人忘了关麦,背景音里传来他家猫叫——这时候你就需要聊天、文件传输和会议控制功能了。

这一章,我们就用 WebRTC 的 DataChannel 把这些功能一一实现。DataChannel 说白了就是一条独立于音视频的数据高速公路,它不经过服务器中转,直接点对点传输。嗯,这里要注意,虽然它走的是 P2P 通道,但信令还是需要服务器帮忙建立连接的。

核心知识点: DataChannel 基于 SCTP 协议,支持可靠和不可靠两种模式。聊天用可靠模式,文件传输可以用可靠模式,实时控制信令建议用不可靠模式(减少延迟)。

25.1 DataChannel 基础:创建与事件监听

我个人习惯在创建 PeerConnection 之后,立刻初始化 DataChannel。这样在媒体协商完成时,数据通道也准备好了。来看一段核心代码:

// 创建 DataChannel(通常在发起方创建)
const dataChannel = peerConnection.createDataChannel('chat', {
  ordered: true,        // 保证消息顺序
  maxRetransmits: 3     // 最多重传3次,避免无限重试
});

// 监听通道打开事件
dataChannel.onopen = () => {
  console.log('DataChannel 已打开');
  // 可以在这里更新 UI,比如启用发送按钮
};

// 监听消息事件
dataChannel.onmessage = (event) => {
  const message = JSON.parse(event.data);
  handleIncomingMessage(message);
};

// 监听错误
dataChannel.onerror = (error) => {
  console.error('DataChannel 错误:', error);
};

// 监听关闭
dataChannel.onclose = () => {
  console.log('DataChannel 已关闭');
};

我在项目中遇到过一个问题:如果 DataChannel 在 ICE 连接稳定之前就发送数据,可能会丢失。所以我的建议是:在 iceConnectionState === 'connected' 之后再允许用户发送消息。

小技巧: 接收方不需要主动创建 DataChannel,只需要监听 PeerConnection 的 ondatachannel 事件即可。它会拿到与发送方配对的 DataChannel 实例。

25.2 聊天功能:文本消息的发送与接收

聊天功能是 DataChannel 最直接的应用。我们定义一个统一的消息格式,方便扩展:

// 消息结构
{
  type: 'chat',         // 消息类型:chat | file | control
  sender: 'user_123',   // 发送者 ID
  payload: {
    text: '大家好,我是张三',
    timestamp: Date.now()
  }
}

// 发送消息
function sendChatMessage(text) {
  if (dataChannel.readyState !== 'open') {
    console.warn('通道未就绪');
    return;
  }
  const message = {
    type: 'chat',
    sender: localUserId,
    payload: { text, timestamp: Date.now() }
  };
  dataChannel.send(JSON.stringify(message));
}

接收端解析后,将消息渲染到聊天面板。这里要注意 XSS 防护,不要直接用 innerHTML,建议用 textContent 或者做转义处理。

曾经踩过的坑: 有一次我忘了检查 readyState,结果在通道关闭后还调用 send,导致控制台报错且消息丢失。所以发送前一定要判断状态。

25.3 文件传输:分片发送与进度反馈

文件传输比文本复杂一些。因为 DataChannel 的单条消息大小有限制(通常 16KB 到 64KB 不等),大文件必须分片。我一般这样设计:

  1. 发送方先发送一个文件元信息(文件名、大小、分片总数)
  2. 然后按顺序发送每个分片
  3. 接收方收到所有分片后,组装成完整文件并触发下载
// 发送文件
async function sendFile(file) {
  const chunkSize = 16 * 1024; // 16KB 一片
  const totalChunks = Math.ceil(file.size / chunkSize);
  
  // 先发元信息
  const meta = {
    type: 'file',
    subtype: 'meta',
    fileName: file.name,
    fileSize: file.size,
    totalChunks,
    fileType: file.type
  };
  dataChannel.send(JSON.stringify(meta));
  
  // 再发分片
  let offset = 0;
  for (let i = 0; i < totalChunks; i++) {
    const chunk = file.slice(offset, offset + chunkSize);
    const buffer = await chunk.arrayBuffer();
    // 二进制数据用 send 直接发送 ArrayBuffer
    dataChannel.send(buffer);
    offset += chunkSize;
    
    // 更新进度(可选)
    updateProgress(i + 1, totalChunks);
  }
}

接收方需要维护一个文件缓冲区:

let fileBuffer = {
  meta: null,
  chunks: [],
  receivedCount: 0
};

dataChannel.onmessage = (event) => {
  if (typeof event.data === 'string') {
    const msg = JSON.parse(event.data);
    if (msg.type === 'file' && msg.subtype === 'meta') {
      fileBuffer.meta = msg;
      fileBuffer.chunks = new Array(msg.totalChunks);
      fileBuffer.receivedCount = 0;
      showFileReceiveProgress(msg);
    }
  } else if (event.data instanceof ArrayBuffer) {
    // 二进制分片
    const chunkIndex = fileBuffer.receivedCount;
    fileBuffer.chunks[chunkIndex] = event.data;
    fileBuffer.receivedCount++;
    
    if (fileBuffer.receivedCount === fileBuffer.meta.totalChunks) {
      assembleFile(fileBuffer);
    }
  }
};
经验之谈: 文件传输时,建议将 DataChannel 设置为 ordered: truemaxRetransmits: 0(即只重传一次)。这样既保证顺序,又不会因为网络抖动导致文件传输卡死。

25.4 会议控制:静音、摄像头开关、踢人

会议控制功能需要区分「本地操作」和「远程操作」。本地操作直接控制自己的媒体轨道,远程操作则通过 DataChannel 发送控制指令。

25.4.1 本地静音与摄像头开关

// 切换麦克风
function toggleMic() {
  const audioTrack = localStream.getAudioTracks()[0];
  if (audioTrack) {
    audioTrack.enabled = !audioTrack.enabled;
    // 通知远端
    sendControlMessage('mic', audioTrack.enabled);
  }
}

// 切换摄像头
function toggleCamera() {
  const videoTrack = localStream.getVideoTracks()[0];
  if (videoTrack) {
    videoTrack.enabled = !videoTrack.enabled;
    sendControlMessage('camera', videoTrack.enabled);
  }
}

// 发送控制指令
function sendControlMessage(action, value) {
  const msg = {
    type: 'control',
    action: action,   // 'mic' | 'camera' | 'kick'
    value: value,
    targetUserId: '...' // 踢人时需要指定目标
  };
  dataChannel.send(JSON.stringify(msg));
}

远端收到控制消息后,更新对应用户的 UI 状态(比如显示「麦克风已关闭」图标)。注意:远端无法直接操作你的本地媒体,只能通过 UI 提示。

25.4.2 踢人功能

踢人需要结合信令服务器。DataChannel 发送踢人指令后,接收方收到指令,主动关闭 PeerConnection 并断开信令连接。同时,信令服务器也要将该用户从房间中移除。

// 踢人指令处理(接收方)
function handleKickCommand(targetUserId) {
  if (targetUserId === localUserId) {
    // 我被踢了
    alert('你已被移出会议');
    peerConnection.close();
    // 断开信令连接
    signalingSocket.close();
    // 返回会议室列表
    navigateToRoomList();
  }
}
注意: 踢人操作一定要在服务端做权限校验,不能完全依赖客户端。否则恶意用户可以通过修改前端代码绕过踢人逻辑。我曾在生产环境中见过这种漏洞,后来加了一层服务端 token 验证才解决。

25.5 整体架构图

下面这张图展示了 DataChannel 在整个视频会议系统中的位置,以及它与媒体流、信令服务器的关系:

DataChannel 在视频会议中的角色 用户A(发起方) 用户B(接收方) 音视频媒体流 音视频媒体流 DataChannel DataChannel 信令连接 信令连接 SRTP/SCTP (P2P) DataChannel (P2P) 信令服务器 聊天、文件传输、控制指令均通过 DataChannel 传输,不经过服务器

25.6 状态管理与 UI 联动

会议控制的核心是状态同步。我建议用一个全局状态对象来管理所有与会者的状态:

const participants = {
  'user_123': {
    displayName: '张三',
    micEnabled: true,
    cameraEnabled: true,
    isHost: false,
    dataChannel: null
  },
  // ...
};

// 收到控制消息时更新状态
function handleControlMessage(msg) {
  const participant = participants[msg.sender];
  if (!participant) return;
  
  if (msg.action === 'mic') {
    participant.micEnabled = msg.value;
  } else if (msg.action === 'camera') {
    participant.cameraEnabled = msg.value;
  }
  // 触发 UI 更新
  renderParticipantList();
}
我的习惯: 在 DataChannel 的 onopen 回调中,立即发送一次当前用户的完整状态(mic、camera 开关状态)。这样新加入的用户可以立刻看到所有人的状态,不需要等待后续操作。

25.7 错误处理与重连

DataChannel 虽然可靠,但网络波动时仍可能断开。我一般这样处理:

  • 监听 onclose 事件,显示「连接已断开」提示
  • 如果 ICE 连接重新建立,DataChannel 会自动恢复(前提是 PeerConnection 没有关闭)
  • onopen 中重新同步一次状态
dataChannel.onclose = () => {
  showToast('数据通道断开,尝试重连...');
  // 不要销毁 dataChannel 实例,等待 ICE 重连后会自动触发 onopen
};

// ICE 连接状态变化
peerConnection.oniceconnectionstatechange = () => {
  if (peerConnection.iceConnectionState === 'connected') {
    // 通道可能已经自动重开,检查状态
    if (dataChannel.readyState === 'open') {
      syncParticipantStatus();
    }
  }
};

好了,这一章的内容就到这里。DataChannel 的功能其实远不止这些,你还可以用它实现白板同步、光标位置共享、甚至小游戏。核心思路都是一样的:定义好消息协议,处理好状态同步,注意边界情况。

希望这些实战经验能帮你少走弯路。如果你在实现过程中遇到问题,欢迎随时交流。

公众号:蓝海资料掘金营,微信deep3321