WebRTC 给了你发动机,但没给你方向盘、轮子和安全带。本文先弄清楚 WebRTC 到底做了什么、没做什么,再展示如何用 ZEGO Express Web SDK 在它之上填平所有工程坑,让一个可用的视频通话在 30 分钟内跑起来。

一、WebRTC 给了你什么,没给你什么
1.1 WebRTC 如何工作
WebRTC 通过 P2P 连接实现两个或多个设备之间的实时通信,使用户能够直接共享音频、视频和数据。该过程始于媒体捕获,此时 WebRTC 会使用浏览器的 getUserMedia API 访问用户的摄像头和麦克风。
媒体捕获完成后,WebRTC 通过 RTCPeerConnection API 建立 P2P 连接。为了建立此连接,两个对等方会通过信令服务器交换网络信息和连接详细信息(如 IP 地址)。随后,WebRTC 利用 ICE(交互式连接建立)处理 NAT 穿越,为设备间的数据传输寻找最佳路径,即使这些设备位于防火墙或 NAT 之后也不例外。
连接建立后,媒体(音频和视频)及数据将通过加密通信通道在对等方之间直接流式传输,从而确保隐私和安全。对于非媒体数据的传输,WebRTC 还使用 RTCDataChannel,该通道支持实时数据共享,例如文件传输或游戏数据。这种架构最大限度地降低了延迟,并减少了对中间节点的依赖,因此 WebRTC 非常适合视频通话、会议和流媒体等实时交互场景。
1.2 WebRTC 不管的三件事(恰好是你要做的)
| 不管的事 | 为什么重要 | 裸写要做什么 |
|---|---|---|
| 信令(Signaling) | 两个浏览器不认识对方,需要第三方传话 | 自建 WebSocket 服务,设计信令协议,处理 SDP Offer/Answer 交换 |
| NAT 穿透 | 90% 的设备在局域网里,IP 不可直达 | 自建 STUN/TURN 服务器,处理 ICE 候选收集和连通性检测 |
| 服务端架构 | 3 个人以上就不能再 P2P 了 | 选型 Mesh/SFU/MCU,自建或部署开源方案 |
1.3 裸写 WebRTC 大概率会踩的坑
一条条说:
- ICE 连接失败是最常见的线上事故。STUN 服务器配错了、TURN 没部署、对称 NAT 穿不透——排查这些问题时你只能对着
chrome://webrtc-internals逐个状态机翻。 - SDP Offer/Answer 的时序陷阱。先 setLocalDescription 还是先 addIceCandidate?Trickle ICE 下候选怎样逐条送达对端?顺序错了画面就不出来。
- 移动端 WebView 的兼容地狱。iOS Safari 直到 2017 年才完整支持 WebRTC,Android 各厂商 WebView 的内核版本参差不齐,
getUserMedia权限请求的交互行为也各不相同。 - 房间管理、流状态管理、断线重连这些完全没有在 WebRTC 规范里出现。你的产品需要,但 WebRTC 不管。
一个完整的 1v1 视频通话 Demo,裸写 WebRTC 大约需要 300+ 行 JavaScript(信令层 + PeerConnection 管理 + ICE 处理 + 异常恢复),而且这还只是 Demo,离生产就绪还有日活级别的距离。
二、ZEGO 的解法:在 WebRTC 之上封装了什么
2.1 架构差异
| 层级 | 裸写 WebRTC | ZEGO Express SDK(实时音视频SDK) |
|---|---|---|
| 信令层 | 自建 WebSocket + 自定协议 | SDK 内置,走 ZEGO 云信令通道 |
| 媒体传输 | P2P(需自建 STUN/TURN) | 云端 SFU 转发,全球 MSDN 节点覆盖 |
| 房间管理 | 自己实现 | loginRoom / logoutRoom |
| 流管理 | 手动维护 PeerConnection 生命周期 | startPublishingStream / startPlayingStream |
| 鉴权 | 自己设计 Token 体系 | Token04 鉴权(基础 + 权限位) |
| 弱网对抗 | WebRTC 内置 GCC 算法 | 自研 QoS 流量控制(抖动缓冲、前向纠错、丢帧补偿、MSDN 全球网络)、云代理等 |
| 跨平台覆盖 | 浏览器跨平台运行 | Web / iOS / Android / Windows / macOS / Linux / Flutter / Electron / 小程序 / uniapp/ Unity / Unreal等 |
| 质量监控 | chrome://webrtc-internals 单点查看 | 星图(音视频质量运营平台)全链路可视化 |
2.2 核心抽象:从 PeerConnection 到「房间 + 流」
这是最关键的思维转换,裸写 WebRTC 关注的是连接,ZEGO 关注的是房间和流。
| 裸写 WebRTC 心智模型 | ZEGO 心智模型 | |
|---|---|---|
| 创建连接 | new RTCPeerConnection(config) | new ZegoExpressEngine(appID, server) |
| 进入会话 | 信令交换 SDP → ICE 候选收集 | loginRoom(roomID, token, user) |
| 发送媒体 | pc.addTrack(track, stream) | startPublishingStream(streamID, localStream) |
| 接收媒体 | pc.ontrack = (e) => {...} | startPlayingStream(remoteStreamID) |
| 离开 | pc.close() | logoutRoom(roomID) |
关键区别:ZEGO 把对等连接抽象成了「向云端推流 + 从云端拉流」,你不需要知道对端是谁,也不需要关心中间有多少层 NAT。
2.3 Web 端的底层:就是 WebRTC,但开了自动驾驶
ZEGO Express Web SDK 的 npm 包名是 zego-express-engine-webrtc,使用了 WebRTC 技术实现实时音视频功能。相比 WebRTC:

- 自研 z264 编码器:同一码率下画质更高,复杂画面尤其明显。
- H.264 / VP8 智能选择:推 CDN 自动切 H.264,Chrome 上优先 VP8。
- Trickle ICE 优化:候选逐条送达,首帧速度更快。
- 设备深度适配:摄像头、麦克风、声卡的采集参数自动调优。
- 3A 及场景化 AI 降噪:自研 3A 算法及业内最轻量级的 AI 降噪,实现极低性能损耗的噪声回声抑制后的纯净人声。业内首发场景化 AI 降噪,实时识别场景,智能保证降噪和音质的综合效果。
- 安全合规:针对防火墙环境提供云代理等方案,不同区域的数据合规诉求提供数据围栏等能力。
- 云服务及组件:提供混流及转码、音频审核、视频审核、录制截图等各种服务及组件。
- 复杂网络环境高可用:音频最高抗 80% 丢包,视频抗 70% 丢包,可实现 1000ms 的超强抗抖动能力,网络带宽限制最低 30kbps。
开发者不需要感知这些,这就是 WebRTC 的自动驾驶模式。
三、实战:用 ZEGO Express SDK 实现 1v1 视频通话(Web 端)
3.1 前提准备
第一步:在 ZEGO 控制台获取凭证
登录ZEGO 控制台,创建项目后获取三个值:
AppID:数字类型,项目的唯一标识ServerSecret:32 字节字符串,用于服务端生成 Token(严禁出现在客户端代码中)Server:SDK 连接的服务器地址
第二步:实现后端 Token 生成接口
Token 必须由服务端用 ServerSecret 生成,客户端绝对不能持有 ServerSecret。
以 Node.js 为例,Token04 的核心生成逻辑(代码来源:ZEGO 官方 Server Assistant):
import { createCipheriv, randomBytes } from 'crypto';
function makeNonce() {
const min = -Math.pow(2, 31);
const max = Math.pow(2, 31) - 1;
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function aesGcmEncrypt(plainText, key) {
if (![16, 24, 32].includes(key.length)) {
throw new Error('Invalid Secret length. Key must be 16, 24, or 32 bytes.');
}
const nonce = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, nonce);
cipher.setAutoPadding(true);
const encrypted = cipher.update(plainText, 'utf8');
const encryptBuf = Buffer.concat([encrypted, cipher.final(), cipher.getAuthTag()]);
return { encryptBuf, nonce };
}
export function generateToken04(appId, userId, secret, effectiveTimeInSeconds, payload) {
if (!appId || typeof appId !== 'number') throw new Error('appID invalid');
if (!userId || typeof userId !== 'string' || userId.length > 64) throw new Error('userId invalid');
if (!secret || typeof secret !== 'string' || secret.length !== 32) throw new Error('secret must be a 32 byte string');
if (!(effectiveTimeInSeconds > 0)) throw new Error('effectiveTimeInSeconds invalid');
const VERSION_FLAG = '04';
const createTime = Math.floor(new Date().getTime() / 1000);
const tokenInfo = {
app_id: appId,
user_id: userId,
nonce: makeNonce(),
ctime: createTime,
expire: createTime + effectiveTimeInSeconds,
payload: payload || ''
};
const plaintText = JSON.stringify(tokenInfo);
const { encryptBuf, nonce } = aesGcmEncrypt(plaintText, secret);
const [b1, b2, b3, b4] = [new Uint8Array(8), new Uint8Array(2), new Uint8Array(2), new Uint8Array(1)];
new DataView(b1.buffer).setBigInt64(0, BigInt(tokenInfo.expire), false);
new DataView(b2.buffer).setUint16(0, nonce.byteLength, false);
new DataView(b3.buffer).setUint16(0, encryptBuf.byteLength, false);
new DataView(b4.buffer).setUint8(0, 1);
const buf = Buffer.concat([
Buffer.from(b1), Buffer.from(b2), Buffer.from(nonce),
Buffer.from(b3), Buffer.from(encryptBuf), Buffer.from(b4),
]);
const dv = new DataView(Uint8Array.from(buf).buffer);
return VERSION_FLAG + Buffer.from(dv.buffer).toString('base64');
}
对应暴露的 HTTP 接口(Express.js 示例):
// 环境变量:ZEGO_APP_ID、ZEGO_SERVER_SECRET(从控制台获取,严禁提交到代码仓库)
const APP_ID = Number(process.env.ZEGO_APP_ID);
const SERVER_SECRET = process.env.ZEGO_SERVER_SECRET;
app.get('/api/zego/token', async (req, res) => {
try {
const { userId, effectiveTime, payload } = req.query;
if (!userId || typeof userId !== 'string') {
return res.status(400).json({ error: 'Missing required parameter: userId' });
}
const effectiveTimeSeconds = effectiveTime ? Number(effectiveTime) : 3600;
// 参数范围限制
if (effectiveTimeSeconds < 60 || effectiveTimeSeconds > 86400) {
return res.status(400).json({ error: 'effectiveTime must be between 60 and 86400 seconds' });
}
const payloadStr = payload ? String(payload) : '';
const token = generateToken04(
APP_ID,
userId,
SERVER_SECRET,
effectiveTimeSeconds,
payloadStr
);
res.status(200).type('text/plain').send(token);
} catch (error) {
console.error('Token generation failed:', error);
res.status(500).json({ error: `Failed to generate token: ${error.message}` });
}
});
安全提示:Token 的默认有效期是 3600 秒(1 小时)。Token 快过期时 SDK 会触发
tokenWillExpire回调(过期前 30 秒),你需要在此回调中调用renewToken更新。不要把 ServerSecret 放在前端代码、Git 仓库或客户端 bundle 中。
第三步:前端安装 SDK
npm install zego-express-engine-webrtc
3.2 完整调用链(7 步)
下面这段代码可以在一个 HTML 文件中直接跑通 1v1 视频通话。实现流程示例:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Zego WebRTC Video Call</title>
</head>
<body>
<h1>ZEGO 视频通话 Demo</h1>
<div>
<h3>本地画面</h3>
<div id="local-video" style="width: 400px; height: 300px; background: #000;"></div>
</div>
<div>
<h3>远端画面</h3>
<div id="remote-video" style="width: 400px; height: 300px; background: #000;"></div>
</div>
<script type="module">
import { ZegoExpressEngine } from "zego-express-engine-webrtc";
// ====== Step 1: 创建引擎实例 ======
// appID 和 server 从 ZEGO 控制台获取
// 3.6.0 及以上版本 server 不可为空,传入控制台提供的地址
const appID = YOUR_APP_ID; // 数字类型
const server = "YOUR_SERVER_ADDR"; // 控制台获取的接入地址
const zg = new ZegoExpressEngine(appID, server);
// ====== Step 2: 从后端获取 Token 并登录房间 ======
async function startCall() {
// 从你的服务端获取 Token
const userId = "user_" + Date.now();
const resp = await fetch(`/api/zego/token?userId=${encodeURIComponent(userId)}`);
const token = await resp.text();
// Step 3: 登录房间
// roomID 建议在后端生成,最大 128 字节,仅支持数字、英文字符和部分特殊符号
const roomID = "room_demo_001";
const result = await zg.loginRoom(roomID, token,
{ userID: userId, userName: userId },
{ userUpdate: true }
);
if (result) {
console.log("登录房间成功");
await publishStream();
}
}
// ====== Step 4: 创建本地流并推流 ======
async function publishStream() {
// createZegoStream: 创建包含摄像头+麦克风的本地流
const localStream = await zg.createZegoStream();
// 将本地画面渲染到页面
localStream.playVideo(document.querySelector("#local-video"));
// streamID 需保证同一个 AppID 下全局唯一
const streamID = new Date().getTime().toString();
zg.startPublishingStream(streamID, localStream);
console.log("开始推流,streamID:", streamID);
}
// ====== Step 5: 监听远端流更新 ======
// 已登录房间内流的新增、删除都会触发此回调
zg.on('roomStreamUpdate', async (roomID, updateType, streamList) => {
console.log('roomStreamUpdate:', roomID, updateType, streamList);
if (updateType === 'ADD') {
// 有多条流时遍历拉取
for (const stream of streamList) {
// Step 6: 拉流并渲染
const remoteStream = await zg.startPlayingStream(stream.streamID);
remoteStream.playVideo(document.querySelector("#remote-video"));
console.log("开始拉流:", stream.streamID);
}
} else if (updateType === 'DELETE') {
// 远端停止推流时的处理——清除 video 画面即可
document.querySelector("#remote-video").innerHTML = "";
}
});
// ====== Step 7: 结束通话 ======
async function endCall() {
// 停止推流
zg.stopPublishingStream(streamID);
// 退出房间
zg.logoutRoom(roomID);
}
// 启动通话
startCall();
</script>
</body>
</html>
3.3 关键事件回调(生产环境必须处理)
这三个回调是 Demo 到生产的分水岭,缺一不可:
| 回调事件 | 触发时机 | 必须做什么 |
|---|---|---|
roomStateChanged | 房间连接状态变化(连接中/已连接/断开) | 断开时提示用户并触发自动重连逻辑 |
tokenWillExpire | Token 过期前 30 秒 | 立即调用后端生成新 Token,然后 zg.renewToken(newToken) |
publisherStateUpdate | 推流状态变化(正在推/已停止/出错) | 推流出错时给用户明确提示,必要时重新推流 |
// 房间状态监听
zg.on('roomStateChanged', (roomID, state, errorCode, extendedData) => {
console.log(`房间状态: ${state}, 错误码: ${errorCode}`);
// state: "CONNECTING" | "CONNECTED" | "DISCONNECTING" | "DISCONNECTED"
if (state === 'DISCONNECTED') {
// 触发重新登录逻辑
}
});
// Token 续期
zg.on('tokenWillExpire', async (roomID) => {
const newToken = await fetch(`/api/zego/token?userId=${encodeURIComponent(userId)}`)
.then(r => r.text());
zg.renewToken(newToken, roomID);
});
// 推流状态
zg.on('publisherStateUpdate', (state, streamID, info) => {
// state: "PUBLISHING" | "NO_PUBLISH" | "PUBLISH_REQUESTING"
if (state === 'NO_PUBLISH') {
console.error(`推流已停止,streamID: ${streamID}`);
}
});
四、从 Demo 到生产:ZEGO 帮你绕过的五个坑
4.1 多人通话架构
裸写 WebRTC 的困境:Mesh 拓扑下每新增一个人,已连接的每个人都要新建 PeerConnection。4 个人就是 3×4=12 条连接,CPU 和上行带宽很快撑不住。上 SFU 就得自建 mediasoup 或 Janus——你需要一个专职的媒体服务端团队。
ZEGO 的做法:云端 SFU 自动调度。你不需要管拓扑、不需要管媒体服务器扩容,所有流经由 ZEGO 的 MSDN 节点转发。对开发者来说,3 个人还是 300 个人 API 完全一样,都是 loginRoom + 推拉流。
多人视频通话的流程:
创建引擎 → 创建用户 → 登录房间 → 注册监听 → 开始推流
↓
roomStreamUpdate 回调获取房间内所有流
↓
遍历流列表 → 对每个流 startPlayingStream
↓
视频通话进行中
4.2 弱网对抗
裸写 WebRTC 的局限:WebRTC 内置的 GCC(Google Congestion Control)算法是通用的,不会针对特定地区、特定运营商优化。国内复杂的跨网环境下(电信 ↔ 联通 ↔ 移动),GCC 的表现经常不够理想。
ZEGO 的做法:
- 自研 QoS 流量控制:根据对端网络状态动态调整码率、帧率、分辨率,保证流畅度优先
- 云代理:针对医院、政府、公司内网等防火墙环境,通过云端代理服务器中转,绕过网络限制
- 地理围栏:将音视频及信令数据传输限定在指定区域,满足数据隐私合规要求
4.3 安全鉴权
裸写 WebRTC 的缺口:WebRTC 的 DTLS-SRTP 保证了媒体流的传输加密,但不解决「谁能进入房间」的问题。你需要自己设计鉴权体系。
ZEGO 的 Token 体系:
| 鉴权方式 | 能力 | 场景 |
|---|---|---|
| 基础鉴权 Token | 验证用户合法性 | 绝大多数场景够用 |
| 权限认证 Token | 额外校验房间 ID 和推流 ID | 会员房间控制、防「幽灵麦」、防作弊 |
Token04 采用 AES-GCM 加密,服务端用 ServerSecret 生成,客户端只负责携带和续期。整个链路是:服务端签发 → 客户端携带 → ZEGO 云端校验。
4.4 跨平台一致性
裸写 WebRTC 的天花板:WebRTC 只在浏览器里原生可用。iOS、Android 需要另外的技术栈,Flutter、Electron 需要 Plugin 桥接。
ZEGO 覆盖的平台:
Web | iOS | Android | Windows | macOS | Linux
Flutter | Electron | React Native | UniApp | Unity | Unreal
Cocos Creator | 小程序 | HarmonyOS
所有平台共用同一套 AppID,同一套房间逻辑,Web 和 Native 端可以在同一个房间里互通。Web 端底层走 WebRTC,Native 端走 ZEGO 自研协议栈。
4.5 质量监控
裸写 WebRTC 的盲区:chrome://webrtc-internals 只能看当前浏览器本地的统计数据,如丢包率、抖动、码率、帧率。没有历史数据、没有聚合报表、没有告警。
ZEGO 星图(Analytics Dashboard):
- 通话前:麦克风、摄像头、扬声器设备检测
- 通话中:实时网络质量和音视频质量数据透明可溯
- 通话后:全链路质量分析,地域节点洞察,并发容量监控
总结
- WebRTC 解决了浏览器之间能实时通信,但没怎么快速交付一个可信赖的音视频产品,包括信令、穿透、SFU、鉴权、弱网对抗、质量监控,这些全部要自己填。
- ZEGO Express SDK 的抽象层次是「房间 + 流」,而不是 PeerConnection,心智模型从「管理两端的连接」变成了「向云端推拉流」。核心 API 只有四步:
new ZegoExpressEngine → loginRoom → startPublishingStream → startPlayingStream - 三十行代码跑通 Demo 不等于做出了产品。ZEGO 让 Demo 到生产的距离,从需要一套基础设施团队变成了处理好事件回调和异常分支。
- 技术选型的本质是成本权衡。裸写 WebRTC 的学习成本低(文档丰富),但工程落地成本极高。用 ZEGO 的切换成本几乎为零(npm install + 几行代码),但每一行你都获得了信号层、传输层、运维层的保障。




