简介:面向需要接入实时视频流的网页开发者,这套基于Vue框架的webrtc-streamer前端示例项目,通过WebRTC技术将RTSP视频源转为浏览器可播的实时画面。代码默认内置四个视频窗口,配置好RTSP地址并启动webrtc-streamer.exe服务后,在Node16环境下即可直接运行,适合作为二次开发和功能验证的起点。压缩包共2015个文件,包含约704个JavaScript逻辑文件、150个Json配置和1134个Markdown说明文档,以Vue组件为核心,整体大小约25.86MB,目录划分清晰、便于按需查阅,压缩包内的依赖已经附带,省去额外安装步骤,便于快速启动。已有548人学习,开发者能够从中掌握前端拉取RTSP流、转换WebRTC流以及多路视频绑定渲染的完整路径,同时可通过说明文档快速定位代码结构;在此基础上,继续扩展播放控制、画面布局、用户鉴权等进阶功能会较为顺手。 接这个需求的那天,我本来以为又是普通的网页播放器对接。客户给了几十路摄像头的RTSP流,要求在网页上做实时预览,我下意识先想到了HLS转流方案,结果一测延迟3到8秒,连客户都摇头说“这哪是监控,这是回放”。后来换成webrtc-streamer做网关,前端用Vue 3封装播放器组件,延迟压到了500毫秒以内,整套方案才算真正落地。这篇文章就把我在Vue项目里集成webrtc-streamer的完整过程记录下来,从架构理解、API调用、组件封装到生产环境的坑,一次性说清楚。适合正在做Web端实时视频预览、监控大屏、低延迟直播,并且打算用Vue作为前端的开发者参考。

1. webrtc-streamer不是前端库:先理解它的运行机制

1.1 它到底是一个什么样的“服务”

webrtc-streamer本质上是一个用C++写的独立服务进程,不是那种npm install一下就能用的前端依赖。它跑在服务器上,负责把RTSP、ONVIF、RTMP这类传统流媒体协议转换成WebRTC标准流,再推给浏览器。

这个转换能力是关键。浏览器里不能直接解析RTSP,而传统的HLS方案要经过切片、转码、再分发,延迟很高。webrtc-streamer的思路是不转码,只做协议层的桥接——它从摄像头拉RTSP流,然后通过WebRTC的SRTP/SCTP通道把媒体数据传给浏览器端,尽量保持原始码流,所以延迟能做得非常低。

它在服务器上默认监听一个端口(比如8000),同时提供HTTP和WebSocket两种访问方式。前端页面通过WebSocket连上这个端口,用JSON-RPC格式的信令和它通信,协商完成后媒体流就直接从网关发往浏览器。这里要注意:WebRTC虽然名字里带“点对点”,但在监控这类场景下,媒体流基本都经过webrtc-streamer中转,而不是浏览器直连摄像头,因为摄像头通常没有公网地址,也扛不住多个浏览器直接连。

1.2 官方demo的运行逻辑

官方仓库的demo是一套非常经典的思路。你启动webrtc-streamer服务后,打开它自带的html页面,页面里的JavaScript会通过WebSocket连上服务,然后调用类似viewMedia的方法,把指定名称的视频流绑定到页面里的video标签上。

这套逻辑其实包含了完整的信令协商过程:发起方创建RTCPeerConnection,生成SDP offer,通过WebSocket发给webrtc-streamer,服务端再回复answer,之后双方交换ICE候选信息,最终建立媒体通道。这些步骤都被封装在官方提供的webrtcstreamer.js里了,开发者不需要自己处理SDP和ICE这些底层的握手细节。

想验证这个demo能不能跑通,最直接的办法是在服务器上启动webrtc-streamer,然后用浏览器打开它的默认页面。一旦确认官方demo能出画面,剩下的工作其实就变成了一件很纯粹的事情:在前端工程里,把官方的webrtcstreamer.js接进来,用Vue的方式管理它的生命周期。

2. Vue工程的集成姿势:为什么我推荐用官方webrtcstreamer.js

2.1 三种接入方式对比

把webrtc-streamer接进Vue项目,常见的有三条路:

第一种是用iframe直接嵌入官方demo页面。最快,但基本不可控。监控项目通常需要自定义UI、切换码流、按需加载,一个iframe塞进去,这些全都做不了,还要处理跨域通信,得不偿失。

第二种是把官方webrtcstreamer.js下载到本地工程里,当作普通脚本引入,再在Vue组件里new WebRtcStreamer类来调用。这是我最推荐的方式,既保留了官方代码对WebRTC底层细节的完整封装,又能完全按Vue组件的思路控制展示层逻辑,可维护性最好。

第三种是自己用WebSocket写一套信令客户端,完全绕过官方JS。理论上最灵活,但你需要自己处理SDP offer/answer、ICE候选收集、断线重连、多流并发这些逻辑,工作量和踩坑成本成倍上涨。非特别复杂的定制需求,不建议走这条路。

2.2 工程目录怎么放

我实测下来,最稳的做法是把官方的webrtcstreamer.js(以及它依赖的loader.js等辅助文件)放在Vue项目的public目录下。放在public目录而不是src目录,原因是这个文件不是ESM模块,直接import会有作用域问题,而且它内部用了很多全局状态,打包时容易被tree-shaking搞出诡异问题。

在index.html里用普通script标签引入它:

<script src="/webrtcstreamer.js"></script>

这样WebRtcStreamer这个类就挂在了全局作用域上,组件里直接用window.WebRtcStreamer访问即可。

2.3 vue.config.js里要处理的代理关系

实际开发中,Vue的devServer和webrtc-streamer服务往往不在同一个端口,甚至不在同一台机器上,这就涉及到开发环境代理的问题。我需要在vue.config.js里把WebSocket请求和http请求都转发到webrtc-streamer的端口:

// vue.config.js
module.exports = {
  devServer: {
    proxy: {
      '/api/stream': {
        target: 'http://192.168.1.100:8000',
        changeOrigin: true,
        ws: true
      }
    }
  }
}

这里的ws: true非常重要,因为webrtc-streamer信令走的是WebSocket协议,只配了http代理不配ws的话,组件能连上服务,但会在信令通信阶段直接超时。我第一次接的时候就是漏了这个配置,折腾了很久。

3. 核心API调用链路:viewMedia之后发生了什么

3.1 一个最小可用的调用示例

先看最核心的调用代码。假设我已经在全局引入了webrtcstreamer.js,那么在一个Vue 3组件的onMounted里,这样就能拉流:

// 在一个Vue组件内
let webRtcStreamer = null

onMounted(() => {
  const wsUrl = 'ws://192.168.1.100:8000'
  webRtcStreamer = new WebRtcStreamer(wsUrl)

  const videoElement = document.getElementById('liveVideo')
  webRtcStreamer.viewMedia(videoElement, 'camera_01', {
    autoplay: 1,
    rtptransport: 1
  })
})

onBeforeUnmount(() => {
  if (webRtcStreamer) {
    webRtcStreamer.closeVideo(document.getElementById('liveVideo'))
    webRtcStreamer = null
  }
})

这里viewMedia的第一个参数是video元素,第二个参数是流名称(要和webrtc-streamer服务里配置或探测到的流名一致),第三个参数是扩展选项。autoplay: 1表示流建立后自动播放,rtptransport: 1表示使用TCP传输,这个选项在后面解决部分网络环境下的花屏问题很有用。

3.2 内部到底发生了什么

很多教程只告诉你这样写,但我觉得有必要把链路讲透:viewMedia被调用后,WebRtcStreamer会立即创建一个RTCPeerConnection,生成SDP offer,通过WebSocket把这个offer发到webrtc-streamer服务端。

服务端收到offer后,会检查对应的流是否存在。如果流不可用,它会在信令回调里返回错误状态,前端通过监听error事件可以感知到;如果流正常,服务端会回复SDP answer,并且两端开始交换ICE候选信息。协商完成后,媒体数据开始从服务端流向浏览器,video元素触发canplay等事件,画面就出来了。

这个流程就是标准的WebRTC信令握手,只是webrtc-streamer把细节藏在了类内部。理解了这个流程,后面排查问题时才会有方向感——比如视频一直黑屏,你要先判断是信令没走通,还是媒体数据没到,排查思路完全不同。

3.3 常用方法一览

我在实际项目中用到的核心方法主要是这几个:

  • new WebRtcStreamer(wsUrl, [clientId]):创建客户端实例,wsUrl指向webrtc-streamer的WebSocket地址。
  • viewMedia(video, streamName, options):开始拉流,绑定到指定video元素。
  • closeVideo(video):关闭当前video上的流,释放资源。
  • setVideoEncoder(name, streamName):设置视频编码参数,可用于码流切换。
  • getStreamInfoList():获取服务端的流信息列表。

这里要特别强调一下,多个播放器实例不要共用一个WebRtcStreamer对象。我在封装多路画面组件时踩过坑:共用实例会导致后一路流的信令把前一路覆盖掉,画面互相串流。正确做法是每个video元素对应一个独立的WebRtcStreamer实例,各自维护各自的RTCPeerConnection状态。

4. 封装video-player组件:从能用变成好用

4.1 单路播放器组件

有了上面的基础,就可以在Vue里封装一个视频播放器组件了。我习惯用Vue 3的组合式API来写,因为生命周期和资源释放的逻辑可以放在一起,阅读起来很清晰:

<template>
  <div class="rtc-player">
    <video
      ref="videoRef"
      autoplay
      muted
      playsinline
      class="rtc-player__video"
    ></video>
    <div v-if="!playing" class="rtc-player__loading">连接中...</div>
  </div>
</template>

<script setup>
import { onMounted, onBeforeUnmount, ref } from 'vue'

const props = defineProps({
  wsUrl: {
    type: String,
    required: true
  },
  streamName: {
    type: String,
    required: true
  },
  options: {
    type: Object,
    default: () => ({ autoplay: 1 })
  }
})

const emit = defineEmits(['play', 'stop', 'error'])
const videoRef = ref(null)
const playing = ref(false)
let webRtcStreamer = null

onMounted(() => {
  if (!window.WebRtcStreamer) {
    emit('error', new Error('webrtcstreamer.js未加载'))
    return
  }

  webRtcStreamer = new WebRtcStreamer(props.wsUrl)
  webRtcStreamer.viewMedia(videoRef.value, props.streamName, props.options)
  playing.value = true
  emit('play')
})

onBeforeUnmount(() => {
  if (webRtcStreamer) {
    webRtcStreamer.closeVideo(videoRef.value)
    webRtcStreamer = null
    playing.value = false
    emit('stop')
  }
})
</script>

这个组件的核心价值在于把WebRtcStreamer的生命周期和Vue组件的生命周期绑定在一起。组件挂载时建立连接,组件销毁时释放资源,父组件完全不需要关心底层细节,只要传入wsUrl和streamName就能用。

4.2 多路画面网格组件

做监控大屏时,单个播放器远远不够,还要一个网格组件来承载多路流。我通常会写一个RtcGrid组件,接收一个流名数组,内部用列表渲染多个RtcPlayer:

<template>
  <div class="rtc-grid" :class="`rtc-grid--${chunks.length}`">
    <RtcPlayer
      v-for="stream in chunks"
      :key="stream"
      :ws-url="wsUrl"
      :stream-name="stream"
    />
  </div>
</template>

<script setup>
import { computed } from 'vue'
import RtcPlayer from './RtcPlayer.vue'

const props = defineProps({
  wsUrl: { type: String, required: true },
  streams: { type: Array, default: () => [] }
})

// 把流名数组分成若干组,控制每行展示数量
const chunks = computed(() => {
  const cols = Math.ceil(Math.sqrt(props.streams.length))
  const rows = []
  for (let i = 0; i < props.streams.length; i += cols) {
    rows.push(props.streams.slice(i, i + cols))
  }
  return rows
})
</script>

不过多路同时拉流有一个现实问题:带宽和性能。几十路流同时通过WebRTC传到浏览器,对GPU解码能力和网络带宽都是考验。所以网格组件里我建议加一个“按需加载”机制——只有滚动到可视区域或者用户点开的格子才真正调用viewMedia,其他格子只显示占位图。这一点在大屏项目里会非常关键。

4.3 处理浏览器的自动播放限制

浏览器的自动播放策略是前端做视频绕不开的坎。哪怕是WebRTC流,video元素没有用户交互直接播放,也会被Chrome拦下来。我的做法是video标签上强制加muted属性,WebRTC流的音频通过单独的UI控制,需要出声时再取消静音。实践证明,muted+playsinline+aotoplay这三个属性组合在一起,在移动端和桌面端都能比较顺利地自动播放。

如果业务上必须默认出声,那就只能引导用户先点一次页面再拉流,或者在用户点击“启动预览”按钮后才触发viewMedia调用。这个交互时序问题,越早和产品确认越好,不要到测试阶段才返工。

5. 实战中的高发雷区:摄像头兼容、跨域与会话残留

5.1 H265编码的摄像头会黑屏

监控设备市场里,H265编码已经非常普及了,但webrtc-streamer对H265的支持存在很大局限。主要原因是Chrome、Edge这些主流浏览器原生不支持H265的WebRTC解码,虽然webrtc-streamer可以通过内部转码方式处理一部分,但实测下来很多设备型号要么花屏要么直接黑屏。

解决办法是:在摄像头端或者webrtc-streamer侧,把编码强制切换成H264。如果设备管理页面支持设置主码流和子码流的编码格式,把webrtc-streamer取流的通道设置为H264子码流,通常画面清晰度也够,而且带宽占用更低。

5.2 跨域与混合内容限制

如果前端站点是HTTPS部署,而webrtc-streamer服务是HTTP协议,浏览器会直接拦截WebSocket连接,因为这是混合内容。这个问题在公网部署时特别常见。

有两种解决路径:一是给webrtc-streamer配置TLS,让它自己也以WSS协议提供服务,但openssl配置和证书更新要自己维护;二是在前面加一层Nginx反向代理,由Nginx统一处理HTTPS证书,再把请求转发给webrtc-streamer的HTTP端口。我建议选第二种,证书管理简单,还能顺带做请求鉴权。

5.3 页面销毁后视频资源不释放

这是一个特别隐蔽又特别严重的坑。Vue组件卸载后,如果只是把组件从DOM上移除,而没有显式调用closeVideo,WebRtcStreamer内部的RTCPeerConnection不会自动关闭。结果就是服务端那边仍然认为这个浏览器还在拉流,继续持续发送视频数据,摄像头资源一直被占用。

在公司内部系统里,这个问题初期看不出来,直到某个摄像机到达了并发拉流上限,新的画面怎么都出不来,回头一查才发现有大量僵尸连接。所以onBeforeUnmount里的closeVideo不是可选项,是必需项。我还习惯在window的beforeunload事件里统一清理一遍,防止用户直接关标签页导致资源悬挂。

5.4 断线后不重连,画面永久卡死

网络抖动是现实世界中避免不了的事情。WebSocket断开后,WebRtcStreamer内部的信令通道失效,视频画面会停在上一个帧上,看起来就像卡死了一样,但其实并没有恢复机制。

我在生产项目里做了一个简单的重连策略:监听video元素的stalled事件和WebSocket的断开事件,超过3秒没有新帧就触发重连。重连之前先closeVideo清理旧会话,再重新构造WebRtcStreamer实例并调用viewMedia,重试间隔从1秒起,按指数退避最大到30秒。实测下来,这种策略能覆盖绝大部分弱网场景,用户基本感知不到断线过。

6. 从demo走向生产:延迟优化和多路管理

6.1 延迟优化的几个有效手段

webrtc-streamer方案的优势本身就是低延迟,但如果配置不当,延迟也会悄悄变高。我在实际调优中总结了几条有效手段:一是优先使用TCP传输(rtptransport: 1),虽然理论延迟比UDP略高,但在跨网络环境下更稳定,避免丢包重传带来的延迟波动;二是关闭音频流,如果业务不需要声音,可以在拉流时只启用video track,减少一条链路的开销;三是避免在webrtc-streamer服务器上同时跑太多CPU密集型的转码任务,服务器负载过高会直接影响拉流延迟。

如果你对延迟的要求极致到“毫秒级”,还需要关注播放端的缓冲设置。浏览器默认的jitter buffer会积累一定的数据量,这部分延迟可以通过在options里设置更激进的抖动缓冲参数来压缩,但代价是抗网络抖动的能力下降,不建议在公网场景下用。

6.2 多路流的管理与自动重启

在多路监控场景下,单一的video-player组件已经不够用,我做了一个播放管理器,统一持有所有活动的WebRtcStreamer实例,并提供启动、停止、轮询截图、批量重连等能力。这个管理器本质上是一个普通的JS单例类,配合Vue的响应式状态,把“哪些流正在播放”“哪些流处于异常状态”共享给整个应用。

管理器的核心逻辑是这样的:每个播放任务都有一个稳定的唯一ID,管理器内部维护一个Map,id对应{ streamName, instance, status }。当某个流上报异常时,管理器先调用instance.closeVideo清理旧连接,再延迟重试,同时通过Vue的reactive数组通知界面更新状态。这样即使未来要扩展到几十路流,前端代码的复杂度也能保持可控。

6.3 生产环境的部署建议

最后说部署。webrtc-streamer本身是单进程模型,单机并发能力有上限。如果只是几十路流的小规模场景,一台服务器足够;如果是上百路的规模,建议拆成多个webrtc-streamer实例,每个实例分管一部分摄像头,前端组件通过接口动态获取每个流对应的wsUrl,而不是把所有压力压到一个服务上。

安全方面也要注意:webrtc-streamer自带的接口几乎没有鉴权能力,凡是能连上WebSocket端口的人都能拉流。生产环境一定要把它放在内网或者反向代理后面,由后端统一做身份校验,再向前端签发有时效的访问凭证。这条经验我贴在被突破的系统复盘报告里过,希望不需要有人再踩一遍。

我在实际项目里还有一个体会,就是这个方案对前端工程师的WebRTC基础要求并不高,反而更看重架构上的取舍。什么场景才需要上webrtc-streamer,哪些摄像头编码不兼容,多路并发的上限是多少,这些决策才是真正拉开经验差距的地方。先把单路跑通,再逐步扩展多路,最后再考虑高可用和性能优化,这个顺序最稳。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐