1. 前言
实时音视频已经从会议、直播、在线教育等垂直场景,逐渐变成业务系统中的基础能力。一个后台系统可能需要远程协助,一个客户服务系统可能需要视频客服,一个邀请链路可能需要让双方直接进入同一个音视频房间。
传统接入方式通常是:每个业务系统自己集成 TRTC Web SDK,自己处理 SDKAppID、UserSig、进房、设备权限、摄像头采集、远端订阅、异常清理和浏览器兼容。这样做能跑通,但很快会暴露几个问题:
- 业务代码与音视频 SDK 强耦合。
- 多个业务系统重复实现同一套 WebRTC 生命周期。
- SDK 升级、权限问题、浏览器策略变化需要多处维护。
- 邀请参数、房间参数、用户身份的生成规则难以统一。
本文基于一个 React + TRTC Web SDK v5 项目实践,讨论一种更适合多业务复用的方案:把 TRTC Web SDK 能力封装成可以被外部业务系统通过 URL 和 iframe 快速调用的音视频服务。
当前项目来源于腾讯云 TRTC Web SDK v5 quick-demo-react,但已经扩展出三个独立服务入口:
| 服务 | 路由 | 职责 |
|---|---|---|
| 邀请参数生成服务 | /#/invite-link | 生成 ownerUserId、guestUserId、roomId、inviteToken 和邀请链接 |
| 音视频发布服务 | /#/media-publisher | 采集当前浏览器摄像头、麦克风,并进入 TRTC 房间发布本地音视频 |
| 远端观看服务 | /#/media-viewer | 进入同一 TRTC 房间,订阅指定 targetUserId 的远端视频 |
这三个服务不是 README 式的项目介绍,而是一次服务化拆分:业务系统不再直接操作 TRTC SDK,而是组合这些稳定的页面服务。
2. WebRTC 与 TRTC 基础概念
对业务系统来说,实时音视频的核心模型可以简化为:用户以某个身份进入一个房间,其中一部分用户发布音视频,另一部分用户订阅并观看音视频。
Room
Room 是音视频通信的隔离空间。当前项目使用字符串房间 ID,TRTC 进房参数名为 strRoomId,对外 URL 参数统一使用 roomId,并兼容 strRoomId。
UserId
UserId 是用户进入 TRTC 房间的身份。当前服务化设计要求同一房间、同一时间内不同 iframe 使用不同的用户身份。例如发布者使用 host_1001,观看者 iframe 使用 viewer_guest_host_1001。
Publisher
Publisher 是发布端。当前项目的 /#/media-publisher 会用 userId 进入房间,并调用 startLocalAudio、startLocalVideo 启动麦克风和摄像头。
Viewer
Viewer 是观看端。当前项目的 /#/media-viewer 使用 viewerUserId 进入房间,不申请本机摄像头和麦克风权限,只订阅 targetUserId 的远端视频。
Media Stream
音视频流来自浏览器摄像头和麦克风,通过 TRTC Web SDK 进入腾讯云 TRTC 房间,再由远端订阅播放。
3. 整体架构设计
当前项目的服务化架构分成两层:
- React 应用内部负责 TRTC SDK 生命周期。
- 外部业务系统通过 iframe 和 postMessage 调用能力、接收状态。
应用入口关系如下:
src/App.tsx 中通过 SILENT_ROUTES 隐藏导航栏,/invite-link、/media-publisher、/media-viewer 都属于静默服务路由。这一点非常关键:服务入口不是给用户操作 Demo 控制台,而是给业务系统嵌入使用。
4. 为什么选择 iframe 服务化模式
把 TRTC 能力封装为 iframe 页面,本质上是在业务系统和 SDK 之间增加一层可复用的音视频服务边界。
它的优势是:
- 解耦业务系统和 TRTC SDK。业务系统只拼 URL,不直接引入
trtc-sdk-v5。 - 降低接入成本。外部系统不需要理解完整 WebRTC 生命周期。
- 统一维护。设备采集、进房、订阅、销毁、SDK 升级集中在一个模块里。
- 多业务复用。不同业务只传不同的
userId、roomId、mode。 - 页面隔离。iframe 内部状态和业务系统的 React/Vue/原生页面互不污染。
它也带来限制:
- iframe 使用摄像头和麦克风必须配置
allow。 - 父子页面通信依赖
postMessage,生产环境必须校验origin和source。 - 跨域部署时需要统一 HTTPS、权限策略和自动播放策略。
- 每个 iframe 都是独立 TRTC 用户,不能复用同一个
userId。
在当前项目里,iframe 不是简单展示页面,而是对外服务 API 的载体。
5. 三个 TRTC 服务设计
5.1 邀请参数生成服务
服务作用
/#/invite-link 不进入 TRTC 房间,只负责生成邀请链路所需的参数和网址。它解决的是“业务系统如何统一生成房间、房主、访客和 token 参数”的问题。
URL
https://trtc.example.com/#/invite-link?ownerUserId=host_1001&roomId=room_001&mode=business_a
参数
| 参数 | 必填 | 说明 |
|---|---|---|
ownerUserId | 是 | 房主用户 ID,兼容读取 userId |
roomId | 是 | TRTC 字符串房间 ID,兼容读取 strRoomId |
mode | 否 | 业务透传参数,默认 rtc,当前实现会转为小写 |
内部流程
接入示例
<iframe
src="https://trtc.example.com/#/invite-link?ownerUserId=host_1001&roomId=room_001&mode=business_a"
allow="clipboard-read; clipboard-write"
style="width: 100%; height: 260px; border: 0;"
></iframe>
注意事项
当前实现中 inviteToken 由前端使用 window.btoa(encodeURIComponent(JSON.stringify(tokenPayload))) 生成,有效期一小时。它适合联调和前端闭环验证;生产环境应改为后端生成并签名。
5.2 音视频发布服务
服务作用
/#/media-publisher 负责把当前浏览器设备上的摄像头和麦克风发布到 TRTC 房间。它不会订阅远端,也不会展示复杂操作 UI。
URL
https://trtc.example.com/#/media-publisher?userId=host_1001&roomId=room_001&mode=business_a
参数
| 参数 | 必填 | 说明 |
|---|---|---|
userId | 是 | 当前发布者进入 TRTC 房间使用的用户 ID |
roomId | 是 | TRTC 字符串房间 ID,兼容 strRoomId |
mode | 否 | 业务透传参数,默认 rtc |
cameraId | 否 | 指定摄像头设备 ID |
microphoneId | 否 | 指定麦克风设备 ID |
videoProfile | 否 | 视频规格,默认 1080p |
内部流程
接入示例
<iframe
src="https://trtc.example.com/#/media-publisher?userId=host_1001&roomId=room_001&mode=business_a"
allow="camera; microphone; autoplay; fullscreen"
style="width: 100%; height: 420px; border: 0; background: #000;"
></iframe>
注意事项
allow="camera; microphone; autoplay; fullscreen" 必须配置。否则浏览器可能禁止 iframe 访问摄像头、麦克风或自动播放媒体。
当前 media-publisher 没有暴露设备选择 UI,设备参数只能通过 URL 传入。
5.3 远端观看服务
服务作用
/#/media-viewer 负责使用观看者身份进入房间,等待并播放指定发布者的远端视频。
URL
https://trtc.example.com/#/media-viewer?viewerUserId=viewer_guest_host_1001&targetUserId=host_1001&roomId=room_001&mode=business_a
参数
| 参数 | 必填 | 说明 |
|---|---|---|
viewerUserId | 是 | 观看者进入 TRTC 房间使用的用户 ID,兼容读取 userId |
targetUserId | 是 | 需要观看的远端发布者 ID |
roomId | 是 | TRTC 字符串房间 ID,兼容 strRoomId |
mode | 否 | 业务透传参数,默认 rtc |
内部流程
接入示例
<iframe
src="https://trtc.example.com/#/media-viewer?viewerUserId=viewer_guest_host_1001&targetUserId=host_1001&roomId=room_001&mode=business_a"
allow="autoplay; fullscreen"
style="width: 100%; height: 420px; border: 0; background: #000;"
></iframe>
注意事项
viewer 不采集本机设备,因此不需要 camera 和 microphone 权限。当前实现会校验 viewerUserId !== targetUserId,同一个 iframe 不能既是观看者又是被观看目标。
6. 邀请参数服务详解
邀请参数服务的价值在于统一生成一组可流转的通话身份,而不是让业务系统临时拼接用户 ID。
src/utils/silentRouteParams.ts 中 createInviteLinkPayload() 会生成:
guestUserId:被邀请者发布音视频时使用。hostViewerUserId:房主观看被邀请者时使用。guestViewerUserId:被邀请者观看房主时使用。inviteToken:包含ownerUserId、guestUserId、roomId、mode、generatedAt、expiresAt。moduleParams:分别给房主 publisher、房主 viewer、访客 publisher、访客 viewer 使用的参数。
token 校验逻辑在 src/utils/hangwuInvite.ts:
export function getHangwuInviteErrors(params: HangwuInviteParams) {
const errors = getMissingHangwuInviteParams(params);
const token = decodeHangwuInviteToken(params.inviteToken);
if (params.inviteToken && !token) errors.push('inviteToken 无效');
if (token) {
if (token.ownerUserId !== params.ownerUserId || token.guestUserId !== params.guestUserId || token.roomId !== params.roomId || token.mode !== params.mode) {
errors.push('邀请参数与 inviteToken 不匹配');
}
if (!Number.isFinite(token.generatedAt) || !Number.isFinite(token.expiresAt) || token.expiresAt <= token.generatedAt) {
errors.push('inviteToken 有效期无效');
} else {
if (token.generatedAt > Date.now() + 60 * 1000) errors.push('inviteToken 生成时间无效');
if (token.expiresAt < Date.now()) errors.push('邀请链接已过期');
}
}
if (params.ownerUserId && params.ownerUserId === params.guestUserId) errors.push('ownerUserId 不能等于 guestUserId');
return errors;
}
这段逻辑说明邀请链路至少解决了四类问题:缺参、token 无效、参数被篡改、链接过期。
7. 音视频发布服务详解
发布服务的目标是把浏览器设备采集和 TRTC 发布链路封装成一个 URL。
发布端页面只有两个核心文件:
src/pages/MediaPublisherPage.tsx:解析参数,渲染本地视频容器和状态浮层。src/hooks/useMediaPublisher.ts:创建 TRTC 实例,进入房间,启动音频和视频。
页面组件非常薄:
export default function MediaPublisherPage() {
const params = useMemo(() => getMediaPublisherParams(), []);
const { status, error, localVideoRef } = useMediaPublisher(params);
const pending = status !== 'publishing' && status !== 'failed' && status !== 'destroyed';
return (
<div className="silent-media-page">
<div className="silent-media-video" ref={localVideoRef} />
{pending && <div className="silent-media-overlay">连接中...</div>}
{status === 'destroyed' && <div className="silent-media-overlay">已退出</div>}
{error && <div className="silent-media-error">{error}</div>}
</div>
);
}
真正的生命周期在 hook 中:
const trtc = TRTC.create();
await trtc.enterRoom({
sdkAppId: currentParams.sdkAppId,
userId: currentParams.userId,
userSig,
strRoomId: currentParams.roomId,
});
await trtc.startLocalAudio({
option: {
microphoneId: currentParams.microphoneId || undefined,
},
});
await trtc.startLocalVideo({
view: localVideoRef.current,
option: {
cameraId: currentParams.cameraId || undefined,
profile: (currentParams.videoProfile || '1080p') as any,
},
});
这里有一个实践细节:当前 TRTC Web SDK v5 封装中,发布端没有单独暴露 publish() 代码路径,而是在进房后启动本地音频和视频。博客或接入文档中如果泛泛写“创建 LocalStream 后 publish”,就和这个实现不一致。
iframe 示例:
<iframe
src="https://trtc.example.com/#/media-publisher?userId=host_1001&roomId=room_001&mode=business_a&videoProfile=1080p"
allow="camera; microphone; autoplay; fullscreen"
style="width: 100%; height: 420px; border: 0; background: #000;"
></iframe>
allow 的意义不是样式配置,而是浏览器权限声明。发布端需要摄像头和麦克风,所以必须包含 camera 和 microphone。
8. 远端观看服务详解
观看服务的关键是 targetUserId。它把“我是谁”和“我要看谁”分离开。
src/hooks/useMediaViewer.ts 的过滤逻辑非常明确:
trtc.on(TRTC.EVENT.REMOTE_VIDEO_AVAILABLE, ({ userId, streamType }: any) => {
if (String(userId) !== String(paramsRef.current.targetUserId)) {
return;
}
window.requestAnimationFrame(() => {
startTargetRemoteVideo(userId, streamType);
});
});
也就是说,即使房间里有多个远端用户,当前 viewer 也只会播放 targetUserId 对应的远端视频。
当目标用户下线或停止视频时,观看端会处理 REMOTE_VIDEO_UNAVAILABLE:
trtc.on(TRTC.EVENT.REMOTE_VIDEO_UNAVAILABLE, ({ userId, streamType }: any) => {
if (String(userId) !== String(paramsRef.current.targetUserId)) {
return;
}
startedRemoteVideoRef.current = false;
clearRemoteVideoContainer();
trtc.stopRemoteVideo({ userId, streamType });
notifyStatus('targetOffline');
});
这使 viewer 可以先进入房间并等待发布端上线。页面状态为 waitingTarget 或 targetOffline 时,会显示“等待远端画面”。
9. 外部业务完整接入流程
假设第三方业务系统要做一条双方音视频通话链路,可以这样组合三个服务:
通用双 iframe 组合方式如下。
房主端:
<iframe
src="https://trtc.example.com/#/media-publisher?userId=host_1001&roomId=room_001&mode=business_a"
allow="camera; microphone; autoplay; fullscreen"
></iframe>
<iframe
src="https://trtc.example.com/#/media-viewer?viewerUserId=viewer_host_guest_2001&targetUserId=guest_2001&roomId=room_001&mode=business_a"
allow="autoplay; fullscreen"
></iframe>
被邀请者端:
<iframe
src="https://trtc.example.com/#/media-viewer?viewerUserId=viewer_guest_host_1001&targetUserId=host_1001&roomId=room_001&mode=business_a"
allow="autoplay; fullscreen"
></iframe>
<iframe
src="https://trtc.example.com/#/media-publisher?userId=guest_2001&roomId=room_001&mode=business_a"
allow="camera; microphone; autoplay; fullscreen"
></iframe>
当前项目还提供了 /#/media-module-demo,用于测试邀请参数、房主端组合和被邀请者端组合。
10. iframe 跨页面通信设计
iframe 接入后,父页面需要知道子模块是否连接成功、是否正在发布、是否失败、是否已经退出。当前项目用 postMessage 实现跨页面通信。
状态通知
状态通知函数在 src/utils/postMessage.ts:
export function postTrtcModuleStatus(payload: Omit<TrtcModuleStatusPayload, 'type'>) {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'TRTC_MODULE_STATUS', ...payload }, '*');
}
}
publisher 状态示例:
{
"type": "TRTC_MODULE_STATUS",
"page": "media-publisher",
"status": "publishing",
"userId": "host_1001",
"roomId": "room_001",
"mode": "business_a"
}
viewer 状态示例:
{
"type": "TRTC_MODULE_STATUS",
"page": "media-viewer",
"status": "viewing",
"userId": "viewer_guest_host_1001",
"roomId": "room_001",
"mode": "business_a",
"targetUserId": "host_1001"
}
publisher 状态机来自 src/types/silentRoom.ts:
idle -> resolvingParams -> creating -> entering -> startingDevice -> publishing
failed
leaving -> destroyed
viewer 状态机:
idle -> resolvingParams -> creating -> entering -> waitingTarget -> viewing
targetOffline
failed
destroyed
命令控制
父页面可以向 iframe 发送命令:
const iframe = document.querySelector('#hostPublisherIframe');
iframe.contentWindow.postMessage({
type: 'TRTC_MODULE_COMMAND',
command: 'leave'
}, '*');
命令订阅逻辑如下:
export function subscribeTrtcModuleCommand(onCommand: (payload: TrtcModuleCommandPayload) => void) {
const handleMessage = (event: MessageEvent) => {
const data = event.data;
if (!data || typeof data !== 'object') return;
if (data.type !== 'TRTC_MODULE_COMMAND') return;
onCommand(data as TrtcModuleCommandPayload);
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}
src/types/silentRoom.ts 预定义了 restart、muteAudio、unmuteAudio、muteVideo、unmuteVideo、switchCamera、switchMicrophone 等命令类型,但当前 useMediaPublisher 和 useMediaViewer 实际只处理 leave。
生产环境中,父页面监听消息时不应直接信任 event.data,还要校验:
event.origin是否为 TRTC 模块服务域名。event.source是否为当前 iframe 的contentWindow。- 消息里的
userId、roomId、targetUserId是否与业务系统记录一致。
11. React 内部架构映射
当前项目没有 services/ 目录,也没有额外的 React Context / Provider。服务化能力主要分布在:
| 层级 | 文件 | 职责 |
|---|---|---|
| 入口 | src/main.tsx | 初始化 Aegis、加载 i18n、渲染 App |
| 路由 | src/App.tsx | 使用 HashRouter 注册页面路由,静默服务路由隐藏 NavBar |
| 页面 | src/pages/InviteLinkPage.tsx | 生成邀请链接和 TRTC_INVITE_LINK_READY |
| 页面 | src/pages/MediaPublisherPage.tsx | 渲染本地视频容器、连接状态和错误 |
| 页面 | src/pages/MediaViewerPage.tsx | 渲染远端视频容器、等待状态和错误 |
| Hook | src/hooks/useMediaPublisher.ts | 发布端 TRTC 生命周期 |
| Hook | src/hooks/useMediaViewer.ts | 观看端 TRTC 生命周期 |
| Utils | src/utils/silentRouteParams.ts | 服务路由 URL 参数解析、缺参校验、邀请 payload 生成 |
| Utils | src/utils/postMessage.ts | iframe 状态通知和命令订阅 |
| Utils | src/utils/generateTestUserSig.ts | 浏览器端测试 UserSig 生成 |
| Types | src/types/silentRoom.ts | 服务页面、状态、命令和参数类型 |
主 Demo 页仍保留在 HomePage + useTRTC + Zustand 架构中,支持手动进房、设备选择、屏幕共享、日志和邀请链接。服务化页面没有复用主页面 UI,而是用更薄的页面加专用 hook,降低 iframe 使用时的干扰。
清理逻辑也集中在 hook 中。发布端卸载时会:
stopLocalVideo
stopLocalAudio
exitRoom
off('*')
destroy
清空本地视频容器
观看端卸载时会:
stopRemoteVideo({ userId: targetUserId })
exitRoom
off('*')
destroy
清空远端视频容器
这类清理对 iframe 模式尤其重要,因为父页面移除 iframe 时,子页面必须释放摄像头、麦克风和 TRTC 连接。
12. 浏览器安全限制
HTTPS
摄像头、麦克风和 WebRTC 依赖浏览器安全上下文。当前 README 和部署文档都明确提醒:生产环境或跨设备访问应使用 HTTPS。否则可能出现 navigator.mediaDevices 不可用、摄像头无法打开或 iframe 权限被拦截。
localhost
本地开发时,localhost 和 127.0.0.1 通常被浏览器视为可用于调试的安全环境。当前 vite.config.ts 会从 5173 开始寻找可用端口,并监听 0.0.0.0,便于本机和局域网调试。
需要注意:局域网 IP + HTTP 通常不是安全上下文,不能等同于 localhost。
iframe 权限
发布端 iframe 必须包含:
camera; microphone; autoplay; fullscreen
观看端 iframe 至少应包含:
autoplay; fullscreen
发布端需要摄像头和麦克风;观看端不采集本机设备,但仍可能受自动播放策略影响。
浏览器兼容
当前 HomePage 调用了 TRTC.isSupported(),如果不支持会提示使用最新版本 Chrome。静默路由没有单独做这层拦截,因此外部业务系统接入时建议在父页面或测试流程中补充浏览器检查。
实际生产中至少要覆盖:
- Chrome:当前项目明确提示的推荐浏览器。
- Edge:基于 Chromium 的 Edge 通常与 Chrome 接近,但仍应实际验证设备权限和自动播放。
- Safari:需要重点验证 getUserMedia、iframe 权限和自动播放策略。
13. 部署注意事项
当前项目使用 Vite 构建,package.json 中脚本为:
{
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
}
Dockerfile 采用两阶段构建:
部署时需要注意:
VITE_TRTC_SDK_APP_ID和VITE_TRTC_SDK_SECRET_KEY是构建期注入。- 修改
.env后,开发环境要重启 Vite,Docker 生产环境要重新构建镜像。 nginx.conf使用try_files $uri $uri/ /index.html;,适配前端路由。- 当前 Docker Compose 将容器
80端口映射到宿主机18080。 - 生产环境必须使用 HTTPS 域名暴露服务,例如
https://trtc.example.com/。
14. TRTC 安全设计
当前项目的 UserSig 生成逻辑在浏览器端:
const generator = new (window as any).LibGenerateTestUserSig(sdkAppId, sdkSecretKey, EXPIRETIME);
const userSig = generator.genTestUserSig(userId);
public/lib-generate-test-usersig.min.js 提供了测试用 UserSig 生成能力。README 中也明确说明:这只适合调试,生产环境必须迁移到后端。
推荐生产架构如下:
生产化改造建议:
- 前端不保存
SDKSecretKey。 inviteToken由后端签发,绑定roomId、ownerUserId、guestUserId、有效期和权限。UserSig由后端按用户身份按需签发。- postMessage 的
origin、source和参数一致性必须校验。 - 邀请链接中的敏感参数应控制有效期,并支持服务端失效。
15. 常见问题
摄像头打不开
优先检查:
- 当前页面是否是 HTTPS、
localhost或127.0.0.1。 - iframe 是否配置
allow="camera; microphone; autoplay; fullscreen"。 - 浏览器是否已授权摄像头和麦克风。
- 摄像头是否被其他程序占用。
VITE_TRTC_SDK_APP_ID和VITE_TRTC_SDK_SECRET_KEY是否在构建产物中正确注入。
viewer 黑屏
优先检查:
- publisher 和 viewer 是否使用相同
roomId。 targetUserId是否等于发布端的userId。- 发布端是否已经进入
publishing状态。 - viewer 是否一直处于
waitingTarget或targetOffline。 viewerUserId是否与targetUserId重复。
iframe 无声音
优先检查:
- iframe 是否包含
allow="autoplay; fullscreen"。 - 浏览器是否因为自动播放策略阻止了音频。
- 观看端是否真正收到
REMOTE_VIDEO_AVAILABLE并进入viewing。 - 发布端麦克风权限是否成功授权。
多窗口冲突
TRTC 用户身份必须唯一。同一个页面中每个 iframe 都应使用不同的 userId 或 viewerUserId:
hostUserId != guestUserId
viewerUserId != targetUserId
同一页面中的每个 iframe 都使用不同身份
双方使用同一个 roomId
缺少 VITE_TRTC_SDK_APP_ID / VITE_TRTC_SDK_SECRET_KEY
这不是外部业务 URL 参数缺失,而是 TRTC 模块自身构建产物缺少 SDK 凭据。开发环境检查 .env 并重启 Vite;Docker 环境通过 build args 重新构建镜像。
16. 总结
这次实践的核心不是“写了三个页面”,而是把 TRTC Web SDK 的复杂生命周期转换成三个稳定的服务入口:
/#/invite-link负责邀请参数和链路生成。/#/media-publisher负责本地设备采集和发布。/#/media-viewer负责指定远端用户订阅和播放。
业务系统通过 iframe 组合这些入口,就能快速获得音视频能力,而不用在每个系统里重复实现 TRTC 初始化、进房、设备启动、事件监听和资源释放。
这类服务化封装适合被多个业务系统复用:音视频模块集中演进,业务系统只关注用户、房间、邀请链路和页面编排。对于企业内部多业务接入实时音视频,这是比“每个系统集成一次 SDK”更可维护的架构。

547

被折叠的 条评论
为什么被折叠?



