“最小可運行”的一對一 WebRTC 視頻聊天示例(前端+信令服務器),完全按照你給出的 8 條信令流程圖實現。
技術棧:
- 前端:原生 JavaScript + socket.io-client
- 信令服務器:Node.js + socket.io + Express(80 端口同時提供靜態文件和信令)
- STUN/TURN:Google 公共 STUN 即可,生產環境請換成自己的 TURN
代碼可直接複製到同一目錄運行,局域網兩台瀏覽器打開 http://localhost 即可互撥。
- 目錄結構
webrtc-1v1/
├─ server.js // 信令服務器
├─ public/
│ ├─ index.html // 頁面
│ └─ client.js // 前端邏輯
└─ package.json
- 安裝依賴
npm init -y
npm i express socket.io
- 信令服務器 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'));
- 前端 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>
- 前端 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;
}
- 運行
sudo node server.js # 監聽 80 端口需 sudo
瀏覽器打開 http://localhost,A/B 兩台機器輸入相同房間名即可互撥。
控制枱可看到 8 條信令按順序打印,完全符合題目流程圖。
- 生產環境注意
- 把 Google STUN 換成自建 TURN(coturn),否則 NAT 穿牆失敗率很高。
- 信令服務器務必加 HTTPS/WSS,否則最新瀏覽器禁止 getUserMedia。
- 增加異常處理、斷線重連、攝像頭/麥克風權限錯誤提示。
- 若要做移動端,需加
playsinline、自動播放策略處理。
本文章為轉載內容,我們尊重原作者對文章享有的著作權。如有內容錯誤或侵權問題,歡迎原作者聯繫我們進行內容更正或刪除文章。