在React中使用WebRTC实现实时视频播放的完整流程
作者:Cxiaomu
一、简介
React 接入实时视频,最核心的问题在于:
前端怎样在浏览器里拿到一段持续到来的媒体流,并稳定地显示在页面上。
如果用 WebRTC 来做,这条链路通常会拆成几步:
- React 页面触发连接
- 浏览器创建
RTCPeerConnection - 前端发起 Offer
- 服务端返回 Answer
- 浏览器收到远端视频流
- 把
MediaStream绑定到<video>
本文章的重点在于通过几段真实代码示例,把 WebRTC 在 React 里如何工作的描述清楚。
文中会借几类代码片段来解释概念:
- 页面入口
- 视频容器
- 播放组件
- WebRTC 封装
- 会话管理
二、什么是WebRTC
WebRTC 是浏览器原生提供的实时音视频通信能力。
它的价值在于:
- 浏览器本身就能建立实时媒体连接
- 前端不用额外安装播放器
- 收到流之后可以直接交给
<video>播放
所以当前端接入 WebRTC,关注点通常不是“怎么解码视频”,而是:
- 怎么建立连接
- 怎么完成协商
- 怎么接收流
- 怎么在 React 生命周期里管理这条连接

三、整体架构
把 React 接入 WebRTC 拆开看,通常会分成四层:
- 页面层
- 决定什么时候发起连接
- 决定展示哪一路视频
- 状态层
- 保存当前连接状态
- 保存当前选中的视频流
- WebRTC 层
- 创建
RTCPeerConnection - 完成 Offer / Answer 协商
- 接收远端轨道
- 渲染层
- 把
MediaStream绑定给<video> - 处理播放、暂停、静音和销毁
如果把 WebRTC 放进 React 页面里理解,通常会落成下面几类角色:
- 页面入口
VideoPage->VideoLayout - 页面主体
VideoStage->MatrixView/SingleView - 状态管理
videoStore - 会话复用
liveSessionPool - WebRTC 客户端
webrtcClient - API 请求
videoApi
可以先把这条链路理解成:

这里需要注意:
- 状态接口会返回
rtsp_source字段,前端会读取它 - 但服务端内部到底如何把 RTSP 变成 WebRTC,不在本次范围内
所以更适合的理解方式是:
React 页面通过 WebRTC 从视频服务拉流播放,状态接口会补充媒体来源和运行状态。
四、WebRTC核心流程
React 接入 WebRTC,核心还是那条经典链路:
创建连接 -> 创建 Offer -> SDP 协商 -> 设置 Answer -> 收流 -> 播放
1. 创建 RTCPeerConnection
连接初始化在 createCameraWebRtcSession 里完成:
const pc = new RTCPeerConnection({ iceServers: options.iceServers ?? [] });
pc.oniceconnectionstatechange = () => {
console.info(
`[webrtc] camera=${options.cameraId} ice=${pc.iceConnectionState}`,
);
};
pc.addTransceiver("video", { direction: "recvonly" });
这一段代码,本质上是在处理 WebRTC 连接的起点:
- 创建
RTCPeerConnection - 配置
iceServers - 声明当前连接只接收视频
这里用了 addTransceiver("video", { direction: "recvonly" }),说明前端的角色是“播放器”,不是“推流端”。
浏览器在这条链路里是接收端。
2. 创建 Offer
接下来是 WebRTC 里最关键的第一轮协商:
const offer = await pc.createOffer(); await pc.setLocalDescription(offer);
可以把这两句理解成:
createOffer():告诉浏览器“把当前这条连接需要协商的内容生成出来”setLocalDescription():把这份协商信息登记为本地描述
从概念上说,这一步就是浏览器先把“我能怎么连”告诉对端。
3. SDP 协商
Offer 生成后,就进入 SDP 协商。
const answer = await startWebRtc(options.cameraId, {
connection_id: connectionId,
sdp: offer.sdp ?? "",
type: "offer",
});
startWebRtc 的请求封装是一个普通的 API 方法:
export async function startWebRtc(
cameraId: string,
payload: CameraWebRtcOfferPayload,
): Promise<CameraWebRtcAnswer> {
return requestTsVideo<CameraWebRtcAnswer>(`/${cameraId}/webrtc`, {
method: "POST",
body: JSON.stringify(payload),
});
}
其实是:
- 用 HTTP
POST /webrtc发送 Offer - 由服务端返回 Answer
也就是说,WebRTC 本身负责媒体连接,信令只是“把 Offer 和 Answer 交换出去”。这里选用的是 HTTP,而不是 WebSocket。
4. 获取 Answer
Offer 和 Answer 的结构在类型定义里写得很清楚:
export interface CameraWebRtcOfferPayload {
connection_id: string;
sdp: string;
type: "offer";
}
export interface CameraWebRtcAnswer {
connection_id: string;
sdp: string;
type: "answer";
}
对于理解 WebRTC 来说,这里最重要的是:
- Offer 里带
connection_id - Offer 里带
sdp type明确是"offer"- 服务端返回的就是同结构的 Answer
5. 建立连接
拿到 Answer 后,浏览器才真正知道“对端接受了什么协商结果”:
await pc.setRemoteDescription(normalizeAnswer(answer));
normalizeAnswer 的作用也很直接:
function normalizeAnswer(answer: CameraWebRtcAnswer): RTCSessionDescriptionInit {
return {
type: answer.type,
sdp: answer.sdp,
};
}
这一部分代码对应的,就是 WebRTC 协商闭环的完成。
6. 接收视频流
真正进入“播放视频”阶段,是从 pc.ontrack 开始的:
pc.ontrack = (event) => {
const stream =
event.streams[0] ??
(event.track ? new MediaStream([event.track]) : null);
if (stream) {
options.onStream(stream);
}
};
这就是理解 WebRTC 最关键的一条分界线:
- 协商之前,前端还只是在建连接
ontrack触发之后,前端才真正拿到了可播放的媒体流
这里的处理方式很实用:
- 优先取
event.streams[0] - 如果没有,就用
event.track手动包装成MediaStream
7. 渲染 Video
拿到流之后,还差浏览器播放链路里的最后一步:把流交给 <video>。
先看会话池怎么把流往外传:
const session = await createCameraWebRtcSession({
cameraId: entry.cameraId,
iceServers: getTsIceServers(),
onStream: (stream) => {
entry.stream = stream;
clearStreamWaitTimer(entry);
notify(entry);
},
});
然后组件侧订阅这条流:
const unsubscribe = subscribeSharedCameraStream(
runtimeCameraId,
(snapshot: SharedCameraStreamSnapshot) => {
setStream(snapshot.stream);
if (snapshot.status === "idle" && !snapshot.connectionId) {
clearWebRtcSessionState(runtimeCameraId);
return;
}
setWebRtcSessionState(runtimeCameraId, {
connectionId: snapshot.connectionId,
status: snapshot.status,
});
},
);
最后由 CameraVideoPlayer 绑定给 <video>:
if (stream) {
video.muted = muted;
if (video.srcObject !== stream) {
video.srcObject = stream;
video.removeAttribute("src");
}
}
如果把这一整段 WebRTC 过程压缩成一行,就是:
RTCPeerConnection → createOffer() → setLocalDescription() → POST /webrtc → answer → setRemoteDescription() → ontrack → MediaStream → video.srcObject = stream
五、React里怎么落地 WebRTC
WebRTC 解决的是“连起来”,React 解决的是“把连接接进页面生命周期里”。
页面组件
页面入口非常轻:
export default function VideoPage() {
return <VideoLayout />;
}
真正负责初始化的是 VideoLayout:
useEffect(() => {
if (!initialized) {
void fetchData();
return;
}
resumeLiveSessions();
}, [fetchData, initialized, resumeLiveSessions]);
useEffect(() => () => releaseAllPrewarmSessions(), []);
React 接实时连接时的处理基本链路:
- 首次进入页面时初始化数据
- 页面恢复时恢复会话
- 页面卸载时释放资源
Hook
这里没有单独抽出一个 useWebRtc Hook。
更贴近 React 思维的做法,是把“订阅流”封装在离视频最近的组件里。
这个内部 Hook 做了四件事:
- 订阅共享流
- 把流存到组件状态
- 把连接状态同步到全局 store
- 在组件卸载时取消订阅
这类设计的价值在于:视频流本身就和组件是否挂载强相关。
Service层
从 React 接 WebRTC 的角度看,最容易讲清楚的拆法是两层:
- API Service
- 一个独立的 API 封装层
负责:
startWebRtc()closeWebRtc()fetchCameraStatus()fetchCameraVideos()
- WebRTC Service
webrtcClientliveSessionPool
负责:
- 创建
RTCPeerConnection - 完成 Offer / Answer 协商
- 把流分发给页面
- 管理共享连接、重试和销毁
这样拆,不是为了“结构漂亮”,而是为了让两类问题分开:
- API 层只关心请求
- WebRTC 层只关心连接
- React 组件只关心显示
视频组件
视频部分再往下看,也可以拆成两层:
VideoTileCard
- 判断当前走实时流还是回放地址
- 维护与会话池的订阅关系
- 把视频数据交给播放器
CameraVideoPlayer
- 直接操作
<video> - 绑定
srcObject - 控制
play、pause、muted - 回传播放进度
这种拆法对应的其实是 React 里很经典的职责分工:
- 上层组件负责业务状态
- 下层组件负责 DOM 和媒体元素
生命周期
把 React 生命周期和 WebRTC 生命周期一一对上,整条链路就会很好理解:
VideoLayout触发fetchData()fetchData()会预热会话,调用prewarmCameraStreams()VideoTileCard挂载后开始订阅共享流- 收到
MediaStream后,CameraVideoPlayer绑定到<video> - 组件卸载后取消订阅;没有消费者时,会话池再延迟关闭连接
如果从“页面如何托住一条 WebRTC 连接”这个角度看,关系可以画成这样:

六、视频显示原理
如果只挑一句最能代表“浏览器开始播放 WebRTC 视频”的代码,就是:
video.srcObject = stream;
这句代码出现在播放器组件里。
为什么这样就能播放?
因为:
stream是浏览器已经收到的MediaStream<video>支持直接把MediaStream作为数据源- 一旦
srcObject指向它,浏览器就会开始解码并渲染轨道内容
这个组件还补了几层细节处理:
- 同步
muted - 在
loadedmetadata、canplay、playing时尝试play() - 每 800ms 检查一次,避免视频元素停住不播
对应逻辑也写在播放器组件里。
这段代码顺手也说明了一个很实用的 React 经验:
- 声明式组件管理状态
- 命令式操作媒体 DOM
七、连接销毁与资源释放
使用 WebRTC 时很容易只盯着建连,但真正放进 React 页面里,释放资源同样重要。
这里可以把销毁路径分成三层。
1. 组件取消订阅
return () => {
unsubscribe();
setStream(null);
if (getSharedCameraConsumerCount(runtimeCameraId) === 0) {
clearWebRtcSessionState(runtimeCameraId);
}
};
这一层对应的是最直接的 React 生命周期:
- 组件卸载
- 不再关心这条流
- 先解除订阅
2. 会话池延迟关闭
function scheduleClose(entry: SharedCameraStreamEntry) {
if (entry.closeTimer || entry.consumers > 0 || entry.sessionPromise) return;
entry.closeTimer = setTimeout(() => {
entry.closeTimer = null;
if (entry.consumers === 0 && !entry.sessionPromise) {
void closeSharedSession(entry);
}
}, SHARED_SESSION_RELEASE_DELAY_MS);
}
这一层对应的是 WebRTC 连接管理:
- 不是一卸载就立刻关连接
- 而是先看还有没有别的消费者
- 没有的话再延迟回收
3. 真正关闭 PeerConnection 并通知服务端
close: async () => {
pc.close();
await closeWebRtc(options.cameraId, connectionId).catch(() => undefined);
},
这一步在 WebRTC 语义上做了两件事:
- 浏览器本地
pc.close() - 请求
/webrtc/close通知服务端清理连接
接口封装在 API 层:
return requestTsVideo<{ ok: boolean }>(`/${cameraId}/webrtc/close`, {
method: "POST",
body: JSON.stringify({ connection_id: connectionId }),
});
关闭代理路由也做了单独处理。
“浏览器侧关闭”和“服务端侧关闭”:
pc.close()/webrtc/close
八、踩坑总结
真正让 WebRTC 难用的,通常不是 API 会不会写,而是链路跑起来以后为什么“像是连上了,但就是没画面”。
下面这几段代码,正好可以拿来解释 WebRTC 落地时的几个典型坑位。
1. 协商成功,但流迟迟不到
if (entry.stream || entry.consumers === 0) return; if (entry.status !== "connected" && entry.status !== "connecting") return; void restartSharedSession(entry);
这段逻辑解释的是一个非常典型的问题:
- WebRTC 协商完成,不代表媒体流一定马上可用
- 如果长时间收不到流,需要主动重建连接
2. 连接失败后的重试
const SHARED_SESSION_RETRY_DELAYS_MS = [800, 1600, 3200];
WebRTC 连接失败后,前端通常不能只报错,还要有节奏地重试。
放在 React 里看,这类重试逻辑最好收口在连接层,而不是散落在按钮点击和组件 effect 里。
3. 流到了,但视频元素没播起来
if (!paused && video.paused && video.srcObject) {
tryPlay();
}
这类问题在浏览器媒体播放里非常常见:
- 流已经拿到了
<video>也已经绑定了- 但媒体元素因为某些状态没有真正开始播放
所以组件里加一个轻量重试是很实用的。
4. 状态接口和真实媒体状态不完全同步
- 状态接口可能滞后于 WebRTC
- 即使状态还没准备好,前端仍允许继续尝试拉流
这也是实时媒体页面里很典型的情况:
- 业务状态和媒体状态不是完全同一拍
- UI 不能简单把状态接口当成唯一真相
九、完整链路总结
先看一张简化时序图。

如果压缩成一句话,React 接入 WebRTC 的主线就是:
页面触发连接 -> RTCPeerConnection -> createOffer -> /webrtc -> answer -> ontrack -> video.srcObject = stream
如果再把页面管理、状态管理和识别通道也加进来,React 页面里的完整链路可以整理成:
页面初始化 ↓ 拉取摄像头与状态 ↓ 预热 WebRTC 会话 ↓ 创建 RTCPeerConnection ↓ createOffer() ↓ POST /webrtc ↓ 返回 Answer ↓ setRemoteDescription() ↓ ontrack 收到 MediaStream ↓ video.srcObject = stream ↓ 页面显示实时视频 ↓ 并行维护状态接口与 WebSocket 识别结果
这里要明确区分两条通道:
- WebRTC:负责实时视频
- WebSocket:负责识别结果推送,不负责 WebRTC 信令
从概念上说,这里并行存在两条通道:
- WebRTC 连接实现
- WebSocket 识别通道实现
十、信令、状态、WebSocket分别在做什么
/webrtc
这一部分代表的是 WebRTC 信令交换。
它发生在:
createOffer()和setLocalDescription()之后
前端发出去的核心内容是:
connection_idsdptype: "offer"
服务端回来的核心内容是:
connection_idsdptype: "answer"
它和 WebRTC 的关系是:
- 负责交换 Offer / Answer
- 不承载媒体流本身
/webrtc/close
这一部分对应的是连接销毁通知。
它发生在:
- 会话关闭时
发出去的关键信息是:
connection_id
返回值层面:
- 前端按
{ ok: boolean }解析
它和 WebRTC 的关系是:
- 告诉服务端“这条连接可以清理了”
/status
这一部分不是信令,而是运行状态补充。
它通常发生在:
- 初始化之后
- 切换视频之后
- 恢复会话之后
返回的信息更偏运行态:
camera_idrtsp_sourcecapture_readystream_readymodels
响应结构在类型定义层里有明确声明。
它和 WebRTC 的关系是:
- 不参与协商
- 只补充状态和来源信息
/ws
这一部分也不是视频媒体链路,而是旁路消息通道。
它通常发生在:
- 页面选中视频并建立识别通道时
它和 WebRTC 的关系是:
- 负责识别结果推送
- 不负责 Offer / Answer 信令
以上就是在React中使用WebRTC实现实时视频播放的完整流程的详细内容,更多关于React WebRTC实时视频播放的资料请关注脚本之家其它相关文章!
