8. 实现 P2P 文本聊天室:结合信令服务器、ICE 交换、数据通道,完成一个双人聊天 Demo

好,咱们终于走到这一步了。前面几章我们把信令、ICE、数据通道拆开讲了一遍,现在该把它们串起来了。这一章的目标很明确——做一个能跑起来的双人聊天 Demo。

说白了,就是两个人打开浏览器,互相发消息。没有服务器中转,消息直接点对点传输。我当年第一次跑通这个 Demo 的时候,还挺激动的。你想想看,数据真的没经过任何中间服务器,直接从我的浏览器飞到对方的浏览器里,这种感觉很奇妙。

整体架构:三个模块怎么配合

先理一下思路。一个完整的 P2P 聊天室,需要三个核心模块协同工作:

  • 信令服务器:负责交换 SDP 和 ICE 候选者。它不传聊天消息,只做“牵线搭桥”。
  • ICE 交换:让双方找到最优的网络路径。说白了就是“你俩怎么连上最快”。
  • 数据通道:连接建立后,真正的聊天数据走这里。

我画了一张图,帮你把这三者的关系理清楚:

P2P 聊天室架构图 信令服务器 用户 A(浏览器) 创建 Offer · 收集 ICE 用户 B(浏览器) 接收 Offer · 创建 Answer SDP / ICE 候选者 SDP / ICE 候选者 数据通道(P2P 直连) "你好!" → 数据通道 "收到!" ← 数据通道 信令路径 数据通道

嗯,这张图你看明白了吗?信令服务器在中间只做“介绍人”,一旦双方握手完成,数据通道就直连了。这时候哪怕信令服务器挂了,你们俩还能继续聊天。

信令服务器的实现

先写信令服务器。我用 Node.js + WebSocket 来实现,轻量又好用。我个人习惯用 ws 这个库,没那么多花里胡哨的东西。

// server.js
const WebSocket = require('ws');
const server = new WebSocket.Server({ port: 8080 });

const clients = new Map();

server.on('connection', (ws) => {
  const id = generateId();
  clients.set(id, ws);
  
  // 告诉客户端自己的 ID
  ws.send(JSON.stringify({ type: 'assign-id', id }));
  
  ws.on('message', (data) => {
    const msg = JSON.parse(data);
    
    switch(msg.type) {
      case 'offer':
      case 'answer':
      case 'ice-candidate':
        // 转发给目标用户
        const target = clients.get(msg.targetId);
        if (target) {
          target.send(JSON.stringify({
            type: msg.type,
            data: msg.data,
            fromId: id
          }));
        }
        break;
    }
  });
  
  ws.on('close', () => {
    clients.delete(id);
  });
});

function generateId() {
  return Math.random().toString(36).substr(2, 8);
}

这段代码很简单,就是维护一个客户端列表,收到消息就转发。注意我没有做房间管理,因为双人聊天不需要。如果你以后要做多人聊天室,可以加个房间概念。

小提示:生产环境别用 Math.random() 生成 ID,会有碰撞风险。用 uuid 或者 nanoid 更靠谱。

客户端的核心逻辑

客户端这边,我分三步走:

  1. 连接信令服务器,拿到自己的 ID
  2. 创建 RTCPeerConnection,设置 ICE 候选者处理
  3. 创建数据通道,绑定消息事件

先看连接信令的部分:

// 客户端代码片段
const ws = new WebSocket('ws://localhost:8080');
let localId = null;
let remoteId = null;
let pc = null;
let dataChannel = null;

ws.onmessage = async (event) => {
  const msg = JSON.parse(event.data);
  
  switch(msg.type) {
    case 'assign-id':
      localId = msg.id;
      console.log('我的 ID:', localId);
      break;
      
    case 'offer':
      // 收到对方的 Offer,创建 Answer
      remoteId = msg.fromId;
      await handleOffer(msg.data);
      break;
      
    case 'answer':
      await handleAnswer(msg.data);
      break;
      
    case 'ice-candidate':
      await pc.addIceCandidate(new RTCIceCandidate(msg.data));
      break;
  }
};

这里有个细节我踩过坑——ICE 候选者的处理时机。我曾经在 Offer 还没设置的时候就添加 ICE 候选者,结果浏览器直接报错。正确的做法是:先设置远程 SDP,再添加 ICE 候选者

创建 PeerConnection 和数据通道

接下来是核心的 RTCPeerConnection 创建:

function createPeerConnection() {
  const config = {
    iceServers: [
      { urls: 'stun:stun.l.google.com:19302' }
    ]
  };
  
  pc = new RTCPeerConnection(config);
  
  // ICE 候选者收集完成后,发送给信令服务器
  pc.onicecandidate = (event) => {
    if (event.candidate) {
      ws.send(JSON.stringify({
        type: 'ice-candidate',
        targetId: remoteId,
        data: event.candidate
      }));
    }
  };
  
  // 数据通道创建
  dataChannel = pc.createDataChannel('chat', {
    ordered: true
  });
  
  setupDataChannel();
  
  return pc;
}

注意 createDataChannel 的第二个参数。我一般设置 ordered: true,保证消息顺序不乱。如果你做文件传输,可以考虑 ordered: false 配合 maxRetransmits 来提升性能。

数据通道的事件绑定

数据通道的事件处理,我习惯封装成一个函数:

function setupDataChannel() {
  dataChannel.onopen = () => {
    console.log('数据通道已打开');
    document.getElementById('status').textContent = '已连接';
  };
  
  dataChannel.onclose = () => {
    console.log('数据通道已关闭');
    document.getElementById('status').textContent = '已断开';
  };
  
  dataChannel.onmessage = (event) => {
    const msg = event.data;
    appendMessage('对方', msg);
  };
}

function sendMessage(text) {
  if (dataChannel && dataChannel.readyState === 'open') {
    dataChannel.send(text);
    appendMessage('我', text);
  } else {
    alert('连接尚未建立,请稍后再试');
  }
}
注意:发送消息前一定要检查 readyState。我曾经在线上环境遇到过一个问题——用户快速点击发送按钮,数据通道还没完全打开,消息就丢了。加个状态检查就能避免。

完整的连接流程

把上面这些拼起来,完整的连接流程是这样的:

步骤 用户 A 信令服务器 用户 B
1 连接 WebSocket 分配 ID 连接 WebSocket
2 创建 Offer 转发 Offer 接收 Offer
3 设置本地 SDP 设置远程 SDP
4 转发 Answer 创建 Answer
5 设置远程 SDP 设置本地 SDP
6 交换 ICE 候选者 双向转发 交换 ICE 候选者
7 ✅ 数据通道建立,开始聊天

你看,其实就这几步。我刚开始学的时候总觉得 WebRTC 很复杂,拆开来看,每一步都很清晰。

启动聊天的入口

最后,用户怎么发起聊天呢?我设计了一个简单的流程:

  • 用户 A 输入用户 B 的 ID,点击“发起连接”
  • 用户 B 收到 Offer 后,自动创建 Answer
  • 双方 ICE 候选者交换完成后,数据通道自动打开

代码实现:

// 用户 A 发起连接
async function startConnection(targetId) {
  remoteId = targetId;
  await createPeerConnection();
  
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  
  ws.send(JSON.stringify({
    type: 'offer',
    targetId: remoteId,
    data: offer
  }));
}

// 用户 B 处理 Offer
async function handleOffer(offer) {
  await pc.setRemoteDescription(new RTCSessionDescription(offer));
  
  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);
  
  ws.send(JSON.stringify({
    type: 'answer',
    targetId: remoteId,
    data: answer
  }));
}
核心要点:整个流程的关键是“谁先发起谁创建 Offer”。发起方调用 createOffer,接收方调用 createAnswer。这个顺序不能乱,否则连接会失败。

好了,到这里一个完整的 P2P 聊天室就实现了。你可以在本地跑起来试试,打开两个浏览器窗口,互相发几条消息感受一下。我第一次跑通的时候,特意把 Wi-Fi 断掉,发现消息还能发——因为数据通道走的是局域网直连,根本不需要互联网。

这就是 P2P 的魅力。下一章我们会在这个基础上,加入更多实用功能。


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