“最小可運行”的一對一 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、自動播放策略處理。