25、项目实战:视频会议系统(四):DataChannel实现聊天与文件传输、会议控制(静音、摄像头开关、踢人)
各位同学,欢迎来到实战系列的第四篇。前面几章我们把视频会议的媒体通路打通了,信令也跑通了。但一个真正的会议系统,光有画面和声音是不够的。你想想看,开会时大家要发个链接、传个文档,或者有人忘了关麦,背景音里传来他家猫叫——这时候你就需要聊天、文件传输和会议控制功能了。
这一章,我们就用 WebRTC 的 DataChannel 把这些功能一一实现。DataChannel 说白了就是一条独立于音视频的数据高速公路,它不经过服务器中转,直接点对点传输。嗯,这里要注意,虽然它走的是 P2P 通道,但信令还是需要服务器帮忙建立连接的。
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' 之后再允许用户发送消息。
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 不等),大文件必须分片。我一般这样设计:
- 发送方先发送一个文件元信息(文件名、大小、分片总数)
- 然后按顺序发送每个分片
- 接收方收到所有分片后,组装成完整文件并触发下载
// 发送文件
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);
}
}
};
ordered: true 但 maxRetransmits: 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();
}
}
25.5 整体架构图
下面这张图展示了 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();
}
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 的功能其实远不止这些,你还可以用它实现白板同步、光标位置共享、甚至小游戏。核心思路都是一样的:定义好消息协议,处理好状态同步,注意边界情况。
希望这些实战经验能帮你少走弯路。如果你在实现过程中遇到问题,欢迎随时交流。