一、简介
React 接入实时视频,最核心的问题在于:
前端怎样在浏览器里拿到一段持续到来的媒体流,并稳定地显示在页面上。
如果用 WebRTC 来做,这条链路通常会拆成几步:
- React 页面触发连接
- 浏览器创建
RTCPeerConnection - 前端发起 Offer
- 服务端返回 Answer
- 浏览器收到远端视频流
- 把
MediaStream绑定到<video>
本文章的重点在于通过几段真实代码示例,把 WebRTC 在 React 里如何工作的描述清楚。
文中会借几类代码片段来解释概念:
- 页面入口
- 视频容器
- 播放组件
- WebRTC 封装
- 会话管理
二、什么是WebRTC
WebRTC 是浏览器原生提供的实时音视频通信能力。
它的价值在于:
- 浏览器本身就能建立实时媒体连接
- 前端不用额外安装播放器
- 收到流之后可以直接交给
<video>播放
所以当前端接入 WebRTC,关注点通常不是"怎么解码视频",而是:
- 怎么建立连接
- 怎么完成协商
- 怎么接收流
- 怎么在 React 生命周期里管理这条连接
#mermaid-svg-OSUDtqUFJ3QzNJKV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OSUDtqUFJ3QzNJKV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OSUDtqUFJ3QzNJKV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OSUDtqUFJ3QzNJKV .error-icon{fill:#552222;}#mermaid-svg-OSUDtqUFJ3QzNJKV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OSUDtqUFJ3QzNJKV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OSUDtqUFJ3QzNJKV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OSUDtqUFJ3QzNJKV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OSUDtqUFJ3QzNJKV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OSUDtqUFJ3QzNJKV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OSUDtqUFJ3QzNJKV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OSUDtqUFJ3QzNJKV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OSUDtqUFJ3QzNJKV .marker.cross{stroke:#333333;}#mermaid-svg-OSUDtqUFJ3QzNJKV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OSUDtqUFJ3QzNJKV p{margin:0;}#mermaid-svg-OSUDtqUFJ3QzNJKV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-OSUDtqUFJ3QzNJKV .cluster-label text{fill:#333;}#mermaid-svg-OSUDtqUFJ3QzNJKV .cluster-label span{color:#333;}#mermaid-svg-OSUDtqUFJ3QzNJKV .cluster-label span p{background-color:transparent;}#mermaid-svg-OSUDtqUFJ3QzNJKV .label text,#mermaid-svg-OSUDtqUFJ3QzNJKV span{fill:#333;color:#333;}#mermaid-svg-OSUDtqUFJ3QzNJKV .node rect,#mermaid-svg-OSUDtqUFJ3QzNJKV .node circle,#mermaid-svg-OSUDtqUFJ3QzNJKV .node ellipse,#mermaid-svg-OSUDtqUFJ3QzNJKV .node polygon,#mermaid-svg-OSUDtqUFJ3QzNJKV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OSUDtqUFJ3QzNJKV .rough-node .label text,#mermaid-svg-OSUDtqUFJ3QzNJKV .node .label text,#mermaid-svg-OSUDtqUFJ3QzNJKV .image-shape .label,#mermaid-svg-OSUDtqUFJ3QzNJKV .icon-shape .label{text-anchor:middle;}#mermaid-svg-OSUDtqUFJ3QzNJKV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OSUDtqUFJ3QzNJKV .rough-node .label,#mermaid-svg-OSUDtqUFJ3QzNJKV .node .label,#mermaid-svg-OSUDtqUFJ3QzNJKV .image-shape .label,#mermaid-svg-OSUDtqUFJ3QzNJKV .icon-shape .label{text-align:center;}#mermaid-svg-OSUDtqUFJ3QzNJKV .node.clickable{cursor:pointer;}#mermaid-svg-OSUDtqUFJ3QzNJKV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-OSUDtqUFJ3QzNJKV .arrowheadPath{fill:#333333;}#mermaid-svg-OSUDtqUFJ3QzNJKV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-OSUDtqUFJ3QzNJKV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-OSUDtqUFJ3QzNJKV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OSUDtqUFJ3QzNJKV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OSUDtqUFJ3QzNJKV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OSUDtqUFJ3QzNJKV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-OSUDtqUFJ3QzNJKV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OSUDtqUFJ3QzNJKV .cluster text{fill:#333;}#mermaid-svg-OSUDtqUFJ3QzNJKV .cluster span{color:#333;}#mermaid-svg-OSUDtqUFJ3QzNJKV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OSUDtqUFJ3QzNJKV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OSUDtqUFJ3QzNJKV rect.text{fill:none;stroke-width:0;}#mermaid-svg-OSUDtqUFJ3QzNJKV .icon-shape,#mermaid-svg-OSUDtqUFJ3QzNJKV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OSUDtqUFJ3QzNJKV .icon-shape p,#mermaid-svg-OSUDtqUFJ3QzNJKV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-OSUDtqUFJ3QzNJKV .icon-shape .label rect,#mermaid-svg-OSUDtqUFJ3QzNJKV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OSUDtqUFJ3QzNJKV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OSUDtqUFJ3QzNJKV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OSUDtqUFJ3QzNJKV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Browser
WebRTC
Video
三、整体架构
把 React 接入 WebRTC 拆开看,通常会分成四层:
- 页面层
- 决定什么时候发起连接
- 决定展示哪一路视频
- 状态层
- 保存当前连接状态
- 保存当前选中的视频流
- WebRTC 层
- 创建
RTCPeerConnection - 完成 Offer / Answer 协商
- 接收远端轨道
- 渲染层
- 把
MediaStream绑定给<video> - 处理播放、暂停、静音和销毁
如果把 WebRTC 放进 React 页面里理解,通常会落成下面几类角色:
- 页面入口
VideoPage->VideoLayout - 页面主体
VideoStage->MatrixView/SingleView - 状态管理
videoStore - 会话复用
liveSessionPool - WebRTC 客户端
webrtcClient - API 请求
videoApi
可以先把这条链路理解成:
#mermaid-svg-Wi65LFNDhDAuLSfr{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Wi65LFNDhDAuLSfr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Wi65LFNDhDAuLSfr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Wi65LFNDhDAuLSfr .error-icon{fill:#552222;}#mermaid-svg-Wi65LFNDhDAuLSfr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Wi65LFNDhDAuLSfr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Wi65LFNDhDAuLSfr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Wi65LFNDhDAuLSfr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Wi65LFNDhDAuLSfr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Wi65LFNDhDAuLSfr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Wi65LFNDhDAuLSfr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Wi65LFNDhDAuLSfr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Wi65LFNDhDAuLSfr .marker.cross{stroke:#333333;}#mermaid-svg-Wi65LFNDhDAuLSfr svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Wi65LFNDhDAuLSfr p{margin:0;}#mermaid-svg-Wi65LFNDhDAuLSfr .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Wi65LFNDhDAuLSfr .cluster-label text{fill:#333;}#mermaid-svg-Wi65LFNDhDAuLSfr .cluster-label span{color:#333;}#mermaid-svg-Wi65LFNDhDAuLSfr .cluster-label span p{background-color:transparent;}#mermaid-svg-Wi65LFNDhDAuLSfr .label text,#mermaid-svg-Wi65LFNDhDAuLSfr span{fill:#333;color:#333;}#mermaid-svg-Wi65LFNDhDAuLSfr .node rect,#mermaid-svg-Wi65LFNDhDAuLSfr .node circle,#mermaid-svg-Wi65LFNDhDAuLSfr .node ellipse,#mermaid-svg-Wi65LFNDhDAuLSfr .node polygon,#mermaid-svg-Wi65LFNDhDAuLSfr .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Wi65LFNDhDAuLSfr .rough-node .label text,#mermaid-svg-Wi65LFNDhDAuLSfr .node .label text,#mermaid-svg-Wi65LFNDhDAuLSfr .image-shape .label,#mermaid-svg-Wi65LFNDhDAuLSfr .icon-shape .label{text-anchor:middle;}#mermaid-svg-Wi65LFNDhDAuLSfr .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Wi65LFNDhDAuLSfr .rough-node .label,#mermaid-svg-Wi65LFNDhDAuLSfr .node .label,#mermaid-svg-Wi65LFNDhDAuLSfr .image-shape .label,#mermaid-svg-Wi65LFNDhDAuLSfr .icon-shape .label{text-align:center;}#mermaid-svg-Wi65LFNDhDAuLSfr .node.clickable{cursor:pointer;}#mermaid-svg-Wi65LFNDhDAuLSfr .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Wi65LFNDhDAuLSfr .arrowheadPath{fill:#333333;}#mermaid-svg-Wi65LFNDhDAuLSfr .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Wi65LFNDhDAuLSfr .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Wi65LFNDhDAuLSfr .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Wi65LFNDhDAuLSfr .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Wi65LFNDhDAuLSfr .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Wi65LFNDhDAuLSfr .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Wi65LFNDhDAuLSfr .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Wi65LFNDhDAuLSfr .cluster text{fill:#333;}#mermaid-svg-Wi65LFNDhDAuLSfr .cluster span{color:#333;}#mermaid-svg-Wi65LFNDhDAuLSfr div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Wi65LFNDhDAuLSfr .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Wi65LFNDhDAuLSfr rect.text{fill:none;stroke-width:0;}#mermaid-svg-Wi65LFNDhDAuLSfr .icon-shape,#mermaid-svg-Wi65LFNDhDAuLSfr .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Wi65LFNDhDAuLSfr .icon-shape p,#mermaid-svg-Wi65LFNDhDAuLSfr .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Wi65LFNDhDAuLSfr .icon-shape .label rect,#mermaid-svg-Wi65LFNDhDAuLSfr .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Wi65LFNDhDAuLSfr .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Wi65LFNDhDAuLSfr .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Wi65LFNDhDAuLSfr :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} CameraA
CameraServer
VideoServer
React
Video.
这里需要注意:
- 状态接口会返回
rtsp_source字段,前端会读取它 - 但服务端内部到底如何把 RTSP 变成 WebRTC,不在本次范围内
所以更适合的理解方式是:
React 页面通过 WebRTC 从视频服务拉流播放,状态接口会补充媒体来源和运行状态。
四、WebRTC核心流程
React 接入 WebRTC,核心还是那条经典链路:
创建连接 -> 创建 Offer -> SDP 协商 -> 设置 Answer -> 收流 -> 播放
1. 创建 RTCPeerConnection
连接初始化在 createCameraWebRtcSession 里完成:
ts
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 里最关键的第一轮协商:
ts
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
可以把这两句理解成:
createOffer():告诉浏览器"把当前这条连接需要协商的内容生成出来"setLocalDescription():把这份协商信息登记为本地描述
从概念上说,这一步就是浏览器先把"我能怎么连"告诉对端。
3. SDP 协商
Offer 生成后,就进入 SDP 协商。
ts
const answer = await startWebRtc(options.cameraId, {
connection_id: connectionId,
sdp: offer.sdp ?? "",
type: "offer",
});
startWebRtc 的请求封装是一个普通的 API 方法:
ts
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 的结构在类型定义里写得很清楚:
ts
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 后,浏览器才真正知道"对端接受了什么协商结果":
ts
await pc.setRemoteDescription(normalizeAnswer(answer));
normalizeAnswer 的作用也很直接:
ts
function normalizeAnswer(answer: CameraWebRtcAnswer): RTCSessionDescriptionInit {
return {
type: answer.type,
sdp: answer.sdp,
};
}
这一部分代码对应的,就是 WebRTC 协商闭环的完成。
6. 接收视频流
真正进入"播放视频"阶段,是从 pc.ontrack 开始的:
ts
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>。
先看会话池怎么把流往外传:
ts
const session = await createCameraWebRtcSession({
cameraId: entry.cameraId,
iceServers: getTsIceServers(),
onStream: (stream) => {
entry.stream = stream;
clearStreamWaitTimer(entry);
notify(entry);
},
});
然后组件侧订阅这条流:
ts
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>:
ts
if (stream) {
video.muted = muted;
if (video.srcObject !== stream) {
video.srcObject = stream;
video.removeAttribute("src");
}
}
如果把这一整段 WebRTC 过程压缩成一行,就是:
text
RTCPeerConnection
→ createOffer()
→ setLocalDescription()
→ POST /webrtc
→ answer
→ setRemoteDescription()
→ ontrack
→ MediaStream
→ video.srcObject = stream
五、React里怎么落地 WebRTC
WebRTC 解决的是"连起来",React 解决的是"把连接接进页面生命周期里"。
页面组件
页面入口非常轻:
ts
export default function VideoPage() {
return <VideoLayout />;
}
真正负责初始化的是 VideoLayout:
ts
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 连接"这个角度看,关系可以画成这样:
#mermaid-svg-aEB69hnhM03iaDkI{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-aEB69hnhM03iaDkI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-aEB69hnhM03iaDkI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-aEB69hnhM03iaDkI .error-icon{fill:#552222;}#mermaid-svg-aEB69hnhM03iaDkI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-aEB69hnhM03iaDkI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-aEB69hnhM03iaDkI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-aEB69hnhM03iaDkI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-aEB69hnhM03iaDkI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-aEB69hnhM03iaDkI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-aEB69hnhM03iaDkI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-aEB69hnhM03iaDkI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-aEB69hnhM03iaDkI .marker.cross{stroke:#333333;}#mermaid-svg-aEB69hnhM03iaDkI svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-aEB69hnhM03iaDkI p{margin:0;}#mermaid-svg-aEB69hnhM03iaDkI .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-aEB69hnhM03iaDkI .cluster-label text{fill:#333;}#mermaid-svg-aEB69hnhM03iaDkI .cluster-label span{color:#333;}#mermaid-svg-aEB69hnhM03iaDkI .cluster-label span p{background-color:transparent;}#mermaid-svg-aEB69hnhM03iaDkI .label text,#mermaid-svg-aEB69hnhM03iaDkI span{fill:#333;color:#333;}#mermaid-svg-aEB69hnhM03iaDkI .node rect,#mermaid-svg-aEB69hnhM03iaDkI .node circle,#mermaid-svg-aEB69hnhM03iaDkI .node ellipse,#mermaid-svg-aEB69hnhM03iaDkI .node polygon,#mermaid-svg-aEB69hnhM03iaDkI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-aEB69hnhM03iaDkI .rough-node .label text,#mermaid-svg-aEB69hnhM03iaDkI .node .label text,#mermaid-svg-aEB69hnhM03iaDkI .image-shape .label,#mermaid-svg-aEB69hnhM03iaDkI .icon-shape .label{text-anchor:middle;}#mermaid-svg-aEB69hnhM03iaDkI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-aEB69hnhM03iaDkI .rough-node .label,#mermaid-svg-aEB69hnhM03iaDkI .node .label,#mermaid-svg-aEB69hnhM03iaDkI .image-shape .label,#mermaid-svg-aEB69hnhM03iaDkI .icon-shape .label{text-align:center;}#mermaid-svg-aEB69hnhM03iaDkI .node.clickable{cursor:pointer;}#mermaid-svg-aEB69hnhM03iaDkI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-aEB69hnhM03iaDkI .arrowheadPath{fill:#333333;}#mermaid-svg-aEB69hnhM03iaDkI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-aEB69hnhM03iaDkI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-aEB69hnhM03iaDkI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aEB69hnhM03iaDkI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-aEB69hnhM03iaDkI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aEB69hnhM03iaDkI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-aEB69hnhM03iaDkI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-aEB69hnhM03iaDkI .cluster text{fill:#333;}#mermaid-svg-aEB69hnhM03iaDkI .cluster span{color:#333;}#mermaid-svg-aEB69hnhM03iaDkI div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-aEB69hnhM03iaDkI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-aEB69hnhM03iaDkI rect.text{fill:none;stroke-width:0;}#mermaid-svg-aEB69hnhM03iaDkI .icon-shape,#mermaid-svg-aEB69hnhM03iaDkI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aEB69hnhM03iaDkI .icon-shape p,#mermaid-svg-aEB69hnhM03iaDkI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-aEB69hnhM03iaDkI .icon-shape .label rect,#mermaid-svg-aEB69hnhM03iaDkI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aEB69hnhM03iaDkI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-aEB69hnhM03iaDkI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-aEB69hnhM03iaDkI :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} VideoPage
VideoLayout
VideoStore
VideoStage
MatrixView
SingleView
VideoTileCard
liveSessionPool
webrtcClient
videoApi
CameraVideoPlayer
Video
recognitionWsClient
六、视频显示原理
如果只挑一句最能代表"浏览器开始播放 WebRTC 视频"的代码,就是:
ts
video.srcObject = stream;
这句代码出现在播放器组件里。
为什么这样就能播放?
因为:
stream是浏览器已经收到的MediaStream<video>支持直接把MediaStream作为数据源- 一旦
srcObject指向它,浏览器就会开始解码并渲染轨道内容
这个组件还补了几层细节处理:
- 同步
muted - 在
loadedmetadata、canplay、playing时尝试play() - 每 800ms 检查一次,避免视频元素停住不播
对应逻辑也写在播放器组件里。
这段代码顺手也说明了一个很实用的 React 经验:
- 声明式组件管理状态
- 命令式操作媒体 DOM
七、连接销毁与资源释放
使用 WebRTC 时很容易只盯着建连,但真正放进 React 页面里,释放资源同样重要。
这里可以把销毁路径分成三层。
1. 组件取消订阅
ts
return () => {
unsubscribe();
setStream(null);
if (getSharedCameraConsumerCount(runtimeCameraId) === 0) {
clearWebRtcSessionState(runtimeCameraId);
}
};
这一层对应的是最直接的 React 生命周期:
- 组件卸载
- 不再关心这条流
- 先解除订阅
2. 会话池延迟关闭
ts
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 并通知服务端
ts
close: async () => {
pc.close();
await closeWebRtc(options.cameraId, connectionId).catch(() => undefined);
},
这一步在 WebRTC 语义上做了两件事:
- 浏览器本地
pc.close() - 请求
/webrtc/close通知服务端清理连接
接口封装在 API 层:
ts
return requestTsVideo<{ ok: boolean }>(`/${cameraId}/webrtc/close`, {
method: "POST",
body: JSON.stringify({ connection_id: connectionId }),
});
关闭代理路由也做了单独处理。
"浏览器侧关闭"和"服务端侧关闭":
pc.close()/webrtc/close
八、踩坑总结
真正让 WebRTC 难用的,通常不是 API 会不会写,而是链路跑起来以后为什么"像是连上了,但就是没画面"。
下面这几段代码,正好可以拿来解释 WebRTC 落地时的几个典型坑位。
1. 协商成功,但流迟迟不到
ts
if (entry.stream || entry.consumers === 0) return;
if (entry.status !== "connected" && entry.status !== "connecting") return;
void restartSharedSession(entry);
这段逻辑解释的是一个非常典型的问题:
- WebRTC 协商完成,不代表媒体流一定马上可用
- 如果长时间收不到流,需要主动重建连接
2. 连接失败后的重试
ts
const SHARED_SESSION_RETRY_DELAYS_MS = [800, 1600, 3200];
WebRTC 连接失败后,前端通常不能只报错,还要有节奏地重试。
放在 React 里看,这类重试逻辑最好收口在连接层,而不是散落在按钮点击和组件 effect 里。
3. 流到了,但视频元素没播起来
ts
if (!paused && video.paused && video.srcObject) {
tryPlay();
}
这类问题在浏览器媒体播放里非常常见:
- 流已经拿到了
<video>也已经绑定了- 但媒体元素因为某些状态没有真正开始播放
所以组件里加一个轻量重试是很实用的。
4. 状态接口和真实媒体状态不完全同步
- 状态接口可能滞后于 WebRTC
- 即使状态还没准备好,前端仍允许继续尝试拉流
这也是实时媒体页面里很典型的情况:
- 业务状态和媒体状态不是完全同一拍
- UI 不能简单把状态接口当成唯一真相
九、完整链路总结
先看一张简化时序图。
Video VideoServer Browser Video VideoServer Browser #mermaid-svg-E2bCmFk9KyB8yuoO{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-E2bCmFk9KyB8yuoO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-E2bCmFk9KyB8yuoO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-E2bCmFk9KyB8yuoO .error-icon{fill:#552222;}#mermaid-svg-E2bCmFk9KyB8yuoO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-E2bCmFk9KyB8yuoO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-E2bCmFk9KyB8yuoO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-E2bCmFk9KyB8yuoO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-E2bCmFk9KyB8yuoO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-E2bCmFk9KyB8yuoO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-E2bCmFk9KyB8yuoO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-E2bCmFk9KyB8yuoO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-E2bCmFk9KyB8yuoO .marker.cross{stroke:#333333;}#mermaid-svg-E2bCmFk9KyB8yuoO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-E2bCmFk9KyB8yuoO p{margin:0;}#mermaid-svg-E2bCmFk9KyB8yuoO .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-E2bCmFk9KyB8yuoO text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-E2bCmFk9KyB8yuoO .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-E2bCmFk9KyB8yuoO .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-E2bCmFk9KyB8yuoO .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-E2bCmFk9KyB8yuoO .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-E2bCmFk9KyB8yuoO #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-E2bCmFk9KyB8yuoO .sequenceNumber{fill:white;}#mermaid-svg-E2bCmFk9KyB8yuoO #sequencenumber{fill:#333;}#mermaid-svg-E2bCmFk9KyB8yuoO #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-E2bCmFk9KyB8yuoO .messageText{fill:#333;stroke:none;}#mermaid-svg-E2bCmFk9KyB8yuoO .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-E2bCmFk9KyB8yuoO .labelText,#mermaid-svg-E2bCmFk9KyB8yuoO .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-E2bCmFk9KyB8yuoO .loopText,#mermaid-svg-E2bCmFk9KyB8yuoO .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-E2bCmFk9KyB8yuoO .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-E2bCmFk9KyB8yuoO .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-E2bCmFk9KyB8yuoO .noteText,#mermaid-svg-E2bCmFk9KyB8yuoO .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-E2bCmFk9KyB8yuoO .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-E2bCmFk9KyB8yuoO .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-E2bCmFk9KyB8yuoO .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-E2bCmFk9KyB8yuoO .actorPopupMenu{position:absolute;}#mermaid-svg-E2bCmFk9KyB8yuoO .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-E2bCmFk9KyB8yuoO .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-E2bCmFk9KyB8yuoO .actor-man circle,#mermaid-svg-E2bCmFk9KyB8yuoO line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-E2bCmFk9KyB8yuoO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} POST /webrtc (offer)answersetRemoteDescriptionMediaStreamvideo.srcObject = stream渲染画面
如果压缩成一句话,React 接入 WebRTC 的主线就是:
页面触发连接 -> RTCPeerConnection -> createOffer -> /webrtc -> answer -> ontrack -> video.srcObject = stream
如果再把页面管理、状态管理和识别通道也加进来,React 页面里的完整链路可以整理成:
text
页面初始化
↓
拉取摄像头与状态
↓
预热 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 信令