Vue3+TS 安防监控接入实战:视频流协议选型与播放器封装
这段时间在项目里接到一个挺典型的对接需求vue3ts 的前端工程要接入某主流安防平台的监控摄像头实现实时预览、录像回放和云台控制。乍一听就是“放个视频到网页上”但真正动手以后才发现这里面的水比想象中深得多——视频流协议怎么选、浏览器支持哪些编码、播放器生命周期怎么管理、鉴权过期怎么办、多路并发怎么控制每一个环节不处理好都会被测试现场的同事反复找上门。这篇就记录一下我最近完整走了一遍流程后的实操总结从需求拆解、方案选型到 Vue3TS 封装、常见问题排查尽量把能避开的坑提前讲清楚。这个需求来自一个内部安防项目下面叫它模拟项目X前端整体技术栈已经定在 Vue3 TypeScript所以对接方案也要围绕这个栈来设计。文章里涉及的都是这类项目里最常见的场景代码部分我会给到可直接参考的封装思路不一定逐行贴全但关键结构和参数都会交代清楚。1. 需求拆解监控对接到底要做什么1.1 一上来就被忽略的“隐藏需求”很多开发一听“摄像头对接”第一反应是给我一个视频地址我放个video标签不就完事了真实项目里远没有这么简单。监控对接本质上是三个能力叠加资源管理登录平台、拉设备列表、拿通道信息、处理组织树实时预览把摄像头的实时视频流拉到浏览器里播放辅助能力云台控制、录像回放、报警订阅、抓图抓录。模拟项目X 最初提需求时只写了“实时预览”等我把第一版预览做出来产品立刻补了云台方向控制、预置位保存、回放时间轴选择等一堆东西。所以从一开始就要把接口设计留足扩展空间不要在组件里写死。还有一层经常被忽略平台侧的摄像头并不只有一种。同一套平台下可能接入了几百路 IPC有的支持 H.264有的只输出 H.265有的在局域网有的在公网设备在线状态各异。前端如果只按一种编码格式做播放器一定会被现场复杂的设备状况教做人。1.2 为什么是 Vue3 TypeScript选型不是纯跟风。在这个项目里Vue3 的 Composition API 对“播放器逻辑复用”的友好度非常明显——实时预览、录像回放、多路网格预览都有相似的拉流、销毁、重连逻辑用useVideoPlayer这类组合式函数把生命周期收拢起来每个页面组件可以按需组合不需要靠 mixins 把一堆方法混到组件里导致命名冲突。TypeScript 的价值则体现在平台接口对接上。监控平台返回的设备树、通道编码、鉴权 token、流地址解析结果都是强结构数据用 interface 定义清楚之后接口联调时的报错能在编译期提前暴露。比如平台返回的StreamInfo里可能同时存在rtsp://、http-flv、ws-flv三种地址类型定义可以统一成一个联合字段后续播放器按协议分发时就不会出现“地址拿到了却不知道传给谁”的尴尬。另外团队里多个人并行开发时TS 的约束相当于给对接文档上了一层保险。后来接手这个模块的新同事靠类型定义就能大致摸清接口返回结构和播放器入参省掉了大量口头沟通成本。2. 视频流与方案选型先定协议再写代码2.1 浏览器能播哪些流摄像头本身最常见的输出协议是 RTSP但这个协议浏览器原生不支持而且是基于 TCP/UDP 的私有会话需要专用播放器或转码服务。纯前端层面我们面对的是“平台把 RTSP 转成什么协议给我们拉”。常见选项主要有四种协议延迟表现浏览器兼容性适用场景RTMP1~3 秒已基本淘汰Flash 停更后无法原生播放老系统遗留HLSm3u85~15 秒原生支持好移动端友好录像回放、多路非实时场景HTTP-FLV / WebSocket-FLV1~3 秒需 flv.js 等库解析实时预览主力方案WebRTC0.3~1 秒现代浏览器支持良好低延迟场景需信令服务配合这里要注意不同协议背后对应的是不同的“拉流地址”。平台返回的地址如果以.flv结尾通常可以交给 flv.js以.m3u8结尾则用 hls.js。前端选播放器不能只看“能不能播”还要看协议封装和浏览器 MSE 支持情况。2.2 平台对接的三种常见姿势第一种是浏览器插件版 SDK需要安装 ActiveX 插件之类。这条路在现代浏览器里基本走不通Chrome 早就禁掉了 NPAPIEdge 对插件的限制更严格。如果目标系统还在要求用户装插件整个方案可以直接毙掉。第二种是平台提供的 H5 无插件 SDK也就是厂商把播放器封装成一个 JS 库前端按文档传入容器和鉴权参数。这种方式封装度高、对接最快但坑也不少库体积大、UI 难定制、内部实现黑盒一旦遇到定制需求很难排错。如果是快速 Demo可以考虑做正式产品我更倾向自己掌握播放器控制权。第三种是走平台的标准 HTTP 接口 流媒体网关。平台提供设备管理、鉴权、流地址获取相关 REST API视频流通过独立流媒体服务转码或直接转发输出成 HTTP-FLV / HLS / WebRTC。这种方式对前端最友好因为播放器可控、协议可控、也只有这种方式能配合 Vue3 组件化思路做深度封装。2.3 我们的选型结论模拟项目X 最终选了“平台 HTTP 接口 流媒体网关转 WebSocket-FLV”的路线预览用 flv.js回放按需切换到 HLS。这么做主要基于三点考虑实时预览延迟要求压在 2 秒内WebSocket-FLV 比 HLS 容易达标网关可以统一处理转码前端不用关心摄像头是 H.264 还是 H.265后端转成 H.264 就完事flv.js 生态成熟容器销毁、断流重连这些坑都有现成方案可抄。如果平台自带的 H5 SDK 能满足产品形态也不排斥第二套方案但至少要在架构上留出“播放器可替换”的接口避免被 SDK 绑定死。3. Vue3TS 落地从封装播放器到对接接口3.1 播放器封装生命周期管理是核心视频播放器最核心的问题不是 API 调不通而是生命周期。一个监控页面往往有多个摄像头网格用户切换路由、关闭弹窗、退出页面时播放器必须被彻底销毁否则视频流连接会一直挂着内存不断上涨。我用组合式函数封装了一个useFlvPlayer职责边界是创建播放器、绑定 DOM、加载流地址、销毁播放器。组件侧只需要调用它不直接操作 flv.js 实例。import flvjs from flv.js export interface FlvPlayerOptions { url: string videoEl: HTMLVideoElement lazyLoad?: boolean destroyOnUnmount?: boolean } export function useFlvPlayer(options: FlvPlayerOptions) { let player: flvjs.Player | null null const start () { if (!flvjs.isSupported()) { console.warn([useFlvPlayer] 当前浏览器不支持 FLV 播放) return } player flvjs.createPlayer( { type: flv, url: options.url, isLive: true }, { enableWorker: true, enableStashBuffer: false, stashInitialSize: 128 } ) player.attachMediaElement(options.videoEl) player.load() player.play() } const destroy () { if (player) { player.pause() player.unload() player.detachMediaElement() player.destroy() player null } } watch( () options.url, (newUrl) { if (!newUrl) return destroy() nextTick(() start()) } ) onScopeDispose(() { destroy() }) return { start, destroy } }几个容易踩的细节enableStashBuffer: false一定要设。默认开启缓冲会让直播画面延迟越拉越大关掉后延迟能稳定在 1~2 秒。onScopeDispose是 Vue3 里面对应组件卸载的钩子比起onBeforeUnmount更适合组合式函数内部做清理它能自动跟随调用方作用域销毁。切换流地址时先销毁再重新创建不要试图用同一个实例调用 load 两次flv.js 的状态机一旦乱掉画面就会卡在最后一帧或是黑屏。3.2 设备列表与鉴权的数据流设计监控平台的接口鉴权通常走 token 体系。登录拿 token后续所有请求带上 token。问题在于 token 会过期而且不同平台的字段名还不一样有的是accessToken有的是ticket有的是sessionId。前端封装时要统一归一化。我习惯在 api 层定义一个统一返回结构export interface ApiResultT unknown { code: number msg: string data: T } export interface DeviceInfo { deviceId: string deviceName: string channels: ChannelInfo[] } export interface ChannelInfo { channelId: string channelName: string status: online | offline streamAddress: { flv?: string hls?: string rtsp?: string } }设备树拉下来后不要直接把接口原字段丢给组件。原平台返回的可能是id、name、childList这种五花八门的结构统一转成DeviceInfo[]后后续云台控制、预览组件的入参就非常一致。鉴权模块单独封装成useAuth职责包括登录、刷新 token、请求拦截器自动附带 token、401 后静默续期。import axios from axios const service axios.create({ baseURL: /api, timeout: 15000 }) service.interceptors.request.use((config) { const authStore useAuthStore() if (authStore.token) { config.headers.Authorization Bearer ${authStore.token} } return config }) service.interceptors.response.use( (response) response.data, async (error) { if (error.response?.status 401) { const authStore useAuthStore() await authStore.refreshToken() error.config.headers.Authorization Bearer ${authStore.token} return service.request(error.config) } return Promise.reject(error) } )Token 续期这块要注意并发请求的问题。如果多个接口同时 401不能用“每个请求都各自去刷新 token”否则会重复刷新导致后一个覆盖前一个。简单做法是加一个isRefreshing标识刷新期间把其他请求先挂到一个队列里等刷新完成再统一重放。3.3 云台控制、录像回放这类“辅助能力”怎么接云台控制PTZ看似简单实际联调最容易出问题。方向控制、变倍、步长、预置位设置不同厂商的接口参数差异很大。前端能做的是定义一套统一的控制指令类型避免组件里到处散落字符串魔数。export type PtzDirection up | down | left | right | left-up | right-up | left-down | right-down export type PtzCommand start | stop | zoom-in | zoom-out export interface PtzParams { channelId: string command: PtzCommand | PtzDirection speed?: number } export function controlPtz(params: PtzParams) { // POST /api/ptz return service.post(/ptz, { channelId: params.channelId, action: params.command, speed: params.speed ?? 3, }) }云台控制有个交互细节很容易被忽略按住方向键是持续转动松开才是停止不能只发一次“向左转”。所以前端要处理 mousedown 发 start、mouseup 发 stop 的逻辑而且移动端要兼容 touch 事件。这里的接口设计最好把 start 和 stop 分开而不是把“转向”当作一次性动作。录像回放相对独立核心是时间轴。接口一般给一个时间段平台返回该时段内有录制的片段列表前端用进度条展示用户拖动到某个时间点再请求对应地址。这个流程不复杂但时间参数的格式要统一成时间戳或 ISO 字符串避免不同浏览器格式化差异。回放流的播放器和实时预览的 flv.js 逻辑可以复用只是isLive要设成false并且遇到暂停、Seek 时要重新拉流。4. 常见问题排查实录测试现场踩过的坑4.1 画面黑屏还不报错先查编码格式这是整个对接过程中遇到最多的问题没有之一。表现为 flv.js 成功加载、视频元素也有流数据但画面就是黑的控制台一条报错都没有。排查顺序非常重要先确认拉到的流是什么编码。H.265HEVC在主流浏览器里基本无法硬解即使软解也会卡成幻灯片。用 ffprobe 看流信息比在前端瞎猜快得多。确认平台返回的地址是不是真实可拉流的地址。很多平台会因为通道类型、权限范围不同给前端的地址是“占位地址”。检查 flv.js 是否真的把流喂给了 video 标签。可以在浏览器 Network 面板里看 WebSocket 连接有没有持续收到二进制帧如果连接没有数据说明网关侧就没出流。H.265 的解决思路是让后端流媒体网关统一转码成 H.264并把转码后的地址返回给前端。前端不要试图去兼容 HEVC各个浏览器的支持进度参差不齐投入产出比太低。4.2 跨域有时候不是后端的问题监控平台常用于内网部署前端页面可能跑在另一台服务器上跨域几乎不可避免。后端的 CORS 配置是一个层面但更隐蔽的坑是视频流域名和接口域名不是同一个播放器拉流时会在流服务器上触发跨域检查。我踩过一次非常隐性的坑接口走/api代理没问题但 flv.js 用 WebSocket 拉流时nginx 没有代理/live路径WebSocket 直接 404画面自然是黑的。排查了半天接口都正常最后才发现是代理规则漏了一条。所以对接监控这种带流媒体服务的系统nginx 配置要把接口路径和流路径都列全location /api/ { proxy_pass http://backend_server; proxy_set_header Authorization $http_authorization; } location /live/ { proxy_pass http://stream_server; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; }4.3 多路预览内存泄漏与浏览器卡顿监控页面最常见的形态是一屏 4 路、9 路、16 路。路数一多内存和 CPU 马上就爆。优化有几个层次播放器销毁离开页面或者关闭弹窗时必须把 player 实例销毁否则 WebSocket 连接持续保持内存只增不减。这就要靠前面useFlvPlayer里onScopeDispose兜底。并发限制一个页面同时创建太多 flv.js 实例会导致解码线程排队整个浏览器都卡。可以控制同时最大拉流路数比如只加载当前网格内可见的画面切换到其他网格时再拉流。避免频繁重建如果只是切换摄像头先把旧播放器销毁再新建是正确做法但不要为了刷新数据每几秒重建一次这会带来严重白屏闪烁和资源浪费。实测 9 路预览如果 9 个 flv.js 全部同时启动Chrome 内存占用能飙升到 1GB 以上改成只加载可视区域内的 4 路其他屏幕离屏等待后内存能压到 500MB 以内。4.4 视频流的延迟越来越大刚开始接入实时预览时播放器延迟会从最初的 1 秒慢慢变成 5 秒、10 秒尤其是长时间挂机后。原因有两个flv.js 默认开启的缓冲机制会把数据囤在内存里网络抖动时自动积压视频关键帧GOP间隔太长播放器等待可解码帧的时间被拉长。解决方法一是前端设置enableStashBuffer: false监测到延迟超过阈值时主动 seek 到最新帧附近二是和后端约定转码输出的 GOP 尽量控制在 1~2 秒H.264 的keyint设置小一些。如果前端已经加了缓冲控制但延迟还是大问题基本就出在源端转码或者编码配置上不是前端能补的。4.5 Token 过期导致的反复黑屏还有一个容易和“视频流断流”混淆的问题页面挂了一晚上第二天打开画面黑屏刷新页面又恢复。原因是平台 token 过期后续的拉流请求或刷新设备列表请求被拒。这种问题不要靠用户手动刷新解决要做好 token 续期并且要在 401 处理里把“刷新 token”和“重放请求”的链路打通。如果平台签发的是短期 token比如有的会话只有 2 小时前端还要考虑在页面空闲时周期性刷新防止用户长时间停留后所有操作突然失效。5. 个人经验与后续扩展方向这个对接需求做下来我最深的感受是技术难点反而不在 Vue3 或 TypeScript 本身而是在流媒体协议、编码格式和播放器生命周期这三件事上。框架只是工具真正的工程能力体现在把平台提供的各种不稳定因素在前端层面收拢、兜底、规范化。有几个习惯值得坚持所有平台返回的数据进入前端后先归一化不要在页面组件里直接出现平台特有字段名播放器实例集中管理不要散落在页面各处否则排查崩溃问题会追到怀疑人生接监控平台第一周先做一张“能力核对表”平台支持哪些协议、哪个接口能拿到流地址、不同编码的设备占比多少这张表比任何代码都重要。后续如果这个项目继续迭代我大概率会做的事情是把视频流能力做成一个独立的微服务层前端只管订阅和展示流地址的获取、鉴权、轮转都交给后端同时引入 WebRTC 做低延迟场景的备选方案毕竟实时性和兼容性会越来越被业务看重。不过这些都是后话项目稳健运行的前提下能逐步演进就是最大的成功。