一对一 WebRTC 视频聊天

“最小可运行”的一对一 WebRTC 视频聊天示例(前端+信令服务器),完全按照你给出的 8 条信令流程图实现。
技术栈:

  • 前端:原生 JavaScript + socket.io-client
  • 信令服务器:Node.js + socket.io + Express(80 端口同时提供静态文件和信令)
  • STUN/TURN:Google 公共 STUN 即可,生产环境请换成自己的 TURN

代码可直接复制到同一目录运行,局域网两台浏览器打开 http://localhost 即可互拨。


  1. 目录结构
    webrtc-1v1/
    ├─ server.js // 信令服务器
    ├─ public/
    │ ├─ index.html // 页面
    │ └─ client.js // 前端逻辑
    └─ package.json

  1. 安装依赖
npm init -y
npm i express socket.io

  1. 信令服务器 server.js
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');
const path = require('path');

const app = express();
const server = http.createServer(app);
const io = socketIo(server, { cors: { origin: '*' } });

// 房间 => Set<socketId>
const rooms = new Map();

app.use(express.static(path.join(__dirname, 'public')));

io.on('connection', socket => {
  console.log('connect', socket.id);

  /* 1. join 加入房间 */
  socket.on('join', room => {
    socket.join(room);
    socket.room = room;

    const others = rooms.get(room) || new Set();
    if (others.size >= 1) {
      // 2. resp-join 返回另一个人的 uid
      socket.emit('resp-join', [...others][0]);
    }
    others.add(socket.id);
    rooms.set(room, others);

    // 4. new-peer 通知房间里其他人
    socket.to(room).emit('new-peer', socket.id);
  });

  /* 3. leave 离开房间 */
  socket.on('leave', () => leaveRoom(socket));

  socket.on('disconnect', () => leaveRoom(socket));

  /* 6. offer 转发 */
  socket.on('offer', ({ target, sdp }) => {
    socket.to(target).emit('offer', { from: socket.id, sdp });
  });

  /* 7. answer 转发 */
  socket.on('answer', ({ target, sdp }) => {
    socket.to(target).emit('answer', { from: socket.id, sdp });
  });

  /* 8. candidate 转发 */
  socket.on('candidate', ({ target, candidate }) => {
    socket.to(target).emit('candidate', { from: socket.id, candidate });
  });

  function leaveRoom(sock) {
    if (!sock.room) return;
    const room = sock.room;
    const others = rooms.get(room);
    if (others) {
      others.delete(sock.id);
      if (others.size === 0) rooms.delete(room);
      else {
        // 5. peer-leave 通知剩余的人
        sock.to(room).emit('peer-leave', sock.id);
      }
    }
    sock.leave(room);
    delete sock.room;
  }
});

server.listen(80, () => console.log('HTTP+Socket.io on 80'));

  1. 前端 public/index.html
<!doctype html>
<html>
<head>
  <meta charset="utf-8"/>
  <title>WebRTC 1v1</title>
  <style>video{width:240px;height:180px;background:#000;margin:6px}</style>
</head>
<body>
  <h3>WebRTC 一对一</h3>
  <input id="room" placeholder="房间名"/>
  <button id="btnJoin">加入</button>
  <button id="btnLeave" disabled>离开</button>
  <br/>
  <video id="local" autoplay muted></video>
  <video id="remote" autoplay></video>

  <script src="/socket.io/socket.io.js"></script>
  <script src="client.js"></script>
</body>
</html>

  1. 前端 public/client.js
const localVideo = document.getElementById('local');
const remoteVideo = document.getElementById('remote');
const roomInput = document.getElementById('room');
const btnJoin = document.getElementById('btnJoin');
const btnLeave = document.getElementById('btnLeave');

let localStream;
let pc;                 // RTCPeerConnection
let socket;
let roomName;
let otherId = null;     // 对端 socket.id

btnJoin.onclick = async () => {
  roomName = roomInput.value.trim();
  if (!roomName) return;
  socket = io();

  /* 1. join */
  socket.emit('join', roomName);

  /* 2. resp-join:如果房间已有人,立即发起 offer */
  socket.on('resp-join', async uid => {
    otherId = uid;
    await createPeerConnection();
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    socket.emit('offer', { target: otherId, sdp: offer });
  });

  /* 4. new-peer:有人后进来,我作为“被叫” */
  socket.on('new-peer', async uid => {
    otherId = uid;
    await createPeerConnection();
  });

  /* 5. peer-leave:对方离开 */
  socket.on('peer-leave', () => {
    closeCall();
  });

  /* 6. offer */
  socket.on('offer', async ({ from, sdp }) => {
    if (!pc) await createPeerConnection();
    await pc.setRemoteDescription(sdp);
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);
    socket.emit('answer', { target: from, sdp: answer });
  });

  /* 7. answer */
  socket.on('answer', async ({ from, sdp }) => {
    await pc.setRemoteDescription(sdp);
  });

  /* 8. candidate */
  socket.on('candidate', async ({ from, candidate }) => {
    await pc.addIceCandidate(candidate);
  });

  socket.on('connect', async () => {
    localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
    localVideo.srcObject = localStream;
  });

  btnJoin.disabled = true;
  btnLeave.disabled = false;
};

btnLeave.onclick = () => {
  if (socket) {
    socket.emit('leave');
    socket.close();
  }
  closeCall();
  btnJoin.disabled = false;
  btnLeave.disabled = true;
};

async function createPeerConnection() {
  pc = new RTCPeerConnection({
    iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
  });
  localStream.getTracks().forEach(t => pc.addTrack(t, localStream));

  pc.ontrack = e => {
    remoteVideo.srcObject = e.streams[0];
  };

  pc.onicecandidate = e => {
    if (e.candidate && otherId) {
      socket.emit('candidate', { target: otherId, candidate: e.candidate });
    }
  };
}

function closeCall() {
  if (pc) {
    pc.close();
    pc = null;
  }
  remoteVideo.srcObject = null;
  otherId = null;
}

  1. 运行
sudo node server.js   # 监听 80 端口需 sudo

浏览器打开 http://localhost,A/B 两台机器输入相同房间名即可互拨。
控制台可看到 8 条信令按顺序打印,完全符合题目流程图。


  1. 生产环境注意
  2. 把 Google STUN 换成自建 TURN(coturn),否则 NAT 穿墙失败率很高。
  3. 信令服务器务必加 HTTPS/WSS,否则最新浏览器禁止 getUserMedia。
  4. 增加异常处理、断线重连、摄像头/麦克风权限错误提示。
  5. 若要做移动端,需加 playsinline、自动播放策略处理。
posted @ 2025-11-16 21:14  aiplus  阅读(6)  评论(0)    收藏  举报
悬浮按钮示例