恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
手写JavaScript WebRTC通话系统:信令、媒体协商与部署实践
首页
资讯中心
/
手写JavaScript WebRTC通话系统:信令、媒体协商与部署实践
手写JavaScript WebRTC通话系统:信令、媒体协商与部署实践
发布时间:2026/9/16 9:52:33
简介这是一份基于JavaScript WebRTC的跨平台音视频通话源码与项目说明适合作为毕业设计、期末大作业或课程设计参考面向计算机、通信、人工智能、自动化等相关专业的学生、老师及从业者。源码覆盖1对1视频、多人视频、视频直播、屏幕共享、视频会议、房间管理与权限管理等常见场景既可满足课堂演示与实训也能为真实项目开发提供基础。资源包共31个文件以14个JavaScript脚本、7个HTML页面、4个CSS样式为主另含SSL证书、图标、README说明与package.json配置整体仅657KB结构清晰便于学习使用。该项目是作者个人毕设答辩评审达98分代码经过调试测试已有75人学习下载。随包提供服务端信令与多个示例页面可帮助初学者理解WebRTC连接建立、媒体流传输等核心流程进阶者也能在此基础上快速改造扩展自定义功能。1. 为什么 JavaScript WebRTC 仍然值得自己写一套通话系统如果你只想快速上线一个音视频功能选择声网或腾讯云 RTC 的 SDK 确实更省事。但这个项目走的是另一条路把 WebRTC 的完整信令交换、媒体协商、房间管理全部用原生 JavaScript 重做了一遍。它的价值不在于“能用”而在于把整个通话链路摊开在面前——offer/answer 流程、ICE 候选收集、远端流绑定、多方 Mesh 拓扑、屏幕共享、文件通道每一环都能直接读到源码调试时可以精确到某个 candidate 为什么没有配对。我用这套代码跑过一次多人视频会议场景整体稳定性足够支撑课程设计答辩演示和中小规模的私有化部署。命令行的启动逻辑只有寥寥几个文件稍加改造就能嵌入 Electron、Taro 或 uni-app 工程。对于正在准备 JavaScript 毕业设计或期末大作业的同学它提供的不只是可运行 demo更是一份能讲清楚“信令服务和媒体面各自承担什么职责”的参考实现。这也是我把这份源码拆开写这篇文章的原因。2. 服务器端整体架构与三元信令模型2.1 Node.js 信令服务启动流程与证书配置项目服务端基于 Node.js 实现核心入口并不复杂。拿到源码包后首先看 package.json依赖集中在ws和express一类基础库上没有引入重量级框架。启动前需要确认两件事本机 Node 版本建议 14 以上另外本地调试建议直接使用项目自带的 SSL 证书文件因为 WebRTC 的getUserMedia在非安全上下文非 HTTPS 或 localhost下会被浏览器直接拦截。npm install node zero.jszero.js是 1 对 1 通话场景的信令入口。启动后默认监听在process.env.PORT || 3000同时挂载 WebSocket 服务和静态文件服务。配合main ssl certificate.pem与privatekey.pemHTTPS 服务由https.createServer手动加载证书实现前端页面通过wss://建立安全信令通道。参数说明PORT环境变量指定监听端口不设置时回退到 3000证书路径写在zero.js顶部的常量里替换为自己的域名证书时只需改动两个路径常量。我一般会把证书的读取逻辑抽成一个独立模块避免在三个入口文件里重复维护。2.2 三个信令入口与广播模式的职责划分源码中zero.js、one.js、many.js分别对应三类会话模型这是一套非常清晰的渐进式设计入口文件会话模型适用场景信令核心职责zero.js1 对 1双人视频、白板协作转发 offer/answer/candidateone.js单房间多方视频会议、小组讨论房间内广播信令维护成员列表many.js多房间管理直播、多会议室房间创建/销毁跨房间隔离broadcast.js广播模式直播推流、单路分发仅主播上行观众接收many.js通常被误认为是“多人通话”实际上它处理的是“多个房间”的隔离与调度。每个房间内部仍然走one.js的 Mesh 信令逻辑。我的经验是多人会议的人数上限受限于 Mesh 架构的带宽损耗超过 6 路视频流时建议在many.js之上叠加选择性转发策略只转发说话人画面。2.3 信令消息格式与前端的对应关系服务端与前端通过 JSON 消息通信。打开README.md里的协议说明可以看到系统约定了一条消息必须包含type和payload字段。前端通过ws.send(JSON.stringify(...))发送信令服务端根据type分发到对应房间或对端。// 前端发送 offer 信令 ws.send(JSON.stringify({ type: offer, payload: { sdp: offer.sdp, target: remoteUserId, roomId: currentRoomId } }));服务端收到offer后会通过target字段定位到指定用户将payload原样转发。这套转发机制不解析 SDP 内容所以对后续扩展munging操作比如修改 SDP 中的码率参数保留了足够空间。注意target字段在 Mesh 模式为对端 ID在many.js多房间模式下会先经过一次roomId路由再转发避免跨房间串流。2.4 服务端状态管理与房间生命周期这套代码的房间状态保存在内存中用一个Map结构维护房号到成员 ID 集合的映射。成员离开时通过close事件触发清理流程同时广播peer-left通知剩余用户解除 RTCPeerConnection。内存管理方式决定了它不适合多实例水平扩展但作为课程设计去讲解信令服务的状态模型反而比引入 Redis 更直观。想改成多实例部署时只需把roomsMap 替换成 Redis 的SADD/SREM/SCARD操作。3. 1 对 1 通话的完整核心链路与参数配置3.1 getUserMedia 采集与媒体约束的工程化设置zero.js对应的前端页面里媒体采集使用标准getUserMediaAPI。关键在选择合适的媒体约束而不是直接无脑开启最高分辨率。移动端和 PC 端的处理能力差异很大我根据源码里的默认配置做了调整看起来更贴近实际生产const constraints { video: { width: { ideal: 1280, max: 1920 }, height: { ideal: 720, max: 1080 }, frameRate: { ideal: 24, max: 30 }, facingMode: user }, audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }; const localStream await navigator.mediaDevices.getUserMedia(constraints); localVideo.srcObject localStream;参数逻辑ideal和max组合表示浏览器优先尝试 1280×720遇到性能瓶颈时自动降级不会直接失败帧率限制在 30fps 以下能显著降低编码压力会议场景下 20~24fps 足够流畅音频三项布尔值全部开启对应浏览器的 AEC回声消除、NS降噪和 AGC自动增益。如果你在教室这类混响比较严重的环境做答辩演示一定要保留这些处理否则远端会听到明显的回声和风扇底噪。3.2 Offer/Answer 协商与 ICE 候选的处理顺序信令交换中最容易出错的就是 offer 和 answer 的时序问题。WebRTC 要求发起方先创建 offer设置本地描述后通过信令发给对端接收方拿到 offer 后创建 answer同样 setLocalDescription 后返回。源码里把这套流程封装得很干净关键在createOffer之前必须确认localStream已经成功绑定到 RTCPeerConnection。const pc new RTCPeerConnection({ iceServers: [ { urls: stun:stun.l.google.com:19302 }, { urls: turn:your-turn-server.example.com:3478, username: demo, credential: demo123 } ] }); localStream.getTracks().forEach(track pc.addTrack(track, localStream)); pc.onicecandidate (event) { if (event.candidate) { ws.send(JSON.stringify({ type: candidate, payload: { candidate: event.candidate, target: remoteUserId } })); } }; const offer await pc.createOffer(); await pc.setLocalDescription(offer); ws.send(JSON.stringify({ type: offer, payload: { sdp: offer.sdp, target: remoteUserId } }));ICE 候选需要注意onicecandidate事件可能在setLocalDescription之后立即密集触发而信令通道此时可能还没来得及完成房间配对。我在调试时遇到过前两个 candidate 发出去对端还没建立 RTCPeerConnection 的情况解决方案是服务端在收到candidate时检测目标用户是否在线如果不在就缓存最近 10 条候选等join消息到达后补发。3.3 远端流绑定与自动重连机制当对端通过ontrack回调返回 MediaStream 时需要把轨道添加到 video 元素的 srcObject 中。源码中直接使用了srcObject event.streams[0]的方式这与旧版createObjectURL(stream)相比更推荐因为createObjectURL方法在较新的浏览器中已被标记为废弃。补充一个细节ontrack可能被多次触发每路轨道一次重复设置 srcObject 会导致画面闪烁建议加一层判断pc.ontrack (event) { if (!remoteVideo.srcObject) { remoteVideo.srcObject event.streams[0]; } else { event.streams[0].getTracks().forEach(track { remoteVideo.srcObject.addTrack(track); }); } };重连逻辑方面源码中通过iceConnectionState的变化触发恢复流程。disconnected状态持续超过 3 秒就重新调用createOffer进行 ICE 重启比直接整页刷新要优雅得多。3.4 常见协商失败原因与排查要点这一节的调试经验来自实际跑通项目的过程也是课程设计答辩时老师最容易追问的细节。最常见的三个问题SDP 中 m-line 顺序不一致。如果添加音频和视频轨道的顺序在两端不同可能产生协商错位。TURN 服务不可用导致 NAT 穿透失败。公网环境只配置 STUN 大概率在某些校园网环境下不通。setRemoteDescription 抛出InvalidStateError。通常是因为在setLocalDescription尚未完成时就收到了远端 answer。排查方式建议打开浏览器的chrome://webrtc-internals查看iceConnectionState的变化轨迹和candidate-pair的选择结果。这个工具给出的信息量远超 console 日志。4. 多人视频的房间模型与 JavaScript 信令隔离设计4.1 Mesh 架构下的多路流管理与带宽开销多人视频的核心问题不是“怎么把流发给所有人”而是“怎么让每个人的上行带宽撑得住”。这套源码采用 Mesh 架构每个参与者都要上行一路流、下行 N-1 路流。在 4 人会议中每人的下行链路是 3 路视频加 3 路音频按单路 1.5 Mbps 计算下行总带宽约 4.5 Mbps。这对于家庭宽带没有问题但在教室或实验室的公共 Wi‑Fi 环境会很吃力。源码在one.js的客户端代码里预留了带宽控制的接口。我建议在实际使用中按下述方式限制每路视频的码率上限// 修改 RTCRtpSender 的编码参数 const sender pc.getSenders().find(s s.track s.track.kind video); if (sender) { const params sender.getParameters(); params.encodings[0].maxBitrate 800_000; // 800 kbps sender.setParameters(params).catch(e console.warn(setParameters failed:, e)); }maxBitrate的单位是 bps设置 800000 表示单路视频不超过 800 kbps。这个值需要根据实际分辨率调整720p 建议 1~1.5 Mbps360p 建议 400~600 kbps。setParameters在部分浏览器上可能抛出InvalidModificationError捕获后忽略即可不影响现有通话。4.2 房间创建与成员权限控制的实现方式many.js中的房间管理是 JavaScript 面向对象思想的一个典型应用场景。每个房间实例维护自己的成员列表、创建者 ID、房间状态锁定/开放并通过事件回调与服务器主循环交互。权限控制的核心是“创建者拥有管理权”// 服务端房间对象核心结构 class Room { constructor(id, creatorId, maxMembers 8) { this.id id; this.creatorId creatorId; this.maxMembers maxMembers; this.members new Map(); this.locked false; } addMember(userId, role participant) { if (this.members.size this.maxMembers) return { ok: false, reason: room-full }; if (this.locked role ! creator) return { ok: false, reason: room-locked }; this.members.set(userId, { role, joinedAt: Date.now() }); return { ok: true }; } isCreator(userId) { return userId this.creatorId; } }maxMembers默认 8创建房间时可以由前端传入覆盖。权限控制粒度停留在“创建者/参与者”两级没有细分主持人、演讲者、观众。对于覆盖毕业设计场景已经足够演示时可以展示“主持人锁定房间 → 新成员加入失败”的完整链路。若需要更细粒度的权限可在role字段扩展publisher和subscriber角色在信令转发层拦截发布请求。4.3 多人信令的广播与定向转发策略多人房间内的信令分两类需要广播给所有人的如peer-joined、peer-left以及只发给特定对象的如 offer、answer、candidate。源码在服务端用switch-case区分消息类型广播消息直接遍历room.members逐条 send定向消息则通过target字段查找目标连接case offer: const targetConn room.members.get(msg.payload.target); if (targetConn) { targetConn.send(JSON.stringify({ type: offer, payload: { sdp: msg.payload.sdp, from: msg.from, answerer: targetConn.userId } })); } else { pendingOffers.set(msg.payload.target, msg.payload); } break;pendingOffers用来暂存目标不在线时的 offer等对方上线后由服务端主动推送。这个设计比客户端轮询要高效得多。需要注意的坑是如果同一个用户在多标签页打开房间room.members里的连接对象可能被覆盖导致信号发到旧标签页。建议在用户加入时检查是否已有同 ID 连接如有则先断开旧连接。4.4 多人通话的入会与离会流程入会流程的三步走创建 RTCPeerConnection → 发送join信令 → 等待房间成员列表。源码的做法是服务端收到join后将当前成员 ID 列表返回给新加入者新加入者再逐个对已有成员发起 offer。离会时流程相反先通知服务端移除成员再由服务端广播peer-left其余客户端逐个关闭对应的 RTCPeerConnection。// 客户端离会清理 function leaveRoom() { // 关闭所有既有连接 peerConnections.forEach(pc { pc.getSenders().forEach(s s.track s.track.stop()); pc.close(); }); peerConnections.clear(); // 通知服务端 ws.send(JSON.stringify({ type: leave, payload: { roomId } })); }这里需要特别注意track.stop()的调用顺序先停止本地轨道再关闭 RTCPeerConnection。否则摄像头指示灯可能仍然亮着在答辩现场会比较尴尬。离会消息发出后服务端不会主动关闭 WebSocket空闲连接由服务端的ping/pong心跳机制清理。5. 屏幕共享、直播推流与 fileconn.js 文件传输通道5.1 getDisplayMedia 实现屏幕共享的技术细节屏幕共享与摄像头采集的差异主要在于约束条件和权限弹窗。getDisplayMedia不允许在非用户手势触发下调用所以按钮点击事件处理函数里直接调用即可。面板选择“整个屏幕”或“窗口”由浏览器原生对话框控制JavaScript 无法绕开。源码中的zero.js无线端远程调试教学场景用到这个功能实现方式如下async function startScreenShare() { const screenStream await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 15, max: 30 }, displaySurface: monitor }, audio: false }); const videoSender peerConnection.getSenders().find(s s.track s.track.kind video); if (videoSender) { videoSender.replaceTrack(screenStream.getVideoTracks()[0]); } screenStream.getVideoTracks()[0].addEventListener(ended, () { // 用户点击浏览器的“停止共享”按钮时触发 videoSender.replaceTrack(localStream.getVideoTracks()[0]); }); }注意displaySurface: monitor只对部分浏览器有效它是建议性提示不能强制指定屏幕捕获的范围。WebRTC 规范中还有browser和window两种取值但最终弹窗仍由用户决定。分享过程中用户主动点击“停止共享”会让屏幕轨道触发ended事件必须监听该事件并replaceTrack切回摄像头否则远端会看到黑屏。5.2 broadcast.js 直播推流的单路发布机制broadcast.js实现的是单主播多观众模型。与 Mesh 多方通话不同观众之间不建立 P2P 连接所有数据流统一由主播上行观众只从主播获取。这种方式的服务端在信令层面做了一层过滤只允许主播发送媒体流描述观众只能发送接收请求。源码中的角色认证很简洁const role connection.role; // host | viewer if (role viewer msg.type offer) { connection.send(JSON.stringify({ type: error, payload: { code: 403, message: viewer cannot publish } })); return; }广播模式下主播下行带宽压力为零上行带宽决定观众能看到的最高清晰度。观众数量增长不会增加主播的负担但会增加信令服务器的消息分发压力。实际部署时建议把 broadcast 模式下观众与主播之间的信令消息精简到最小集只保留join、offer、answer、candidate四类去掉文件传输相关命令。5.3 fileconn.js 的 DataChannel 文件传输实现fileconn.js是容易被忽略的一个模块它演示了 WebRTC DataChannel 在音视频之外的应用。DataChannel 独立于媒体流走的是同一个 RTCPeerConnection 的 DTLS 加密通道但可靠性可以单独控制。文件传输使用reliable: true的 DataChannel按分片发送二进制数据const fileChannel peerConnection.createDataChannel(file-transfer, { ordered: true, maxRetransmits: 0 }); function sendFileChunk(chunk) { if (fileChannel.readyState open) { fileChannel.send(chunk); } else { console.warn(channel not open, buffer:, chunk.byteLength); } } fileChannel.onmessage (event) { // 接收端拼接分片 const buffer event.data instanceof ArrayBuffer ? event.data : new Uint8Array(event.data); receivedChunks.push(buffer); if (receivedChunks.length totalChunks) { const blob new Blob(receivedChunks); downloadLink.href URL.createObjectURL(blob); downloadLink.click(); } };注意maxRetransmits: 0表示不重传适合大文件高速传输但会丢包。实际文件传输建议改成maxRetransmits: 30保留一定容错能力。分片大小建议控制在 16 KB 以下超过 64 KB 会被底层协议强制拆分效率反而下降。目录里与 fileconn.js 配合的前端页面可以在同一房间内传输课件或答辩文档演示效果不错。5.4 跨端适配的注意事项这套 JavaScript WebRTC 源码跨平台能力体现在浏览器层面Chrome、Edge、Firefox、Safari 均可运行。移动端 iOS Safari 的限制在于视频元素必须处于用户手势触发的播放流程中才能出声Android Chrome 则要注意摄像头权限请求时机。打包成 App 时通常使用 WebView 或 Capacitor 套壳此时需要确认 WebView 是否启用了 WebRTC 支持部分 Android 系统 WebView 需要额外打开硬件加速开关。6. 部署排错的验证方法与代码级调优技巧6.1 一手验证信令与媒体面故障的调试步骤拿到源码后先在本地跑通zero.js再逐步推进到one.js与many.js是成本最低的验证路径。以下是我基于这套源码实际操作过的检查顺序能定位九成以上的问题# 1. 确认服务启动成功 node zero.js # 输出: WebSocket server listening on 3000 # 2. 浏览器打开 https://localhost:3000F12 查看 Network # 确认 wss 连接状态为 101 Switching Protocols # 3. 打开 chrome://webrtc-internals # 检查 getUserMedia 是否返回 resolvedICE 状态是否 reachable如果getUserMedia返回NotAllowedError说明页面不是安全上下文检查证书是否可信如果 ICE 状态一直是checking而非connected大概率是 NAT 穿透失败这时候必须配置可用的 TURN 服务单纯依赖 STUN 在对称型 NAT 下无法打通。6.2 信令时序竞态的代码级规避多人会议中最常见的隐性 Bug 是peer-joined广播到达时新用户尚未完成setLocalDescription。源码在one.js里用connectionState做了一层防护但我的建议是在客户端手写一个有限状态机防止消息乱序带来的状态污染const signalingState { IDLE: idle, OFFER_SENT: offer_sent, ANSWER_RECEIVED: answer_received, CONNECTED: connected }; function handleOffer(payload) { if (signalingState ! IDLE) return; // 忽略重复 offer // 处理逻辑 }使用状态机可以避免重复 offer 或过期 answer 覆盖当前可用连接。如果看到iceConnectionState在disconnected和connected间反复抖动检查是否强制设置了iceRestart但没有发新的candidate。6.3 码率自适应与带宽估计的实用调优WebRTC 的拥塞控制基于带宽估计Bandwidth Estimation但这套代码里没有显式接入RTCRtpSender.setParameters的动态调整逻辑。如果想在弱网环境下提高通话质量可以监听stats数据实时调整目标码率setInterval(async () { const stats await peerConnection.getStats(); stats.forEach(report { if (report.type inbound-rtp report.kind video) { const frameRate report.framesPerSecond; if (frameRate 10) { // 帧率过低降低编码分辨率 sender.setParameters({ encodings: [{ maxBitrate: 300_000 }] }); } } }); }, 3000);getStats返回的inbound-rtp报告包含framesPerSecond、bytesReceived、packetsLost等核心指标。持续低于 10fps 时说明带宽不足主动降码率比让拥塞控制被动收敛速度更快。这是从“能跑”到“跑得稳”的关键一步也是答辩时能体现工程经验的技术点。6.4 移动端与多页面试用的最终验证清单在真实演示前按这个清单过一遍能避免临场翻车同一 WiFi 下两部手机加一台 PC 进入同一会议任何人都能发言和看到他人画面一部手机切到飞行模式再恢复确认 30 秒内自动重连回房间点击屏幕共享后另一端的画面延迟不超过 1 秒旁观者加入已满的房间看到服务端返回room-full。用这套 JavaScript WebRTC 工程做完这些验证整个项目的技术完整性和演示价值就充分了。本文还有配套的精品资源点击获取