WebRTC 直播模块实战02
WebRTC 直播模块实战
本篇目标
- 搭建信令服务器
- 写主播端 HTML:采集摄像头、建立连接、给每个观众发 Offer
- 跑起来验证:主播页面能把视频推给观众
1. 项目结构
webrtc-live/
server.js
public/
publisher.html
viewer.html
package.json
2. 信令服务器(server.js)
功能:
- 提供静态页面(express)
- WebSocket 做信令转发
- 支持房间:一个主播,多观众
- 服务器不处理媒体数据,只转发消息
创建 server.js:
const express = require("express");
const http = require("http");
const WebSocket = require("ws");
const path = require("path");
const app = express();
app.use(express.static(path.join(__dirname, "public")));
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
// roomId -> { publisherId: string|null, viewers: Map<clientId, ws> }
const rooms = new Map();
function uid() {
return Math.random().toString(16).slice(2) + Date.now().toString(16);
}
function send(ws, data) {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(data));
}
wss.on("connection", (ws) => {
const clientId = uid();
ws.clientId = clientId;
ws.role = null; // "publisher" | "viewer"
ws.roomId = null;
send(ws, { type: "welcome", clientId });
ws.on("message", (raw) => {
let msg;
try { msg = JSON.parse(raw.toString()); } catch { return; }
const { type, roomId, payload, targetId } = msg;
if (type === "join") {
ws.roomId = roomId;
ws.role = msg.role;
if (!rooms.has(roomId)) {
rooms.set(roomId, { publisherId: null, publisherWs: null, viewers: new Map() });
}
const room = rooms.get(roomId);
if (ws.role === "publisher") {
room.publisherId = ws.clientId;
room.publisherWs = ws;
send(ws, { type: "joined", role: "publisher", roomId });
// 通知主播:当前已有多少观众(可选)
send(ws, { type: "room_state", viewers: Array.from(room.viewers.keys()) });
} else {
room.viewers.set(ws.clientId, ws);
send(ws, { type: "joined", role: "viewer", roomId, publisherId: room.publisherId });
// 通知主播:新观众加入
if (room.publisherWs) {
send(room.publisherWs, { type: "viewer_joined", viewerId: ws.clientId });
}
}
return;
}
// 转发信令:viewer -> publisher 或 publisher -> viewer
if (type === "signal") {
const room = rooms.get(ws.roomId);
if (!room) return;
// targetId 必须指定
if (!targetId) return;
// 找到目标 websocket
let targetWs = null;
if (room.publisherId === targetId) targetWs = room.publisherWs;
else targetWs = room.viewers.get(targetId);
if (!targetWs) return;
send(targetWs, {
type: "signal",
fromId: ws.clientId,
payload
});
return;
}
});
ws.on("close", () => {
const { roomId, role, clientId } = ws;
if (!roomId || !rooms.has(roomId)) return;
const room = rooms.get(roomId);
if (role === "publisher") {
// 主播退出:通知所有观众
for (const vws of room.viewers.values()) {
send(vws, { type: "publisher_left" });
}
rooms.delete(roomId);
} else {
// 观众退出:从房间移除,并通知主播
room.viewers.delete(clientId);
if (room.publisherWs) {
send(room.publisherWs, { type: "viewer_left", viewerId: clientId });
}
}
});
});
server.listen(3000, () => {
console.log("WebRTC Live Signaling Server running at http://localhost:3000");
});
3. 主播端:publisher.html(推流实现)
创建 public/publisher.html:
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<title>WebRTC 直播 - 主播推流</title>
<style>
body { font-family: Arial; padding: 16px; }
video { width: 480px; background: #000; border-radius: 8px; }
.row { display: flex; gap: 16px; align-items: flex-start; }
.panel { min-width: 320px; }
input, button { padding: 8px 10px; margin: 6px 0; }
.log { white-space: pre-wrap; background:#f6f6f6; padding:10px; border-radius:8px; height:260px; overflow:auto; }
</style>
</head>
<body>
<h2>主播端(推流)</h2>
<div class="row">
<div>
<video id="localVideo" autoplay playsinline muted></video>
<div>
<button id="btnStartCam">1) 打开摄像头/麦克风</button>
</div>
<div>
房间号:<input id="roomId" value="room1" />
<button id="btnJoin">2) 进入房间开始直播</button>
</div>
</div>
<div class="panel">
<div>已连接观众:<span id="viewerCount">0</span></div>
<div class="log" id="log"></div>
</div>
</div>
<script>
const logEl = document.getElementById("log");
const localVideo = document.getElementById("localVideo");
const viewerCountEl = document.getElementById("viewerCount");
function log(...args) {
logEl.textContent += args.join(" ") + "\n";
logEl.scrollTop = logEl.scrollHeight;
}
let ws;
let clientId;
let roomId;
let localStream = null;
// viewerId -> RTCPeerConnection
const pcs = new Map();
// ICE 服务器:STUN 用 Google 公共服务(开发阶段可用)
const rtcConfig = {
iceServers: [
{ urls: "stun:stun.l.google.com:19302" }
]
};
async function openCamera() {
if (localStream) return;
localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
localVideo.srcObject = localStream;
log("[OK] 摄像头/麦克风已开启");
}
function connectWS() {
return new Promise((resolve, reject) => {
ws = new WebSocket(`ws://${location.host}`);
ws.onopen = () => resolve();
ws.onerror = (e) => reject(e);
ws.onmessage = async (evt) => {
const msg = JSON.parse(evt.data);
if (msg.type === "welcome") {
clientId = msg.clientId;
log("[WS] welcome clientId =", clientId);
return;
}
if (msg.type === "joined") {
log("[WS] joined as publisher, room =", msg.roomId);
return;
}
if (msg.type === "room_state") {
const viewers = msg.viewers || [];
viewerCountEl.textContent = viewers.length;
log("[ROOM] 当前观众列表:", viewers.join(", ") || "(none)");
return;
}
if (msg.type === "viewer_joined") {
const viewerId = msg.viewerId;
log("[ROOM] 新观众加入:", viewerId);
viewerCountEl.textContent = pcs.size + 1;
// 为这个观众创建连接并发送 offer
await createPeerForViewer(viewerId);
return;
}
if (msg.type === "viewer_left") {
const viewerId = msg.viewerId;
log("[ROOM] 观众离开:", viewerId);
closePeer(viewerId);
viewerCountEl.textContent = pcs.size;
return;
}
if (msg.type === "signal") {
const fromId = msg.fromId;
const payload = msg.payload;
await handleSignal(fromId, payload);
return;
}
};
});
}
async function createPeerForViewer(viewerId) {
if (!localStream) {
log("[ERR] 还未打开摄像头,无法推流");
return;
}
// 每个观众一条 PeerConnection
const pc = new RTCPeerConnection(rtcConfig);
pcs.set(viewerId, pc);
// 把本地音视频轨道加进去(这一步就是“推流”)
localStream.getTracks().forEach(track => {
pc.addTrack(track, localStream);
});
// 收集本地 ICE candidate,发给对端
pc.onicecandidate = (e) => {
if (e.candidate) {
ws.send(JSON.stringify({
type: "signal",
roomId,
targetId: viewerId,
payload: { kind: "ice", candidate: e.candidate }
}));
}
};
pc.onconnectionstatechange = () => {
log(`[PC->${viewerId}] state =`, pc.connectionState);
};
// 创建 offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 发 offer 给观众
ws.send(JSON.stringify({
type: "signal",
roomId,
targetId: viewerId,
payload: { kind: "offer", sdp: offer }
}));
log(`[PC->${viewerId}] offer sent`);
}
async function handleSignal(fromId, payload) {
const pc = pcs.get(fromId);
if (!pc) {
log("[WARN] 收到信令但 pc 不存在 fromId =", fromId);
return;
}
if (payload.kind === "answer") {
await pc.setRemoteDescription(new RTCSessionDescription(payload.sdp));
log(`[PC->${fromId}] answer setRemoteDescription`);
return;
}
if (payload.kind === "ice") {
try {
await pc.addIceCandidate(new RTCIceCandidate(payload.candidate));
} catch (e) {
log(`[PC->${fromId}] addIceCandidate error`, e.message);
}
return;
}
}
function closePeer(viewerId) {
const pc = pcs.get(viewerId);
if (pc) {
pc.close();
pcs.delete(viewerId);
}
}
async function joinAsPublisher() {
roomId = document.getElementById("roomId").value.trim();
if (!roomId) {
log("[ERR] 房间号不能为空");
return;
}
if (!localStream) {
log("[ERR] 请先打开摄像头/麦克风");
return;
}
await connectWS();
ws.send(JSON.stringify({ type: "join", role: "publisher", roomId }));
log("[OK] 已请求加入房间,等待观众进入...");
}
document.getElementById("btnStartCam").onclick = () => openCamera().catch(e => log("[ERR]", e.message));
document.getElementById("btnJoin").onclick = () => joinAsPublisher().catch(e => log("[ERR]", e.message));
</script>
</body>
</html>
4. 运行与验证
启动服务器:
node server.js
浏览器打开:
- 主播端:
http://localhost:3000/publisher.html
先点:
- 打开摄像头
- 进入房间开始直播
然后打开观众端(下一篇会实现):
http://localhost:3000/viewer.html
5. 本篇核心“原理点”你可以写进博客
- 推流不是 RTMP,而是
addTrack()把轨道交给 PeerConnection - Offer/Answer 是媒体能力协商(SDP)
- ICE Candidate 交换是为了打洞穿透网络

浙公网安备 33010602011771号