简介这是一份基于 WebRTC 的简单视频会议应用完整源码面向计算机相关专业学生及企业开发人员可用于课程设计、毕业设计、大作业或初期项目立项演示帮助读者理解实时音视频通信的基本实现流程。资源包共 94 个文件约 299KB以 48 个 JavaScript 文件为核心逻辑配合 7 个 Vue 组件、4 个 HTML 页面、8 张 PNG 图片及若干 JSON、Markdown 配置与说明文档整体结构清晰涵盖前端界面、路由、构建配置与 Node 服务端模块。项目代码经过测试运行功能正常读者可从中学习 WebRTC 信令交互、房间管理、前后端联调等关键环节并参考说明文档快速搭建本地环境。目前已有 48 人学习适合作为入门实战与项目参考。1. 从一份「基于 WebRTC 的简单视频会议 app 完整源码」说起它到底能跑出什么你手上如果拿到一个叫「基于 WebRTC 的简单视频会议 app 完整源码说明.zip」的包第一反应大概率是解压、装依赖、跑起来然后发现两个浏览器窗口能互相看到画面就以为成了。但真正把它放到局域网之外、放到多人房间、放到弱网环境里翻车点会一个接一个冒出来。WebRTC 本身负责的是点对点音视频传输它不负责房间管理、不负责信令、不负责用户列表这些都得靠外层代码补。所以这份源码的价值不在于「视频会议」四个字而在于它把信令服务器、房间逻辑、SDP 交换、ICE 候选收集这几块拼成了一个能跑的最小闭环。适合谁适合想自己搭一套内网会议工具的后端或全栈工程师也适合想搞懂 WebRTC 信令流程、不想一上来就啃大型开源项目的开发者。下面我按「先跑通、再拆解、再避坑、最后进阶」的顺序把这份源码里最值得抄的部分讲清楚。2. 把源码跑起来之前先搞懂 WebRTC 信令和房间模型2.1 为什么 WebRTC 自己搞不定「进房间」这件事WebRTC 的 API 里没有「房间」这个概念。RTCPeerConnection只关心两件事跟谁连、怎么连。它需要你先通过一条外部通道把 SDP offer/answer 和 ICE candidate 传过去这条通道就是信令。常见做法是用 WebSocket 做信令因为它是全双工、低延迟、浏览器原生支持。源码里一般会有一个server.js或signaling.js里面用ws库起一个 WebSocket 服务维护一个rooms对象结构大概是{ roomId: [ { userId, ws } ] }。当第一个人加入房间时服务端只记录第二个人加入时服务端通知第一个人「有人来了你发 offer」第三个人加入时服务端可以选择让新人跟已有的人逐一建立连接也可以走 SFU 架构。这份「简单源码」大概率是 mesh 架构也就是每个人跟其他所有人各建一条 PeerConnection。mesh 的优点是实现简单、不需要媒体服务器缺点是人数一多上行带宽和 CPU 就扛不住。一般 34 人以内 mesh 还能用超过 5 人就会明显卡顿。2.2 信令消息的四种类型和交换顺序源码里信令消息通常就四种join、offer、answer、candidate。顺序不能乱。新人进来先发join服务端返回当前房间已有用户列表然后新人针对每个已有用户创建RTCPeerConnection调用createOffer把 offer 通过信令发给对方对方收到后setRemoteDescription再createAnswer发回去双方在onicecandidate里把候选地址发给对方对方addIceCandidate。这里有个血泪经验setRemoteDescription和addIceCandidate都是异步的如果 candidate 比 remoteDescription 先到就会报InvalidStateError。解决办法是在收到 candidate 时先缓存等setRemoteDescription完成后再批量addIceCandidate。源码里如果没做这个缓存在跨网络环境里就会时好时坏属于典型的玄学问题。2.3 用 Node.js 起一个最小信令服务的命令和参数假设源码根目录有package.json依赖里有express和ws。先装依赖再启动# 进入源码目录 cd webrtc-meeting-app # 安装依赖注意 ws 版本要和源码里 require 的方式匹配 npm install # 启动信令服务默认端口一般在 server.js 里写死为 3000 node server.js如果server.js里用了process.env.PORT可以这样覆盖PORT8080 node server.js启动后浏览器打开http://localhost:3000两个标签页分别输入同一个房间号就能看到本地画面互通。注意localhost下 WebRTC 可以不用 HTTPS但换成局域网 IP 或公网域名时浏览器会强制要求 HTTPS否则getUserMedia直接报NotAllowedError。这是新手最容易踩的坑没有之一。2.4 前端getUserMedia的参数怎么调才不糊源码里获取本地流的代码通常长这样// 约束参数分辨率、帧率、是否采集音频 const constraints { video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 24, max: 30 } }, audio: { echoCancellation: true, // 回声消除会议场景必开 noiseSuppression: true, // 噪声抑制键盘声、风扇声靠它 autoGainControl: true // 自动增益避免有人声音太小 } }; navigator.mediaDevices.getUserMedia(constraints) .then(stream { localVideo.srcObject stream; // 后续把 stream 里的 track 加到 PeerConnection }) .catch(err console.error(获取媒体失败:, err));ideal表示期望值浏览器会尽量满足但不保证max是硬上限。如果设成exact: 1920而摄像头不支持直接抛OverconstrainedError。会议场景我一般把帧率压到 24因为人眼对会议画面流畅度不敏感省下来的带宽留给分辨率更划算。音频三个开关全开尤其是echoCancellation不开的话对方会听到自己的回声体验直接崩。3. 把 mesh 改成多人可用房间管理、ICE 和带宽控制3.1 房间人数上限和 mesh 连接数的关系mesh 架构下N 个人需要 N*(N-1)/2 条连接。3 人 3 条4 人 6 条5 人 10 条。每条连接都要独立编码和发送音视频流上行带宽 单路码率 × (N-1)。假设单路 720p 码率 1.5 Mbps5 人时上行就是 6 Mbps很多家用宽带上行只有 1020 Mbps再加上 WiFi 抖动基本就卡成幻灯片了。所以源码如果没写人数限制你得自己加。常见做法是在服务端join时判断房间人数超过 4 人直接拒绝并返回提示。如果非要支持更多人就得引入 SFU比如 mediasoup 或 Janus但那就不是「简单源码」的范畴了。3.2 ICE candidate 收集不全导致「能看到人但没画面」ICE 负责穿透 NAT收集三类候选host本机地址、srflx公网映射地址、relay中继地址。如果只收集到 host跨网络就连不上。源码里如果没有配置 STUN 服务器iceServers就是空的浏览器只会给 host candidate。加上公共 STUN 的写法const pc new RTCPeerConnection({ iceServers: [ { urls: stun:stun.l.google.com:19302 }, { urls: stun:stun1.l.google.com:19302 } ], iceCandidatePoolSize: 10 // 预收集候选加快连接速度 });iceCandidatePoolSize设成 10 表示提前收集 10 个候选能减少首帧时间。但注意STUN 只能解决锥形 NAT对称 NAT 下还是得靠 TURN 中继。TURN 需要自己搭比如 coturn配置里要开lt-cred-mech和realm然后在iceServers里加{ urls: turn:your-server:3478, username, credential }。没有 TURN 的话大概 10%20% 的网络环境会连不上表现就是信令通了、但视频一直黑屏。3.3 用getStats看真实码率和丢包别靠猜WebRTC 的getStats是排查问题的黑匣子。在PeerConnection上定时调用setInterval(async () { const stats await pc.getStats(); stats.forEach(report { if (report.type outbound-rtp report.kind video) { console.log(发送码率:, report.bytesSent, 帧数:, report.framesEncoded); } if (report.type inbound-rtp report.kind video) { console.log(接收丢包:, report.packetsLost, 抖动:, report.jitter); } }); }, 2000);packetsLost持续增长说明网络丢包jitter超过 30ms 说明网络抖动大。这时候可以调RTCRtpSender.setParameters里的maxBitrate来降码率const sender pc.getSenders().find(s s.track.kind video); const params sender.getParameters(); params.encodings[0].maxBitrate 500000; // 500 kbps await sender.setParameters(params);注意setParameters必须在getParameters之后调用而且有些浏览器要求encodings数组不能为空否则报InvalidModificationError。3.4 房间信令服务端的关键代码和边界处理服务端用ws维护房间核心逻辑如下const WebSocket require(ws); const wss new WebSocket.Server({ port: 3000 }); const rooms {}; // { roomId: [ws1, ws2] } wss.on(connection, ws { ws.on(message, msg { const data JSON.parse(msg); if (data.type join) { const { roomId } data; if (!rooms[roomId]) rooms[roomId] []; if (rooms[roomId].length 4) { ws.send(JSON.stringify({ type: error, msg: 房间已满 })); return; } rooms[roomId].push(ws); ws.roomId roomId; // 通知房间里其他人有新成员加入 rooms[roomId].forEach(client { if (client ! ws client.readyState WebSocket.OPEN) { client.send(JSON.stringify({ type: new-peer })); } }); } // offer/answer/candidate 直接转发给同房间其他人 if ([offer, answer, candidate].includes(data.type)) { rooms[data.roomId]?.forEach(client { if (client ! ws client.readyState WebSocket.OPEN) { client.send(JSON.stringify(data)); } }); } }); ws.on(close, () { const room rooms[ws.roomId]; if (room) { rooms[ws.roomId] room.filter(c c ! ws); if (rooms[ws.roomId].length 0) delete rooms[ws.roomId]; } }); });这里有几个边界readyState必须判断否则向已关闭的连接发消息会抛异常close事件里要清理房间不然内存泄漏人数上限判断要在push之前。这些细节源码里如果漏了跑久了就会出问题。4. 避坑与排查源码跑不通时先看这 5 条4.1 现象本地两个标签页能通换台电脑就黑屏原因iceServers没配 STUN或者配了但被防火墙拦了。host candidate 只在同一台机器或同一局域网内有效跨网络必须靠 srflx 或 relay。解决在RTCPeerConnection配置里加上公共 STUN如果还不行就搭 TURN。排查方法是在chrome://webrtc-internals里看 candidate 列表如果只有host类型就是这个问题。4.2 现象getUserMedia报NotAllowedError或NotFoundError原因NotAllowedError通常是页面不是 HTTPS 或 localhost浏览器禁止访问摄像头NotFoundError是设备没有摄像头或麦克风。解决本地开发用localhost局域网测试用https或者用chrome://flags/#unsafely-treat-insecure-origin-as-secure把 IP 加白名单。设备问题就检查系统权限和浏览器权限设置。4.3 现象setRemoteDescription报InvalidStateError原因candidate 比 remoteDescription 先到或者重复调用了setRemoteDescription。解决在收到 candidate 时先存到数组等setRemoteDescription的 Promise resolve 后再遍历addIceCandidate。另外确保createOffer和createAnswer只调一次重复调用会改变状态机。4.4 现象视频画面卡住但音频正常原因视频码率超过带宽上限或者编码器卡死。解决用getStats看framesEncoded是否停止增长如果是就调低maxBitrate或降低分辨率。另外检查是不是用了H264而对方只支持VP8虽然 WebRTC 会自动协商但某些老版本浏览器协商会失败。可以在setLocalDescription之前用RTCRtpSender.getCapabilities(video)看支持的编码。4.5 现象多人房间里有人听不到声音原因音频 track 没有加到所有 PeerConnection 上或者addTrack之后没有重新协商。解决每次新建 PeerConnection 时把本地 stream 的所有 track 都addTrack进去。如果是后加入的人需要重新触发一次 offer/answer 流程。另外注意audio的echoCancellation在某些设备上会导致声音断续可以尝试关掉对比。5. 进阶把「简单源码」改成能长期用的内网会议工具5.1 用replaceTrack做屏幕共享切换源码里如果只有摄像头想加屏幕共享不需要重建 PeerConnection。用getDisplayMedia拿到屏幕流然后const screenStream await navigator.mediaDevices.getDisplayMedia({ video: true }); const screenTrack screenStream.getVideoTracks()[0]; const sender pc.getSenders().find(s s.track.kind video); await sender.replaceTrack(screenTrack);replaceTrack不会触发重新协商切换很快。想切回摄像头就再replaceTrack一次。注意屏幕共享的 track 要监听ended事件用户点「停止共享」时切回摄像头否则对方会一直看到静止画面。5.2 用datachannel传文字消息和文件WebRTC 除了音视频还能建数据通道const dc pc.createDataChannel(chat, { ordered: true }); dc.onopen () dc.send(JSON.stringify({ user: me, text: hello })); dc.onmessage e console.log(收到:, e.data);ordered: true保证消息顺序传文件时建议分片每片 16KB 左右因为超过 64KB 某些浏览器会直接断开。数据通道走 SCTP不占音视频带宽适合传聊天和少量文件。5.3 验证方案是否值得投入的三个指标第一首帧时间。从点击「加入房间」到看到对方画面局域网内应小于 1 秒跨网络小于 3 秒。第二卡顿率。用getStats统计freezeCount每分钟超过 2 次就说明带宽或编码有问题。第三连接成功率。在没有 TURN 的情况下跨网络连接成功率大概 80%加上 TURN 能到 99%。如果你要长期用TURN 是必须投入的一台 1 核 2G 的云服务器跑 coturn 就能支撑几十路中继。我自己的习惯是每次改完信令逻辑先在chrome://webrtc-internals里把 offer/answer 和 candidate 的时序图看一遍确认没有乱序再测多人。这个习惯帮我省掉了至少一半的「玄学」问题。希望帮到你。本文还有配套的精品资源点击获取