Skip to content

WebRTC - 文本演示

在本章中,我们将构建一个客户端应用,允许两台不同设备上的用户使用 WebRTC Data Channels(数据通道)交换文本消息。与之前的演示类似,我们的应用将有一个登录视图和一个聊天视图。

原始教程在此处包含了一张显示应用的图片:一个登录屏幕和一个聊天屏幕。聊天屏幕有一个区域用于显示消息,一个输入框用于输入消息,“发送”按钮,一个输入框用于指定要呼叫的用户,“呼叫”按钮和“挂断”按钮。我们将在 HTML 部分描述此 UI。

视图将通过切换 div 的可见性来管理,交互通过 JavaScript 事件监听器处理。

对于这个文本聊天演示,我们可以重复使用为语音/视频演示构建的相同的 Node.js WebSocket 信令服务器。它的作用保持不变:促进 SDP offer、answer 和 ICE candidate 的交换,以便对等端能够建立直接的 RTCPeerConnection。然后 RTCDataChannel 将在此连接上创建。

请参考“WebRTC - 现代语音演示”章节中的 server.js 代码。在测试客户端应用之前,请确保它正在运行(node server.js)。此文本演示不需要对该服务器进行任何更改。

与之前一样,测试涉及在两个浏览器标签页中打开应用并启动聊天。

创建一个 index.html 文件。我们将通过 CDN 使用 Bootstrap 进行基本样式设置。

index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebRTC Text Demo</title>
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
<style>
body { background: #f0f0f0; padding: 20px; }
.container { max-width: 600px; margin: auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }
#loginPage, #callPage { margin-top: 20px; }
#chatArea { height: 200px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; margin-bottom: 10px; background-color: #f9f9f9; }
.message { margin-bottom: 5px; }
.message .sender { font-weight: bold; }
</style>
</head>
<body>
<div class="container">
<div id="loginPage">
<h2 class="text-center">WebRTC Text Chat</h2>
<p class="text-center">Please sign in</p>
<div class="form-group">
<input type="text" id="usernameInput" class="form-control" placeholder="Enter your username" autofocus required>
</div>
<button id="loginBtn" class="btn btn-primary btn-block">Sign In</button>
</div>
<div id="callPage" style="display: none;">
<h3 class="text-center">Chatting as <span id="currentUser"></span></h3>
<div id="chatArea"></div>
<div class="form-group">
<input type="text" id="callToUsernameInput" class="form-control" placeholder="Username to connect with">
</div>
<button id="callBtn" class="btn btn-success">Connect</button>
<button id="hangUpBtn" class="btn btn-danger">Disconnect</button>
<hr>
<div class="input-group mb-3">
<input type="text" id="msgInput" class="form-control" placeholder="Type your message...">
<div class="input-group-append">
<button id="sendMsgBtn" class="btn btn-info">Send</button>
</div>
</div>
</div>
</div>
<script src="client.js"></script>
</body>
</html>

此 HTML 结构与语音演示类似,但没有 <audio> 元素,而是一个带有 id="chatArea" 的 div 用于显示消息,以及一个带有“发送”按钮(sendMsgBtn)的输入框(msgInput)用于发送消息。

现在,创建 client.js:

// client.js
let name;
let connectedUser;
let peerConnection;
let dataChannel;
const wsURL = 'ws://localhost:9090';
const connection = new WebSocket(wsURL);
// DOM Elements
const loginPage = document.querySelector('#loginPage');
const usernameInput = document.querySelector('#usernameInput');
const loginBtn = document.querySelector('#loginBtn');
const callPage = document.querySelector('#callPage');
const currentUserDisplay = document.querySelector('#currentUser');
const callToUsernameInput = document.querySelector('#callToUsernameInput');
const callBtn = document.querySelector('#callBtn');
const hangUpBtn = document.querySelector('#hangUpBtn');
const chatArea = document.querySelector('#chatArea');
const msgInput = document.querySelector('#msgInput');
const sendMsgBtn = document.querySelector('#sendMsgBtn');
// ICE servers configuration
const configuration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
};
connection.onopen = () => {
console.log('Connected to signaling server');
};
connection.onmessage = async (msg) => {
console.log('Got message', msg.data);
const data = JSON.parse(msg.data);
switch (data.type) {
case 'login':
handleLogin(data.success, data.message);
break;
case 'offer':
await handleOffer(data.offer, data.name);
break;
case 'answer':
await handleAnswer(data.answer);
break;
case 'candidate':
await handleCandidate(data.candidate);
break;
case 'leave':
handleLeave();
break;
case 'error':
alert(`Error: ${data.message}`);
break;
default:
break;
}
};
connection.onerror = (err) => {
console.error('WebSocket error:', err);
alert('Error connecting to signaling server.');
};
function send(message) {
if (connectedUser) {
message.name = connectedUser; // Target user for signaling messages
}
connection.send(JSON.stringify(message));
}
// UI Event Listeners
loginBtn.addEventListener('click', () => {
name = usernameInput.value;
if (name.length > 0) {
send({ type: 'login', name: name });
} else {
alert('Please enter a username.');
}
});
function handleLogin(success, message) {
if (success === false) {
alert(`Login failed: ${message || 'Try a different username.'}`);
} else {
loginPage.style.display = 'none';
callPage.style.display = 'block';
currentUserDisplay.textContent = name;
// Initialize RTCPeerConnection after login, ready for calls
initializePeerConnection();
}
}
function initializePeerConnection() {
peerConnection = new RTCPeerConnection(configuration);
// Handle ICE candidates
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
send({ type: 'candidate', candidate: event.candidate });
}
};
// This event is triggered on the receiving side when the sender calls createDataChannel
peerConnection.ondatachannel = (event) => {
console.log('Data channel received');
dataChannel = event.channel;
setupDataChannelEvents(dataChannel);
};
// Optional: Log connection state changes
peerConnection.oniceconnectionstatechange = () => {
console.log(`ICE connection state: ${peerConnection.iceConnectionState}`);
if (peerConnection.iceConnectionState === 'connected') {
console.log('Peers connected!');
} else if (['failed', 'disconnected', 'closed'].includes(peerConnection.iceConnectionState)) {
console.log('Peer connection failed or closed.');
// Could trigger handleLeave() or alert user
}
};
}
function setupDataChannelEvents(channel) {
channel.onopen = () => {
console.log('Data channel is open');
appendMessage('System', 'Chat connected!');
msgInput.disabled = false;
sendMsgBtn.disabled = false;
};
channel.onmessage = (event) => {
console.log(`Message received: ${event.data}`);
appendMessage(connectedUser, event.data);
};
channel.onclose = () => {
console.log('Data channel is closed');
appendMessage('System', 'Chat disconnected.');
msgInput.disabled = true;
sendMsgBtn.disabled = true;
};
channel.onerror = (error) => {
console.error('Data channel error:', error);
};
}
callBtn.addEventListener('click', async () => {
const callToUsername = callToUsernameInput.value;
if (callToUsername.length === 0) {
alert('Please enter a username to connect with.');
return;
}
if (callToUsername === name) {
alert('You cannot connect to yourself.');
return;
}
connectedUser = callToUsername;
console.log(`Initiating connection to ${connectedUser}`);
if (!peerConnection) initializePeerConnection();
// Create data channel - this is done by the initiator
dataChannel = peerConnection.createDataChannel('chatChannel', { reliable: true });
console.log('Data channel created by initiator');
setupDataChannelEvents(dataChannel);
try {
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
send({ type: 'offer', offer: offer });
} catch (error) {
console.error('Error creating offer:', error);
alert('Error creating connection offer: ' + error.message);
}
});
async function handleOffer(offer, fromName) {
if (!peerConnection) initializePeerConnection();
connectedUser = fromName;
console.log(`Received offer from ${connectedUser}`);
try {
await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
send({ type: 'answer', answer: answer });
} catch (error) {
console.error('Error handling offer:', error);
}
}
async function handleAnswer(answer) {
console.log('Received answer');
try {
await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
} catch (error) {
console.error('Error handling answer:', error);
}
}
async function handleCandidate(candidate) {
try {
if (candidate) {
await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
}
} catch (error) {
console.error('Error adding ICE candidate:', error);
}
}
hangUpBtn.addEventListener('click', () => {
send({ type: 'leave' });
handleLeave();
});
function handleLeave() {
console.log('Connection ended or other user left.');
appendMessage('System', 'Disconnected.');
connectedUser = null;
if (dataChannel) {
dataChannel.close();
dataChannel = null;
}
if (peerConnection) {
peerConnection.close();
peerConnection.onicecandidate = null;
peerConnection.ondatachannel = null;
peerConnection.oniceconnectionstatechange = null;
peerConnection = null;
}
callToUsernameInput.value = '';
msgInput.disabled = true;
sendMsgBtn.disabled = true;
// Re-initialize for next potential connection
initializePeerConnection();
}
sendMsgBtn.addEventListener('click', () => {
const msg = msgInput.value;
if (msg.length > 0 && dataChannel && dataChannel.readyState === 'open') {
appendMessage(name, msg); // Display my own message
dataChannel.send(msg);
msgInput.value = '';
} else if (!dataChannel || dataChannel.readyState !== 'open'){
appendMessage('System', 'Message not sent. No active connection.');
}
});
msgInput.addEventListener('keypress', (event) => {
if (event.key === 'Enter') {
sendMsgBtn.click();
}
});
function appendMessage(sender, messageText) {
const messageElement = document.createElement('div');
messageElement.classList.add('message');
const senderElement = document.createElement('span');
senderElement.classList.add('sender');
senderElement.textContent = `${sender}: `;
const textElement = document.createElement('span');
textElement.textContent = messageText;
messageElement.appendChild(senderElement);
messageElement.appendChild(textElement);
chatArea.appendChild(messageElement);
chatArea.scrollTop = chatArea.scrollHeight; // Auto-scroll to latest message
}
// Disable message input initially
msgInput.disabled = true;
sendMsgBtn.disabled = true;
// Clean up on window close
window.addEventListener('beforeunload', () => {
if (name) {
send({ type: 'leave' });
}
if (dataChannel) dataChannel.close();
if (peerConnection) peerConnection.close();
if (connection) connection.close();
});

client.js 中用于文本聊天的关键更改和功能:

  • 专注于 RTCDataChannel:主要目标是建立并使用 RTCDataChannel。
  • initializePeerConnection():设置 RTCPeerConnection。关键是它定义了 peerConnection.ondatachannel。当发起方调用 createDataChannel() 时,接收方会触发此事件处理程序。
  • callBtn(连接按钮)逻辑:当发起方点击“连接”时:
  • * 它调用 `peerConnection.createDataChannel('chatChannel', { reliable: true })`。我们为聊天消息使用可靠(reliable)的数据通道。
  • * 然后继续执行 `RTCPeerConnection` 本身的标准 offer/answer 交换。
  • setupDataChannelEvents(channel):此函数用于本地创建的数据通道(发起方)和远程接收的数据通道(接收方)。它为数据通道设置 onopen、onmessage、onclose 和 onerror 处理程序。
  • * `onopen`:数据通道准备就绪时触发。启用消息输入。
  • * `onmessage`:收到消息时触发。将其添加到 `chatArea`。
  • * `onclose`:通道关闭时触发。禁用消息输入。
  • appendMessage(sender, messageText):一个辅助函数,用于在 chatArea 中显示消息。
  • 现代 JavaScript:像之前的演示一样,使用 async/await、let/const。
  • 错误处理和日志记录:包含基本的控制台日志,以便于调试。原始教程展示了一张控制台输出的图片,确认已连接到信令服务器;现在这部分由 console.log 覆盖。

在 RTCPeerConnection 构造函数中已弃用的 optional: [{RtpDataChannels: true}] 约束(原始教程中提到)不再需要,因为数据通道已成为 WebRTC 的标准部分。

运行演示:

  1. 确保您的信令服务器(server.js)正在运行。
  2. 将 index.html 和 client.js 保存在一个新文件夹中。
  3. 在两个浏览器标签页中打开 index.html。
  4. 使用不同的用户名登录。
  5. 在一个标签页中,输入另一个标签页的用户名并点击“连接”。
  6. 连接成功后,您应该能够交换文本消息。

原始教程包含了一张图片,展示了最终应用有两个用户登录并正在交换消息。更新后的代码提供了此功能。