开源项目从来不是天生完备的,很多经典工具的第一版代码都朴素得惊人。前阵子我重新翻一个早期 WebRTC 信令服务的实现,发现核心逻辑只有几百行,连数据库都没有,却把浏览器之间“如何找到对方、如何协商参数、如何交换媒体”这件事讲得明明白白。这正好符合 WebRTC 工程落地的一个朴素事实:媒体传输的内核由浏览器搞定,真正需要开发者操心的,是信令的“约定”和“状态流转”。

这篇文章我准备从一个本地测试服务器的搭建过程出发,聊聊 WebRTC 应用里最容易被忽略、又最能体现工程水平的部分——信令服务的设计与实现。我会用 Node.js 和 Socket.IO 搭一个最小可用的信令服务,配合一个完整的前端页面,实现局域网内两台设备之间的音视频通话,最后再整理一份常用命令和自测清单。整个过程既适合刚接触 WebRTC 的开发者照着敲一遍,也适合已经写过通话 demo、但没系统梳理过信令状态机的同学查漏补缺。

1. WebRTC 工程实现里的一个关键误区

很多人第一次接触 WebRTC,都是从 MDN 的教程或者 Google 的官方示例开始的。这些资料通常会把 getUserMedia 、 RTCPeerConnection 、 addIceCandidate 这几个 API 串成一个“点对点”的 demo。但这里一直有个容易被忽略的前提: 两个浏览器要建立 P2P 连接,首先要能互相“递纸条”。

这个“递纸条”的过程,就是信令(Signaling)。它需要解决三件事:

  1. 双方如何发现彼此(会话标识和房间机制)。
  2. 双方如何交换会话描述(SDP 的 offer/answer)。
  3. 双方如何交换网络候选信息(ICE Candidate)。

WebRTC 标准里,媒体数据的传输走的是 SRTP 和 ICE 通道,但 信令本身并没有被标准化 。你可以用 WebSocket、HTTP 轮询、甚至飞鸽传书来实现,只要能把 A 的消息原样送到 B。这种“自由”带来的直接后果就是:很多人把信令想得太简单,随便拿 WebSocket 广播一下就开干,结果一到 NAT 穿透、多房间隔离、异常重连这些场景就崩。

真正做工程的人会告诉你一句话: WebRTC 的难点不在媒体通道,而在信令的状态管理。 这就是为什么我们需要一个结构清晰的信令服务,而不是在页面里堆一堆 socket.on 。

本次搭建的本地测试服务器,核心目标有三个:

  • 提供房间(Room)的创建和加入能力,让两个客户端在语义上“找到对方”。
  • 提供信令消息的转发,包括 offer、answer、ICE candidate、hangup 等事件。
  • 保证本机调试和局域网测试的稳定性,方便你观察整个协商流程。

先说结论:这个服务用 Node.js + Socket.IO 来实现,代码量不大,但模式足够典型,可直接用于理解真实项目中的信令设计。

2. 搭建前的准备工作与整体设计

2.1 合理的工具选型:为什么用 Node.js + Socket.IO

WebRTC 信令服务本质上是一个“低延迟消息中转站”。在工程选型上,我见过有人用纯 WebSocket 手写,有人用 MQTT,有人用 Socket.IO,还有人用 Go 的 gorilla/websocket。对于本地测试和中小型应用,Node.js + Socket.IO 有几个很实际的优势:

  • 原生 JSON 消息 :信令内容本身就是 JSON,JavaScript 生态处理起来零成本。
  • 内置房间机制 :Socket.IO 的 join/leave 语义天然适合会议室模型,不需要自己去维护连接表。
  • 自动重连和心跳 :本地调试时经常刷新页面,Socket.IO 的断线重连能减少很多“莫名其妙失联”的问题。
  • 兼容性好 :浏览器端直接引客户端库即可,不用额外处理二进制帧和分片。

如果是高并发生产环境,你可能会考虑换成 Go 或者 Rust 实现,同时对消息做持久化和鉴权。但本地的核心目标是“快速跑通、看清逻辑”,Node.js 足够。

2.2 目录结构与职责划分

我习惯把信令服务和静态页面放在同一个仓库里,方便一次性启动:

webrtc-lab/
├── package.json
├── server.js
└── public/
    ├── index.html
    ├── client.js
    └── style.css
  • server.js :信令服务入口,负责 Socket.IO 连接、房间管理和消息转发。
  • public/ :前端页面,包含 UI 和 WebRTC 客户端逻辑。

这种划分虽然简单,但职责清晰:服务端只管“连接和转发”,前端管“媒体采集和 PeerConnection 状态”。

2.3 依赖安装与基础服务骨架

初始化项目并安装依赖:

mkdir webrtc-lab
cd webrtc-lab
npm init -y
npm install express socket.io

服务端代码骨架如下:

// server.js
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');

const app = express();
const server = http.createServer(app);
const io = new Server(server, {
  cors: {
    origin: "*",
    methods: ["GET", "POST"]
  }
});

app.use(express.static('public'));

const rooms = new Map(); // roomId -> Set<socket.id>

io.on('connection', (socket) => {
  console.log(`[连接] ${socket.id}`);
  // 后续处理 join、offer、answer、candidate 等事件
});

server.listen(3000, () => {
  console.log('信令服务器已启动: http://localhost:3000');
});

先解释一下 rooms 这个 Map 的作用。每个房间对应一个 Set,里面存的是 socket.id。这比单纯用 socket.join(roomId) 多了一层自己的记录,原因是 Socket.IO 的 join 只解决“广播到谁”的问题,但我们要知道“房间里现在有几个人、谁是谁”,这层记录方便后续做人数控制和角色判断。

2.4 前端页面的基础布局

页面不需要花哨,但需要能直观展示当前状态。我的 HTML 里包含以下核心元素:

  • 本地视频区( #localVideo )
  • 远程视频区( #remoteVideo )
  • 房间号输入框( #roomId )
  • 创建/加入房间按钮
  • 开始通话按钮和挂断按钮
  • 状态信息展示区( #status )
<div id="container">
  <div id="videoContainer">
    <video id="localVideo" autoplay muted playsinline></video>
    <video id="remoteVideo" autoplay playsinline></video>
  </div>
  <div id="controls">
    <input id="roomId" placeholder="请输入房间号" />
    <button id="joinBtn">加入/创建房间</button>
    <button id="callBtn" disabled>开始通话</button>
    <button id="hangupBtn" disabled>挂断</button>
  </div>
  <p id="status"></p>
</div>

为什么要用 muted 属性放在本地视频上?这是 WebRTC 调试的一个极易踩的坑:如果不静音本地预览,麦克风的声音会直接回放到扬声器,形成刺耳的啸叫和回声。 playsinline 是为了兼容 iOS Safari,否则视频会被强制全屏播放。

3. 信令服务的核心实现:房间机制与消息转发

3.1 房间的创建、加入和人数控制

先实现最基本的房间逻辑:

io.on('connection', (socket) => {
  // 创建或加入房间
  socket.on('join', (roomId, callback) => {
    if (!roomId || typeof roomId !== 'string') {
      callback && callback({ code: 1, msg: '房间号不合法' });
      return;
    }

    // 如果之前已经加入过其他房间,先退出
    socket.rooms.forEach((r) => {
      if (r !== socket.id) socket.leave(r);
    });

    const room = rooms.get(roomId) || new Set();
    
    // 简单的人数控制:每个房间最多两人
    if (room.size >= 2) {
      callback && callback({ code: 2, msg: '房间已满' });
      return;
    }

    // 记录并加入
    room.add(socket.id);
    rooms.set(roomId, room);
    socket.join(roomId);
    socket.data.roomId = roomId;

    console.log(`[加入房间] ${socket.id} -> ${roomId}`);
    
    // 通知房间内其他成员,有新用户加入了
    socket.to(roomId).emit('peer-joined', {
      peerId: socket.id
    });

    callback && callback({ code: 0, msg: 'ok', roomId });
  });

  // 退出房间
  socket.on('leave-room', () => {
    leaveRoom(socket);
  });

  socket.on('disconnect', () => {
    leaveRoom(socket);
    console.log(`[断开] ${socket.id}`);
  });
});

function leaveRoom(socket) {
  const roomId = socket.data.roomId;
  if (!roomId) return;

  const room = rooms.get(roomId);
  if (room) {
    room.delete(socket.id);
    if (room.size === 0) {
      rooms.delete(roomId);
    } else {
      socket.to(roomId).emit('peer-left', {
        peerId: socket.id
      });
    }
  }

  socket.leave(roomId);
  socket.data.roomId = null;
}

这里有几个工程细节值得细说:

第一, “房间已满”的判断要放在 join 之前 。因为 Socket.IO 的 socket.join(roomId) 是幂等的,如果房间已经存在两人,第三个人仍然可以 join 进去,但这时 WebRTC 协商会变得混乱,谁和谁通话说不清楚。

第二, 每人在同一时刻只允许在一个房间里 。我在代码里先遍历 socket.rooms ,把除了默认的 socket 自身房间之外的其他房间都退出。这避免了客户端多次调用 join 导致状态错乱。

第三, 用 socket.data 保存用户在服务端的会话状态 。这个变量存在服务器内存中,和 socket 生命周期绑定,非常适合保存 roomId 这类元数据,而不是频繁用 socket.handshake.query 去传。

3.2 转发 SDP 与 ICE 候选

接下来是信令服务的“主菜”——转发消息。这里要理解一个核心设计原则: 服务端在信令交互中扮演的是“邮局”角色,不应该尝试理解和修改消息内容。

// 转发 offer(主叫方 -> 被叫方)
socket.on('offer', (data) => {
  const roomId = socket.data.roomId;
  const target = data.target;
  
  console.log(`[转发 offer] ${socket.id} -> ${target}`);
  socket.to(target).emit('offer', {
    from: socket.id,
    sdp: data.sdp
  });
});

// 转发 answer(被叫方 -> 主叫方)
socket.on('answer', (data) => {
  const target = data.target;
  
  console.log(`[转发 answer] ${socket.id} -> ${target}`);
  socket.to(target).emit('answer', {
    from: socket.id,
    sdp: data.sdp
  });
});

// 转发 ICE candidate
socket.on('ice-candidate', (data) => {
  const target = data.target;
  
  console.log(`[转发 ICE] ${socket.id} -> ${target}`);
  socket.to(target).emit('ice-candidate', {
    from: socket.id,
    candidate: data.candidate
  });
});

// 挂断
socket.on('hangup', (data) => {
  const target = data.target;
  socket.to(target).emit('hangup', {
    from: socket.id
  });
});

这个设计看起来简单到“没有设计”,但恰恰是大多数 WebRTC 信令的标准形态。有个容易被忽略的点是: 每条消息都带 from 字段。

为什么?因为 WebRTC 的 PeerConnection 是一对一的,终端在收到消息后需要知道消息来自谁,才能路由到对应的 RTCPeerConnection 实例。虽然两端通话场景下只有一个远端,但你的代码仍然需要保持这种“显式路由”习惯,否则将来扩展多人会议时要重构很多。

3.3 信令事件命名约定

信令服务的可维护性,很大程度取决于事件名的语义清楚。我推荐的事件命名规范如下:

事件名 方向 语义
join 客户端 -> 服务端 请求加入/创建房间
peer-joined 服务端 -> 客户端 房间内新成员加入通知
offer 客户端 -> 服务端 -> 客户端 发送 SDP offer
answer 客户端 -> 服务端 -> 客户端 发送 SDP answer
ice-candidate 客户端 -> 服务端 -> 客户端 交换 ICE 候选
hangup 客户端 -> 服务端 -> 客户端 挂断通知
peer-left 服务端 -> 客户端 房间内成员离开

这套命名最大的好处是: 动词 + 内容 的模式让代码阅读者几乎不需要注释就能判断消息意图。我见过有些项目用 message 、 msg1 、 msg2 这类模糊名字,调试时非常痛苦。

4. 前端核心实现:从媒体采集到 P2P 连接

4.1 获取本地媒体流

前端代码的核心是要把这几个 WebRTC API 串起来:

  1. navigator.mediaDevices.getUserMedia
  2. new RTCPeerConnection(configuration)
  3. pc.addTrack / pc.ontrack
  4. pc.createOffer / pc.setLocalDescription
  5. pc.onicecandidate

先看媒体采集部分:

async function startLocalMedia() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      video: {
        width: { ideal: 1280 },
        height: { ideal: 720 }
      },
      audio: true
    });
    
    localStream = stream;
    document.getElementById('localVideo').srcObject = stream;
    document.getElementById('callBtn').disabled = false;
    updateStatus('本地媒体已就绪');
    
    return stream;
  } catch (err) {
    console.error('获取媒体失败:', err);
    updateStatus('无法访问摄像头/麦克风,请检查权限');
    throw err;
  }
}

这里要注意 video 的约束条件不是“必须精确 1280x720”,而是“理想情况下尽量接近”。浏览器会根据摄像头实际能力做协商,真实项目中如果你传的是 exact: 1280 ,遇到不支持的设备会直接报 OverconstrainedError 。

4.2 创建 RTCPeerConnection 的完整配置

这是最容易踩坑的地方。很多人直接写 new RTCPeerConnection() ,不带任何配置。在 localhost 环境下这没问题,因为本地回环不走网络。但一旦跨设备联调,没有 ICE 服务器就会导致候选收集不完整。这里分两种情况:

场景一:纯本地调试(相同机器两个标签页)

const pc = new RTCPeerConnection();

场景二:局域网测试(两台电脑/手机)

const pc = new RTCPeerConnection({
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' }
  ]
});

在局域网内,其实只需要 STUN 服务器即可。为什么?STUN 的作用是让设备“看见自己”在 NAT 后的公网映射。在局域网测试中,两台设备处在同一网段,通过 mDNS 或主机名就能发现彼此,STUN 的主要作用是兜底某些复杂局域网环境。如果将来要跨公网测试,才需要配置 TURN 服务器(coturn 自建或云厂商的)。

这里我建议的调试路径是:

  1. 先不用任何 ICE 服务器,用 localhost 测试,确保基本协商逻辑正确。
  2. 再改为局域网 IP + STUN,测试跨设备连通性。
  3. 最后上生产环境配置 TURN,应对对称型 NAT。

不要一上来就配一堆 TURN 服务器,那样反而会掩盖很多候选收集的逻辑问题。

4.3 呼叫发起方的完整流程

主叫方在“开始通话”按钮点击后,执行一系列标准步骤:

async function makeCall() {
  const roomId = document.getElementById('roomId').value.trim();
  if (!roomId || !peerId) {
    updateStatus('请先加入房间并等待对方上线');
    return;
  }
  
  pc = createPeerConnection();
  localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
  
  // 等 ICE 候选收集
  pc.onicecandidate = (event) => {
    if (event.candidate) {
      socket.emit('ice-candidate', {
        target: peerId,
        candidate: event.candidate
      });
    }
  };
  
  pc.ontrack = (event) => {
    // 注意:这里要用 event.streams[0],才是对方的完整媒体流
    document.getElementById('remoteVideo').srcObject = event.streams[0];
  };
  
  // 创建 Offer
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  
  console.log('[主叫] 发送 offer');
  socket.emit('offer', {
    target: peerId,
    sdp: pc.localDescription
  });
  
  updateStatus('已发送呼叫请求,等待对方应答...');
}

注意 pc.localDescription 的传递时机。 createOffer 之后、 setLocalDescription 之后, localDescription 里才包含完整的 SDP 内容。 一定要等 setLocalDescription 完成后才能发送 offer ,否则你发出去的是空 SDP。

某些较早的资料会让你直接 socket.emit('offer', { sdp: offer }) ,这时如果你还没调用 setLocalDescription ,ICE 候选可能在 SDP 到达对方之前就开始收集了,这虽然不致命,但会导致时序问题难以排查。规范路径永远是:

createOffer -> setLocalDescription -> 发送 offer

4.4 被叫方接收 Offer 的处理流程

被叫方收到 offer 事件后,需要做的是:

socket.on('offer', async (data) => {
  peerId = data.from;
  
  // 创建对等连接
  pc = createPeerConnection();
  localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
  
  pc.onicecandidate = (event) => {
    if (event.candidate) {
      socket.emit('ice-candidate', {
        target: peerId,
        candidate: event.candidate
      });
    }
  };
  
  pc.ontrack = (event) => {
    document.getElementById('remoteVideo').srcObject = event.streams[0];
  };
  
  // 关键:先 setRemoteDescription,再 createAnswer
  await pc.setRemoteDescription(data.sdp);
  
  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);
  
  console.log('[被叫] 发送 answer');
  socket.emit('answer', {
    target: peerId,
    sdp: pc.localDescription
  });
  
  updateStatus('已接听呼叫');
});

这里有一个严格顺序要求: setRemoteDescription 必须在 createAnswer 之前 。因为 createAnswer 需要基于收到的远端描述来生成匹配的本地应答。如果反了,浏览器会抛 InvalidStateError 。

4.5 ICE 候选的接收与添加

ICE 候选的处理相对简单,核心注意点是“候选可能先于 offer/answer 到达”。比如主叫方在发送 offer 后,ICE 候选可能立刻就开始传输,而被叫方此时可能还没 setRemoteDescription 。

这时候如果直接调用 pc.addIceCandidate ,会遇到 InvalidStateError ,因为“远端描述”还没有设置。对这个问题有两种处理方式:

方式一(推荐):在收到 offer 后先设置远端描述,然后在创建 answer 之前把缓存的候选批量添加。

let pendingCandidates = [];

socket.on('ice-candidate', async (data) => {
  if (data.from !== peerId) return;
  
  if (pc && pc.remoteDescription) {
    // 正常的直接添加
    try {
      await pc.addIceCandidate(data.candidate);
      console.log('[ICE] 已添加候选');
    } catch (err) {
      console.warn('[ICE] 添加候选失败', err);
    }
  } else {
    // 远端描述还没设置,先缓存
    pendingCandidates.push(data.candidate);
  }
});

在 setRemoteDescription 之后,将缓存清空并添加:

await pc.setRemoteDescription(data.sdp);

// 清空缓存
for (const candidate of pendingCandidates) {
  await pc.addIceCandidate(candidate).catch(console.warn);
}
pendingCandidates = [];

这个“候选先于描述到达”的现象非常常见,尤其是多候选场景下,代码里不加保护十有八九会报 Error: ICE... 或者 InvalidStateError 。这是 WebRTC 开发初期最典型的问题之一。

4.6 通话建立后的状态展示

我建议在页面上用一个状态字段来展示当前呼叫状态,至少包括:

  • idle :空闲,可开始呼叫
  • calling :主叫中,等待应答
  • ringing :被叫中,等待接听
  • connected :通话中
  • disconnected :通话已结束

这些状态的变化和 UI 按钮的禁用/启用是同步的,也是很多新手忽略的“隐形逻辑”。你可以用一个简单的变量保存当前状态,在每次状态变化时刷新按钮:

function updateStatus(text) {
  document.getElementById('status').textContent = text;
}

function handleStateChange(state) {
  const callBtn = document.getElementById('callBtn');
  const hangupBtn = document.getElementById('hangupBtn');
  
  if (state === 'connected') {
    callBtn.disabled = true;
    hangupBtn.disabled = false;
  } else if (state === 'idle') {
    callBtn.disabled = false;
    hangupBtn.disabled = true;
  }
}

状态机是 WebRTC 通话的“隐藏骨架”。我调试时见过太多 bug 是因为状态没切换导致按钮乱点、事件重复注册、连接对象重复创建。 先把状态流转画清楚,再写代码,效率会翻倍。

5. 局域网测试的部署要点与 HTTPS 问题

5.1 让其他设备访问本地服务器

localhost 测试通过后,下一步就是局域网内的真实设备互测。这时需要让手机或另一台电脑访问你的信令服务。

先查本机 IP:

ipconfig  # Windows
ifconfig  # Linux / macOS

假设本机 IP 是 192.168.1.100 ,那么你需要让服务监听在 0.0.0.0 而不是默认的 127.0.0.1 。修改启动命令:

node server.js --host 0.0.0.0 --port 3000

或者直接在代码里:

server.listen(3000, '0.0.0.0', () => {
  console.log('信令服务器已启动: http://0.0.0.0:3000');
});

然后手机访问 http://192.168.1.100:3000 。这一步通常会遇到一个“新手墙”: getUserMedia 在非安全上下文下会被浏览器拒绝。

5.2 本地 HTTPS 证书的快速方案

Chrome 从 47 版本开始, getUserMedia 等敏感 API 只在 secure context(HTTPS 或 localhost)下可用。也就是说,其他设备通过 http://192.168.1.100:3000 访问页面时,摄像头和麦克风将无法启动。

解决方案有两种:

方案一:在 Chrome 中手动开启 unsafely-treat-insecure-origin-as-secure (不推荐,仅调试)。

方案二:生成自签名证书,用 HTTPS 启动服务(推荐)。

生成证书用 mkcert 最省事:

# 安装 mkcert
# macOS: brew install mkcert
# Windows: choco install mkcert

mkcert -install
mkcert 192.168.1.100 localhost 127.0.0.1

会生成两个文件,比如 192.168.1.100+2.pem 和 192.168.1.100+2-key.pem 。然后修改服务端:

const https = require('https');
const fs = require('fs');

const options = {
  key: fs.readFileSync('密钥文件路径'),
  cert: fs.readFileSync('证书文件路径')
};

const server = https.createServer(options, app);

注意 Socket.IO 也要挂载到 HTTPS server 上,逻辑不变。手机访问 https://192.168.1.100:3000 ,首次预览证书警告,点击继续后即可正常使用。

为什么我强调“本地测试也要走 HTTPS”?因为 如果不尽早踩这个坑,等部署到公网环境时会发现代码在 localhost 能跑,上服务器就黑屏 。这个区别几乎全部来自浏览器安全策略。

5.3 STUN 配置与局域网 ICE 候选

在局域网测试时,有一个容易忽略的细节: 两端设备的 IP 可能是私有地址(如 192.168.x.x),这些地址无法在公网路由。 但 WebRTC 的 ICE 框架会优先尝试 host 候选(即本机网卡地址),所以同一局域网内通常能直接连通,不需要额外的 STUN。

不过我还是建议在代码里保留 STUN 配置。原因有二:

第一,某些公司/校园网会启用 AP 隔离,客户端之间无法直接访问,这时 host 候选会失败,需要靠 STUN 提供的 srflx 候选兜底(虽然多数局域网 AP 隔离也挡了 STUN)。

第二,从代码迁移角度看,保持 ICE 配置的完整形态,将来上公网不用改逻辑。

我的推荐配置:

const rtcConfig = {
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    { urls: 'stun:stun1.l.google.com:19302' }
  ],
  iceCandidatePoolSize: 10
};

iceCandidatePoolSize 是另一个经常被忽视的参数,它控制在 setLocalDescription 之前预先收集的候选数。设大一点有助于更快地完成连接,但资源占用略高,本地调试设 10 足够。

6. 常见问题与排查技巧实录

6.1 摄像头正常但听不到对方声音

这是最典型的 WebRTC 新手问题,我也踩过好几次。原因几乎都是: 远端音频轨没被正确播放或自动播放策略被浏览器拦截。

排查顺序如下:

  1. 确认 ontrack 回调确实触发了。
  2. 确认 event.streams[0] 里有 audio track。
  3. 确认 remoteVideo.srcObject 已经赋值。
  4. 确认 remoteVideo 没有设置 muted 。
  5. 确认页面里至少有一次真实用户交互(如点击按钮)后才调用 play() 。

这里有个很隐蔽的坑:有些浏览器要求 video.play() 必须由用户手势触发。如果你在 ontrack 里直接 remoteVideo.srcObject = stream ,但没调用 play() ,有些浏览器会因为你之前的点击事件链太长而拦截自动播放。解决办法是在用户点击“接听”或“开始通话”的处理器里,对 remoteVideo 执行一次 play() 。

6.2 连接已建立但视频黑屏

视频黑屏但状态显示 connected,通常不是信令问题,而是 媒体流没绑定到 video 元素 。

我最常遇到的情况是: ontrack 被触发了,但 event.streams 为空数组。这多见于旧的浏览器或某些 WebRTC 网关实现。正确的做法是使用 event.streams[0] 的同时,做一个兼容兜底:

pc.ontrack = (event) => {
  const stream = event.streams[0];
  if (stream) {
    remoteVideo.srcObject = stream;
  } else {
    // 极端兼容:单独构造 MediaStream
    const newStream = new MediaStream();
    event.track.getSettings();
    // 实际上 event.track 此时需要拉流...
  }
};

还有另一个常见原因: 本应显示远端视频的 video 元素,被本地视频的 srcObject 覆盖了。 检查你是否把一个流对象同时赋给了两个 video。如果不小心用了同一个 localStream ,那么两端看到的是各自的摄像头,而不是对方。

6.3 offer/answer 协商时报 InvalidStateError

这个报错在 WebRTC 里信息量很大,但几乎都指向同一个事实: 你调用某个 API 的时机,不符合当前 RTCPeerConnection 的状态机。

常见触发场景:

  • 在 setRemoteDescription 之前调用了 createAnswer 。
  • 在 setLocalDescription 之前发送了 offer。
  • 没有等待 ICE 收集完成就和对方交换候选(但这里通常不报错,只是连不上)。

最简单的排查方法是在每次关键调用前打印 pc.signalingState 和 pc.connectionState 。

console.log('[状态]', pc.signalingState, pc.connectionState);

理解 WebRTC 的状态机,是入门到进阶的分水岭。 signalingState 有以下几个值:

  • stable :没有进行中的协商。
  • have-local-offer :已设置本地 offer,等待远端 answer。
  • have-remote-offer :已收到远端 offer,等待本地 answer。
  • closed :连接已关闭。

只要把上述状态值打印出来对照,大多数“莫名其妙的报错”其实都逻辑非常清晰。

6.4 ICE 候选一直收集不完

我在本地调试时,遇到过 iceGatheringState 停在 gathering 状态不结束的情况。这大多数发生在 STUN 服务器无法访问时。浏览器会反复尝试,候选收集超时时间很长(通常 20 秒以上),导致两端迟迟无法建立连接。

解决办法:

  1. 用 curl 或在线工具确认 STUN 服务器是否可达:
curl stun:stun.l.google.com:19302  # 这个命令在多数系统上不可用
# 建议用 nc 验证 UDP 端口
nc -vuz stun.l.google.com 19302
  1. 或者直接看浏览器 chrome://webrtc-internals 的 ICE candidates 面板,确认候选类型。

如果 STUN 不可达,可以先用空 iceServers 跑通局域网,再做公网测试。

6.5 浏览器控制台报错:“Unable to create RTCPeerConnection”

这个报错通常不是代码问题,而是 浏览器不支持 WebRTC (极少数情况)或 浏览器安全策略阻止 了 API 暴露(常见于非 HTTPS 页面)。

检查方法:

if (!window.RTCPeerConnection) {
  alert('当前浏览器不支持 WebRTC');
}

用 HTTPS 或 localhost 重新加载页面后,问题基本消失。真遇到不支持的情况,建议换一个主流浏览器测试,不要在同一浏览器上耗时间。

7. 常用测试命令与调试工具速查

7.1 启动服务器常用命令整理

为了方便日常操作,我习惯在 package.json 里配好 scripts:

{
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js",
    "start:https": "node server.js --https"
  }
}

需要使用的核心命令汇总如下:

场景 命令 说明
启动 HTTP 信令服务 npm start 默认 localhost:3000
监听所有网卡 node server.js --host 0.0.0.0 供局域网设备访问
HTTPS 启动 node server.js --https 读取证书文件,走 TLS
检查端口占用 lsof -i:3000 / netstat -ano 确认服务是否被占用
局域网 IP 查询 ipconfig / ifconfig 确认设备访问地址

7.2 浏览器原生调试利器:WebRTC Internals

Chrome 内置的 chrome://webrtc-internals 是排查 WebRTC 问题的第一利器。打开后能看到:

  • 当前所有 PeerConnection 实例。
  • getUserMedia 返回的媒体流轨道详情。
  • ICE 候选的完整列表(host / srflx / relay)。
  • SDP 的原始文本。
  • RTP 包的收发统计和丢包率。

在本地调试时,我习惯把两个标签页都打开这个页面,然后对比它们的 ICE 候选和 SDP 时间线。一旦哪边少了一个候选,立刻能定位是哪一步出了问题。

7.3 快速自测清单

每次写完代码,按照下面的清单自测一遍,可以把很多低级问题扼杀在本地:

  1. 两个标签页能否通过 localhost 互通音频视频?
  2. 加入同一个房间后,状态是否都显示“对方已上线”?
  3. 主叫方发出 offer 后,被叫方是否马上触发 answer ?
  4. 两端是否都添加了对方的 ICE candidate ?
  5. 挂断后,再次发起呼叫,是否还能成功?(验证状态重置逻辑)
  6. 刷新页面后,房间里的残留状态是否被清理?
  7. 两台局域网设备能否通过 HTTPS 互通?
  8. 断开网络后,远端是否收到 peer-left 通知?

第 5 条和第 6 条是我个人比较偏爱验证的,因为很多项目 demo 只跑通“第一次通话”,但一刷新或者一挂断就崩,这在实际发布中是不可接受的。

8. 扩展思路:从本地测试走向生产级信令

本地测试服务器跑通后,你会自然遇到一个问题: 如果多人同时在一个房间怎么办?如果服务端重启,客户端的连接状态怎么办?

这些问题指向的答案是:生产级信令服务需要额外关注这三个点。

8.1 消息持久化与会话恢复

Socket.IO 默认是全内存的,服务端重启后所有房间记录消失。生产环境里,至少要把“房间与成员”的关系持久化到 Redis。另外,如果你是做 1v1 通话,可以考虑引入“呼叫状态机”,把 idle、calling、connected 等状态放在服务端维护,这样就能支持掉线重呼、超时未接听、忙线应答等更丰富的业务逻辑。

8.2 多房间与多人会议的应对

1v1 的信令逻辑是“转发”,多人的信令逻辑是“分发”和“合流”。多人会议里,每个加入者需要把自己生成的 offer 发给房间里所有其他人,每个人返回 answer 后,加入者再分发 ICE 候选。这个“扇形扩散”模式用 Socket.IO 的 to(roomId) 广播就很容易实现。但这只是“信令参与者”层面,真正决定多人会议效果的是服务端要不要做 SFU(选择性转发单元),比如 mediasoup / Janus,这是另一个大工程。

8.3 TURN 服务器的搭建时机

在 STUN 不够用、需要穿越对称型 NAT 时,就必须引入 TURN。自建 coturn 是最常见的方案:

coturn -n --lt-cred-mech --realm=example.com \
  --user=youruser:yourpassword \
  --cert=/path/to/cert.pem \
  --pkey=/path/to/privkey.pem

然后用 turn:yourdomain.com:3478?transport=udp 加上认证信息放入 iceServers 即可。

但我建议 不要把 TURN 纳入初版测试 。原因很简单:如果候选收集正常,局域网内 host 候选天然可通;你要是上来就配 TURN,反而会掩盖 host 候选的优先级问题。先掌握本地和局域网的调试节奏,再引入 TURN 会顺理成章得多。

8.4 关于并发与性能的简单预估

很多人一听到信令服务就觉得要很扛并发,其实在 WebRTC 场景里,信令服务的资源开销极其有限。媒体流经过的是 P2P 通道或 SFU,信令服务只承担“连接建立”和“状态同步”,每秒处理几千条 JSON 消息完全不是问题。用 Node.js 单线程架构跑几百个并发连接,做对象存储和转发,性能绰绰有余。

真正的瓶颈永远在媒体层的带宽和 CPU 编解码,不在信令层。这一点搞清楚了,你在设计信令服务时就不会被“扛并发”这种模糊焦虑带偏。


说点个人体会。每次搭这种本地测试环境,我都喜欢把它当成一次“缩小版生产架构演练”。虽然最后只跑通了两台设备的音视频通话,但这里面涉及的房间管理、消息转发、ICE 缓存、状态机设计,和真实产品的信令层几乎没有差别。WebRTC 的入门曲线就从这里展开:不是那些眼花缭乱的 API,而是“如何把简单的转发逻辑做严谨”。

如果你照着本文跑通了第一通视频通话,后面再看任何 WebRTC 项目的服务端代码,都会发现自己能轻松读懂它的房间模型和事件流。这算是这个实验最大的价值所在。

Logo

邀请您加入社区

更多推荐