React接入WebRTC实时视频实践

一、简介

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 拆开看,通常会分成四层:

  1. 页面层
  • 决定什么时候发起连接
  • 决定展示哪一路视频
  1. 状态层
  • 保存当前连接状态
  • 保存当前选中的视频流
  1. WebRTC 层
  • 创建 RTCPeerConnection
  • 完成 Offer / Answer 协商
  • 接收远端轨道
  1. 渲染层
  • 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 的角度看,最容易讲清楚的拆法是两层:

  1. API Service
  • 一个独立的 API 封装层

负责:

  • startWebRtc()
  • closeWebRtc()
  • fetchCameraStatus()
  • fetchCameraVideos()
  1. WebRTC Service
  • webrtcClient
  • liveSessionPool

负责:

  • 创建 RTCPeerConnection
  • 完成 Offer / Answer 协商
  • 把流分发给页面
  • 管理共享连接、重试和销毁

这样拆,不是为了"结构漂亮",而是为了让两类问题分开:

  • API 层只关心请求
  • WebRTC 层只关心连接
  • React 组件只关心显示

视频组件

视频部分再往下看,也可以拆成两层:

  1. VideoTileCard
  • 判断当前走实时流还是回放地址
  • 维护与会话池的订阅关系
  • 把视频数据交给播放器
  1. CameraVideoPlayer
  • 直接操作 <video>
  • 绑定 srcObject
  • 控制 playpausemuted
  • 回传播放进度

这种拆法对应的其实是 React 里很经典的职责分工:

  • 上层组件负责业务状态
  • 下层组件负责 DOM 和媒体元素

生命周期

把 React 生命周期和 WebRTC 生命周期一一对上,整条链路就会很好理解:

  1. VideoLayout 触发 fetchData()
  2. fetchData() 会预热会话,调用 prewarmCameraStreams()
  3. VideoTileCard 挂载后开始订阅共享流
  4. 收到 MediaStream 后,CameraVideoPlayer 绑定到 <video>
  5. 组件卸载后取消订阅;没有消费者时,会话池再延迟关闭连接

如果从"页面如何托住一条 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
  • loadedmetadatacanplayplaying 时尝试 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_id
  • sdp
  • type: "offer"

服务端回来的核心内容是:

  • connection_id
  • sdp
  • type: "answer"

它和 WebRTC 的关系是:

  • 负责交换 Offer / Answer
  • 不承载媒体流本身

/webrtc/close

这一部分对应的是连接销毁通知。

它发生在:

  • 会话关闭时

发出去的关键信息是:

  • connection_id

返回值层面:

  • 前端按 { ok: boolean } 解析

它和 WebRTC 的关系是:

  • 告诉服务端"这条连接可以清理了"

/status

这一部分不是信令,而是运行状态补充。

它通常发生在:

  • 初始化之后
  • 切换视频之后
  • 恢复会话之后

返回的信息更偏运行态:

  • camera_id
  • rtsp_source
  • capture_ready
  • stream_ready
  • models

响应结构在类型定义层里有明确声明。

它和 WebRTC 的关系是:

  • 不参与协商
  • 只补充状态和来源信息

/ws

这一部分也不是视频媒体链路,而是旁路消息通道。

它通常发生在:

  • 页面选中视频并建立识别通道时

它和 WebRTC 的关系是:

  • 负责识别结果推送
  • 不负责 Offer / Answer 信令
相关推荐
Maimai108081 小时前
Web3 前端交易系统如何落地:从下单 UI 到 Operation 编码、签名与实时状态更新
前端·react.js·ui·架构·前端框架·web3
小鹿研究点东西2 小时前
AI直播复盘实操:如何自动录制并拆解直播话术
人工智能·自动化·音视频
chase。2 小时前
【学习笔记】RIGVid:通过模仿生成视频实现机器人操作,无需物理演示
笔记·学习·音视频
Maimai108082 小时前
Web3 前端实时通信如何落地:从 SSE 订阅到行情、订单与账户状态更新
前端·javascript·react.js·前端框架·web3·状态模式
用户887665426633 小时前
Web3 前端实时通信如何落地:从 SSE 订阅到行情、订单与账户状态更新
前端·react.js·web3
黑科技研究僧3 小时前
蘑兔AI的12轨分轨功能:编曲师深度测评
人工智能·经验分享·vscode·学习·新媒体运营·音视频
Deitymoon3 小时前
RV1126——OSD模块
计算机视觉·音视频·rv1126·osd
AndyHuang19764 小时前
WebRTC 强制 Relay 模式下 TCP 重连失败深度排查与优化实战
webrtc