WebRTC - 文本演示
WebRTC - 现代文本聊天演示
Section titled “WebRTC - 现代文本聊天演示”在本章中,我们将构建一个客户端应用,允许两台不同设备上的用户使用 WebRTC Data Channels(数据通道)交换文本消息。与之前的演示类似,我们的应用将有一个登录视图和一个聊天视图。
原始教程在此处包含了一张显示应用的图片:一个登录屏幕和一个聊天屏幕。聊天屏幕有一个区域用于显示消息,一个输入框用于输入消息,“发送”按钮,一个输入框用于指定要呼叫的用户,“呼叫”按钮和“挂断”按钮。我们将在 HTML 部分描述此 UI。
视图将通过切换 div 的可见性来管理,交互通过 JavaScript 事件监听器处理。
对于这个文本聊天演示,我们可以重复使用为语音/视频演示构建的相同的 Node.js WebSocket 信令服务器。它的作用保持不变:促进 SDP offer、answer 和 ICE candidate 的交换,以便对等端能够建立直接的 RTCPeerConnection。然后 RTCDataChannel 将在此连接上创建。
请参考“WebRTC - 现代语音演示”章节中的 server.js 代码。在测试客户端应用之前,请确保它正在运行(node server.js)。此文本演示不需要对该服务器进行任何更改。
客户端应用 (HTML & JavaScript)
Section titled “客户端应用 (HTML & JavaScript)”与之前一样,测试涉及在两个浏览器标签页中打开应用并启动聊天。
创建一个 index.html 文件。我们将通过 CDN 使用 Bootstrap 进行基本样式设置。
<!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.jslet name;let connectedUser;let peerConnection;let dataChannel;
const wsURL = 'ws://localhost:9090';const connection = new WebSocket(wsURL);
// DOM Elementsconst 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 configurationconst 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 ListenersloginBtn.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 initiallymsgInput.disabled = true;sendMsgBtn.disabled = true;
// Clean up on window closewindow.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 的标准部分。
运行演示:
- 确保您的信令服务器(
server.js)正在运行。 - 将
index.html和client.js保存在一个新文件夹中。 - 在两个浏览器标签页中打开
index.html。 - 使用不同的用户名登录。
- 在一个标签页中,输入另一个标签页的用户名并点击“连接”。
- 连接成功后,您应该能够交换文本消息。
原始教程包含了一张图片,展示了最终应用有两个用户登录并正在交换消息。更新后的代码提供了此功能。