当业务需要一个"打开即用"的实时视频沟通能力时,H5 往往是最快的那条路:微信内嵌网页直接发起视频问诊、WebView 内嵌到现有 App 提供视频客服、活动页一键进房连麦;不用过审、不用装包、一套代码通吃所有移动端。
但 H5 做视频通话和原生 App 是两套完全不同的工程问题:浏览器要 HTTPS 才给摄像头权限、移动端 Safari 默认禁掉带声音的自动播放、同一分辨率在 PC 是横屏在手机上是竖屏、微信内嵌网页对 H.264 编码支持差……这篇文章基于ZEGO Express Web SDK,把 H5 移动端视频通话从环境准备、最小可用代码到移动端专属的坑,一次讲完。

一、为什么选 H5?
适合 H5 的场景:低频但刚需的临时通话(在线问诊、视频客服、远程验机)、需要微信/App 内嵌的轻量沟通、业务链路以网页为主的场景。
不适合的场景:高频长时通话(通话 30 分钟以上)、需要后台保活/系统级来电通知,这些请用原生 App 或小程序。
H5 视频通话的技术底座是 WebRTC,浏览器原生支持,无需安装任何插件。ZEGO Express Web SDK 在其上封装了房间管理、信令、弱网对抗、跨端互通(H5 可直接与 iOS/Android 原生 App 通话),延迟在 200ms 量级,体验接近原生。
二、准备工作
2.1 HTTPS
浏览器规定,摄像头和麦克风(getUserMedia)只能在安全上下文调用:https://、localhost、127.0.0.1。
开发期用 localhost 调试没问题;一旦上线,必须是 HTTPS,否则采集直接失败。这是 H5 视频通话最常见的"为什么我本地能跑、线上黑屏"原因。
2.2 浏览器兼容性:先检测再进入房间
官方支持:Chrome 58+、Safari 11+、Firefox 56+、Opera 45+ 以及 QQ 浏览器、360 极速模式等。
移动端 H5 和桌面 Web 的关键差异在编码格式:
| 编码 | 优势 | 移动端劣势 |
|---|---|---|
| H.264 | 配套成熟,可直接转推 CDN、直接与小程序互通 | 移动端浏览器支持度差,微信浏览器、WebView 尤其明显 |
| VP8 | 移动端浏览器兼容性更好,微信浏览器/WebView 兼容性优于 H.264 | 不可直接转推 CDN、不可直接与小程序互通 |
另一个细节:Safari 12.1 及以下版本只支持 H.264,Firefox 帧率只支持 30fps。上线前用 SDK 提供的兼容性检测接口,不满足的话需要给用户提示:
import { ZegoExpressEngine } from "zego-express-engine-webrtc";
// 创建引擎实例(AppID 和 Server 地址从 ZEGO 控制台获取)
const zg = new ZegoExpressEngine(appID, server);
const res = zg.checkSystemRequirements();
if (!res.webRTC) {
// 浏览器不支持 WebRTC,提示用户更换浏览器
alert("当前浏览器不支持视频通话,请使用 Chrome/Safari 等现代浏览器");
}
2.3 申请AppID 和 Server 地址
在ZEGO 控制台创建项目,申请有效的 AppID 和 Server 地址,详情请参考控制台 – 项目管理中的“项目信息”。
三、五分钟跑通:最小视频通话
核心就四步:创建引擎 → 登录房间 → 推自己的流 → 拉对方的流。所有 API 都基于同一个"房间"概念:同一房间内的用户可以互相收发音视频,登录房间需要 Token(见第四章)。
先安装 SDK:
npm install zego-express-engine-webrtc
3.1 创建引擎 + 登录房间
import { ZegoExpressEngine } from "zego-express-engine-webrtc";
// 项目唯一标识,Number 类型,从 ZEGO 控制台获取
const appID = 1234567890;
// 接入服务器地址,3.7.0 及以上版本可直接填空字符串
const server = "";
// 用户 ID,同一房间内需要唯一
const userID = "user_" + new Date().getTime();
const roomID = "room_1001";
// 从自己的服务端获取的 Token(见第四章)
const token = await fetch("/api/zego/token?userId=" + userID).then(r => r.text());
const zg = new ZegoExpressEngine(appID, server);
// 登录房间;userUpdate: true 表示关注房间内用户变化
const isLogin = await zg.loginRoom(roomID, token, { userID, userName: userID }, { userUpdate: true });
if (isLogin) {
console.log("login success");
}
注意:ZegoExpressEngine 实例不能被框架以响应式方式处理。在 Vue3 中需用 markRaw(zg) 标记,避免 SDK 实例被转为代理导致不可预测的问题。
3.2 注册关键回调
创建引擎后立即注册回调,避免错过事件通知:
// 房间状态变化:登录中/已登录/登录失败/重连中/重连成功/被踢出等
zg.on("roomStateChanged", (roomID, reason, errorCode, extendedData) => {
if (reason === "LOGINED") {
// 登录成功,此时才能推拉流
} else if (reason === "KICKOUT") {
// 被踢出房间
}
});
// 房间内用户进出通知(需登录时设置 userUpdate: true)
zg.on("roomUserUpdate", (roomID, updateType, userList) => {
// updateType: "ADD" 用户加入 / "DELETE" 用户退出
});
// 房间内流变化:对方的流出现/消失,这是"知道该拉谁的流"的信号
zg.on("roomStreamUpdate", async (roomID, updateType, streamList) => {
if (updateType === "ADD") {
// 有新的流,开始拉流
for (const stream of streamList) {
await zg.startPlayingStream(stream.streamID);
}
} else if (updateType === "DELETE") {
// 流消失,停止拉流
for (const stream of streamList) {
zg.stopPlayingStream(stream.streamID);
}
}
});
3.3 创建本地流并推流
// 创建本地流(默认采集摄像头 + 麦克风,高清格式)
const localStream = await zg.createZegoStream();
// 本地预览:把画面挂到页面上
localStream.playVideo(document.querySelector("#local-video"));
// 开始推流;streamID 自行生成,但需保证同一 AppID 下全局唯一
const streamID = "stream_" + userID;
zg.startPublishingStream(streamID, localStream);
想设置采集参数(分辨率、前后摄像头、纯音频等),在 createZegoStream 里配置:
const localStream = await zg.createZegoStream({
camera: {
video: { quality: "ultra", facingMode: "environment" }, // environment = 后置
audio: true,
},
});
3.4 拉流播放
对方推流后,roomStreamUpdate 回调会带出对方的 streamID,用它对端拉流并渲染:
const remoteStream = await zg.startPlayingStream(remoteStreamID);
const remoteView = zg.createRemoteStreamView(remoteStream);
remoteView.play(document.querySelector("#remote-video"));
3.5 退出清理
zg.stopPublishingStream(streamID); // 停止推流
zg.logoutRoom(roomID); // 退出房间
zg.destroyEngine(); // 销毁引擎,释放摄像头/麦克风等资源
至此,一个能通话的最小 Demo 就完成了。两个终端分别以不同 userID 登录同一 roomID,互推互拉即可通话。
四、服务端配合:Token 鉴权不能省
为什么必须有 Token:它决定了"谁能进你的房间"。没有鉴权,任何人拿到你的 AppID 就能进房间看画面,这在业务场景里是不可接受的。Token 由服务端生成,客户端每次从自己的后端接口获取;Server Secret 是最高机密,绝不能出现在前端代码里。
服务端核心逻辑(Node.js 示例,官方 Server Assistant 提供的 token04 算法,基于 AES-256-GCM):
import { createCipheriv, randomBytes } from "crypto";
// appId: 与客户端一致;userId: 当前用户;secret: 控制台获取的 Server Secret(32 字节)
// effectiveTimeInSeconds: Token 有效期,默认 3600 秒
// payload: 可选,权限扩展信息
function generateToken04(appId, userId, secret, effectiveTimeInSeconds, payload = "") {
const version = "04";
const createTime = Math.floor(Date.now() / 1000);
const tokenInfo = {
app_id: appId,
user_id: userId,
nonce: Math.floor(Math.random() * 2 ** 32), // 随机数,与官方示例一致即可
ctime: createTime,
expire: createTime + effectiveTimeInSeconds,
payload,
};
// 用 secret 加密 JSON,得到密文 + 认证标签 + 随机 nonce
const nonce = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", secret, nonce);
const encrypted = cipher.update(JSON.stringify(tokenInfo), "utf8");
const encryptBuf = Buffer.concat([encrypted, cipher.final(), cipher.getAuthTag()]);
// 按固定格式拼接并 Base64 编码
const header = Buffer.alloc(13);
header.writeBigInt64BE(BigInt(tokenInfo.expire), 0); // 过期时间
header.writeUInt16BE(nonce.length, 8); // nonce 长度
header.writeUInt16BE(encryptBuf.length, 10); // 密文长度
header[12] = 1; // 加密模式 GCM
return version + Buffer.concat([header, nonce, encryptBuf]).toString("base64");
}
服务端只需暴露一个接口,客户端调用即可:
const token = await fetch("/api/zego/token?userId=" + userID).then(r => r.text());
五、移动端 H5 的专属优化
到这里 Demo 已经能跑,但真机体验差。以下问题只出现在移动端 H5,桌面 Web 基本遇不到。
5.1 前后摄像头切换:三种方案,推荐第 1 种
直接调 useVideoDevice 在部分机型上会黑屏或切换失败。官方给出三种方案:
方案一:facingMode 指定朝向(推荐,SDK 3.2.0+)
创建流时用 facingMode 指定摄像头朝向,用 useFrontCamera 切换:
// "user" 前置 / "environment" 后置
const localStream = await zg.createZegoStream({
camera: { video: { facingMode: "user" } },
});
// 切换为后置
await zg.useFrontCamera(localStream, false);
// 切换为前置
await zg.useFrontCamera(localStream, true);
方案二:facingMode + 重建流(兼容所有版本)
部分机型(如荣耀 10)不支持同时打开两个摄像头。切换前必须先 stop 旧视轨,再创建新流,用 replaceTrack 替换:
// 1. 停掉当前视轨
const tracks = localStream.getVideoTracks();
tracks.forEach(track => track.stop());
// 2. 创建后置摄像头流
const backStream = await zg.createZegoStream({
camera: { video: { facingMode: "environment" } },
});
// 3. 替换视轨并同步更新推流
const videoTrack = backStream.getVideoTracks()[0];
await zg.replaceTrack(localStream, videoTrack);
5.2 竖屏适配:移动端和 PC 对"宽高"的理解相反
这是最反直觉的一条:同样的分辨率,PC 上是横屏,移动端就是竖屏。移动端 H5 默认就是竖屏采集,不用(也不能)按桌面逻辑去设宽高。
另外,自定义分辨率时宽高建议设为 8 的倍数。部分设备的摄像头只能以特定分辨率采集,浏览器自动调整时可能黑屏,8 的倍数能规避大部分此类问题。
5.3 自动播放策略:最常见的"有画面没声音"
浏览器的规则:用户交互之前,不允许带声音的媒体自动播放。远端流带音频,不处理就直接播放,iOS Safari 和部分 Android 浏览器会静音播放失败。
SDK 的 ZegoStreamView 播放组件内置了处理:自动播放失败时弹出引导框,用户点击后恢复播放。你也可以在业务层自行处理——捕获播放失败后引导用户点击页面。
5.4 Safari 多视频限制:iOS 14 以下的真坑
旧版 Safari 同一时间只允许播放一个带音频的视频:你正在看对方视频,对方又推了一路流,第二路会被浏览器强制暂停。绕过方法(iOS 14.0+ 已无此限制):
- 加载时所有 video 元素静音(
muted属性)并播放 - 用户产生交互(如点击"开启声音"按钮)后,统一取消静音
5.5 三个小优化
- 保持屏幕常亮:通话中防止息屏,播放器插件支持
keepScreenOn配置 - 首帧事件做加载态:监听本地流/远端流的
canPlayVideo事件,首帧前显示 loading,避免用户对着黑屏误以为坏了 - 弱网控制:推流码率默认缓慢上升即可,不要设
target模式,弱网下快速抬码率会造成卡顿花屏
FAQ 速查
Q1:本地能跑,真机黑屏? 先确认线上是 HTTPS;再确认分辨率是 8 的倍数;最后检查摄像头权限是否被浏览器拦截。
Q2:有画面没声音? 绝大多数是自动播放策略问题。按 5.3 处理,或改用 SDK 的 ZegoStreamView 组件。
Q3:切换摄像头失败/黑屏? 部分机型不支持同时开两个摄像头。先 stop 旧视轨再切换,见 5.1 方案二。
Q4:微信内嵌网页卡顿/不兼容? 换 VP8 编码试试(startPublishingStream 时指定 videoCodec: "VP8");微信内嵌网页对 H.264 支持差是已知问题。
参考文档




