简介:这是一款面向自媒体创作者与视频平台运维人员的视频流速度监控插件,主要用于观察直播或点播传输中的速率、延迟、丢包与缓冲表现,帮助定位卡顿、上传效率低等网络问题。压缩包为 zip 格式,共 18 个文件,约 286KB,包含 9 个 JavaScript 脚本、2 个 HTML 页面、1 个 CSS 样式表与 1 个 JSON 配置文件,另有 png 图标和 mp3 提示音,分别承担核心监控逻辑、设置与弹窗界面、样式配置及声音提醒等用途。目前已有 1400 人学习下载。读者可获得可运行的浏览器插件源码与模块化目录结构,理解流速采集、阈值告警和界面展示的实现思路,并据此调整分辨率、比特率或网络环境;目录按脚本、页面、样式、配置、图像与音频分区,也便于二次开发与按需裁剪,适合需要排查视频传输问题、优化播放体验的自媒体人和平台维护者参考。
1. 一段播放页到底卡在哪,靠肉眼是看不出来的
某内容团队做自媒体视频的发布前自检,播放页上线后用户反馈「卡」,可后台只有播放量、完播率两个数,既定位不到是网络抖动、编码参数还是播放器缓冲策略的问题,也说不清卡在开头还是卡在中段。视频流速的插件解决的正是这个盲区:它挂在 video 元素上,按固定周期采样 currentTime 的推进量、前方缓冲余量与累计丢帧数,把一句主观的「卡」拆成 playRate、bufferAhead、stallCount 三个可横向比较、可跨版本对比的数字。它不替换播放器、不改业务渲染逻辑,一行 init 就能出流速曲线,适合自己写播放页的前端、做内容分发的测试同学,以及需要在上线前跑一遍弱网自检的自媒体团队。
2. 插件测的四类流速:把「卡」拆成可比较的数字
2.1 播放速率:currentTime 推进量除以墙钟时间
流速监控最核心的一个量是播放速率,算法朴素到一行就能写完:在一个采样周期内,看 currentTime 前进了多少秒,再看墙钟过去了多少秒,两者相除。理想情况下这个值等于 1.0,播放器按 1 倍速正常出帧;掉到 0.95 以下说明播放被拖慢,掉到 0.5 说明一半的时间在等数据。
很多人第一反应是拿 buffered 直接算流速,这其实测的是下载而不是播放。缓冲涨得快不等于画面走得顺,中间还隔着解码和渲染两道工序,老机型上这两步翻车的概率并不低。所以插件把「播放推进」和「缓冲增长」拆成两个独立指标,前者反映用户看到的流畅度,后者反映网络侧的供给能力,两者一起看才能判断是该调 CDN 还是该降码率。
采样周期的选择是个经验活。周期太短(比如 200ms),currentTime 的推进量会被浏览器的定时器精度和视频帧间隔拉扯得上下跳,曲线全是毛刺;周期太长(比如 5s),一次 400ms 的瞬时卡顿会被平均掉,等于没测。我一般把 sampleInterval 定在 1000ms,配合下面要说的卡顿状态机做互补:采样负责画趋势,状态机负责抓毛刺。
// 每 sampleInterval 采一次样,last 保存上一次的采样快照 function sample(video, last) { const now = performance.now(); const wall = (now - last.wall) / 1000; // 墙钟秒数,用 performance.now 避免系统时钟跳变 if (wall <= 0) return null; const t = video.currentTime; const bufferedEnd = video.buffered.length ? video.buffered.end(video.buffered.length - 1) // 取最后一段,不是第 0 段 : 0; const deltaT = t - last.t; // 暂停、seek、刚开始播放时 deltaT 会为负或异常,直接丢弃这一拍 if (video.paused || video.seeking || deltaT < 0) return null; return { ts: Date.now(), playRate: +(deltaT / wall).toFixed(3), // 正常 ≈ 1.0 bufferAhead: +(bufferedEnd - t).toFixed(2), // 前方还有多少秒可播 downloadRate: +((bufferedEnd - last.bufferedEnd) / wall).toFixed(2), readyState: video.readyState, // 2 及以下基本等于要卡 bufferedRanges: video.buffered.length // 段数异常多说明服务端分片有问题 }; }这段代码里有三个参数值得单独拎出来说。performance.now() 是单调时钟,不受用户改系统时间影响,用它算墙钟比 Date.now() 稳;buffered.end 要取最后一段,多段缓冲在 ABR 切换和 seek 之后很常见,取第 0 段会得到一条几乎恒为 0 的假曲线;deltaT 为负必须丢掉,seek 回退会让播放速率瞬间变成负数,这类脏数据进了均值,整条曲线就废了。
2.2 缓冲余量与下载速率:区分「供给不足」和「供给不稳」
bufferAhead 是前方还能播多少秒,它回答的是「现在危不危险」;downloadRate 是每秒能往缓冲里灌多少秒的内容,它回答的是「接下来会不会危险」。这两个数字配合看,能分出三类故障:bufferAhead 一路往下掉、downloadRate 也低,是带宽不够;bufferAhead 上下剧烈震荡、downloadRate 忽高忽低,是网络抖动或服务端分片慢;bufferAhead 稳定但 playRate 就是上不去,那问题多半在解码或渲染,跟网络无关。
判断阈值不能拍脑袋。对时长 3 分钟的短视频,bufferAhead 低于 2 秒就该警惕,因为一次网络抖动就足以击穿;对长视频点播,低于 5 秒才算危险。插件把阈值做成可配的 lowBufferThreshold,默认 3 秒,是个折中值。至于 downloadRate,一个粗略的经验是它至少要达到播放码率的 1.5 倍,缓冲才有上涨趋势;如果码率是 2Mbps 而 downloadRate 折算下来只有 2.4Mbps,那基本是在原地踏步,任何一次抖动都会立刻转成卡顿。
这里有个容易被忽略的细节:buffered 的增长是阶梯状而不是连续的,服务端按分片下发,一个 4 秒的分片下完,bufferedEnd 会一次性跳 4 秒。所以 downloadRate 在 1 秒粒度的采样下会出现 0 和 8 交替的锯齿,直接看单点值毫无意义。常见的处理是拿 5 个采样点做滑动窗口求和再除,或者干脆只上报分片级别的事件。我在实现里选的是前者,滑动窗口固定 5 拍,够平滑又不会太滞后。
2.3 帧率与丢帧:比播放速率更早报警的指标
playRate 有个先天局限:它是秒级的,而掉帧是毫秒级的。一个页面每秒掉 8 帧,currentTime 照样匀速前进,playRate 依然是漂亮的 1.0,可用户看到的就是一顿一顿。要抓这类问题,得靠 requestVideoFrameCallback(rVFC),它在每一帧被合成呈现时回调一次,回调参数里带着累计呈现帧数 presentedFrames,两次回调的帧数差除以时间差就是实际渲染帧率。
rVFC 的浏览器覆盖已经相当不错,但没覆盖到的环境还得有退路,getVideoPlaybackQuality() 返回的 droppedVideoFrames 是累计值,取相邻两次的差就能得到这段时间的丢帧数。两个指标一起用:帧率低于源帧率的 90%,或者 10 秒内丢帧超过 30 帧,就记为一次渲染异常。
// rVFC 版本:只在页面可见时统计,后台标签页会被节流 let prev = null; function onFrame(now, meta) { if (prev) { const dFrames = meta.presentedFrames - prev.frames; const dTime = (now - prev.now) / 1000; if (dTime > 0.2) { // 间隔太长的样本不可信,丢弃 frameRate = +(dFrames / dTime).toFixed(1); } } prev = { now, frames: meta.presentedFrames }; video.requestVideoFrameCallback(onFrame); } if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) { video.requestVideoFrameCallback(onFrame); }这段代码里 dTime > 0.2 那个判断是血泪经验换来的。标签页切到后台后,浏览器会把 rVFC 的调度压到 1 秒甚至更低,此时 dTime 可能到 1.5 秒,算出来的帧率会掉到个位数,报上去就是一堆假告警。加上这道过滤,再配合 visibilitychange 事件暂停统计,数据才干净。
2.4 为什么不用 Network Information API 单打独斗
有人会问,navigator.connection 不是直接给了 effectiveType 和 downlink 吗,何必这么麻烦。可以看,但不能只信它。一是粒度太粗,downlink 是按设备历史流量估的,一条 4G 连接可能估成 10Mbps 也可能估成 1.5Mbps,跟当前这一刻的真实吞吐没关系;二是它反映的是设备到网关的链路,测不到服务端分片慢、CDN 回源慢这类应用层问题;三是在部分环境下这些字段干脆不实现,读到 undefined。
正确的用法是把它当标签而不是当指标:上报时附上 effectiveType、saveData,用来做数据分群,比如对比「4G 用户」和「WiFi 用户」的 P95 卡顿率差异。真正的流速判定必须从媒体元素本身取,因为元素上的数字包含了网络、解码、渲染全链路的结果,这才是用户实际感受到的东西。这个取舍决定了插件的整体架构:以元素级采样为主干,connection 信息只做旁路标注。
3. 接入与参数:三行初始化到一张配置表
3.1 引入与初始化
插件是独立脚本,不依赖任何播放器框架,挂载方式就是找到页面上的 video 元素然后 init。对已经封装了播放器的场景,把 video 元素的引用传进去即可,插件不去监听播放器的 API,也不干预 source 的切换,只做只读采集。这一点很重要:只读意味着它不会成为播放链路的一部分,出问题时拔掉脚本就能回退,不会像某些侵入式埋点那样把播放器带崩。
// 引入后立即初始化,script 放 body 末尾或加 defer const monitor = FlowWatch.init({ video: document.querySelector('#player video'), sampleInterval: 1000, // 采样周期,ms stallThreshold: 300, // 超过这个时长的 waiting 才记一次卡顿 lowBufferThreshold: 3, // 前方缓冲低于 3 秒记一条预警 reportUrl: '/api/flow/report', reportBatch: 20, // 攒够 20 条采样就上报 reportInterval: 15000, // 或者每 15 秒强制上报一次 sampleRate: 1.0, // 采样率,灰度期可以降到 0.2 debug: false // true 时把采样打到 console });初始化这一步最容易翻车的地方是执行时机。如果 script 在 head 里同步加载,document.querySelector 大概率拿到 null,因为 DOM 还没解析到播放器节点。稳妥写法是把 init 放进 DOMContentLoaded,或者在播放器组件挂载完成的钩子里调用。另外单页应用切路由时 video 元素会被销毁重建,记得在组件卸载时调 monitor.destroy(),否则监听器会挂在已经脱离文档的节点上,越积越多,最后表现成内存泄漏式的卡死。
3.2 参数逐个说:默认值为什么是这个数
参数不对,测出来的数据比不测还危险。下面这张表里的默认值都是按短视频和中等时长点播这两类场景调的,长视频或直播还得再改。
| 参数 | 默认值 | 作用 | 调整建议 |
|---|---|---|---|
| sampleInterval | 1000ms | 采样周期 | 短视频可降到 500ms 抓毛刺,长视频升到 2000ms 降开销 |
| stallThreshold | 300ms | waiting 事件记卡顿的门槛 | 移动端可放宽到 500ms,桌面端 200ms 更贴近体感 |
| lowBufferThreshold | 3s | 缓冲预警线 | 直播场景别用这个参数,直播缓冲本来就浅 |
| reportBatch | 20 | 攒批条数 | 流量大的站点调到 50 减少请求数 |
| reportInterval | 15000ms | 强制上报周期 | 不能超过后端会话超时时间,一般 15~30s |
| sampleRate | 1.0 | 用户级采样率 | 全量期 1.0,稳定后 0.1~0.2 足够算分位数 |
stallThreshold 这个参数最需要按端调。桌面端 200ms 的停顿用户几乎无感,移动端因为触控交互和性能差异,300~500ms 才开始被感知为「卡了一下」。设得太低,waiting 事件里混进大量几十毫秒的正常缓冲切换,卡顿次数虚高;设得太高,又抓不到真实的体感问题。我的做法是先按 300ms 上线,跑一周看卡顿时长的分布直方图,如果 300~400ms 这一档占了 60% 以上,说明阈值偏低,往上抬一档再观察。
3.3 手动打点与自定义指标
自动采样覆盖的是连续性指标,但业务上很多关键节点是离散的:用户点了播放、切了清晰度、拖了进度条、切到后台再回来。这些节点不标出来,分析时就会出现「流速突然掉了但不知道为什么」的黑匣子。插件提供 mark(name, extra) 接口,把业务动作写进同一条时间线。
// 业务侧埋点,name 建议用固定枚举,别用自由文本 monitor.mark('quality_change', { from: '720p', to: '480p' }); monitor.mark('user_seek', { to: video.currentTime }); monitor.mark('visibility_hidden'); // 拿到当前会话的聚合快照,用于自检面板或前端日志 const snap = monitor.summary(); console.log(snap.p95Stall, snap.avgPlayRate, snap.totalStall);mark 的字段别贪多,固定枚举加两三个数值就够了,自由文本会让后端聚合时分不了组。summary() 返回的是当前会话内的聚合值,适合做上线前的自检面板:测试同学在弱网模拟器里走一遍完整流程,看一眼 P95 卡顿时长和平均播放速率,就知道这一版能不能发。
3.4 与播放器事件对齐
如果页面用的是带 ABR 的播放器,清晰度切换点必须和流速曲线对齐看。同一个时间点上,playRate 掉下去而 bufferAhead 没掉,八成是切换瞬间解码器重建导致的短时卡顿;如果 bufferAhead 也同步掉,那就是新码率的首个分片下载慢。插件本身不解析 manifest,但会把 playing、waiting、ratechange 这几个原生事件按时间戳记进队列,和采样点混在一起上报,服务端按 ts 排序就能还原现场。
4. 卡顿判定与上报聚合:从毛刺到可用曲线
4.1 waiting/playing 状态机与 seek 排除
光靠采样的 playRate 抓不住毫秒级卡顿,必须有事件级的状态机补位。video 元素在缓冲不足时会抛 waiting,恢复播放时抛 playing,两个事件的时间差就是这次卡顿的时长。逻辑简单,但有两个坑必须处理:seek 过程中也会触发 waiting,这不是卡顿;用户主动暂停后恢复,playing 前也可能带一次 waiting。
let stallStart = 0; let seeking = false; video.addEventListener('seeking', () => { seeking = true; }); video.addEventListener('seeked', () => { seeking = false; }); video.addEventListener('waiting', () => { if (seeking) return; // seek 期间的 waiting 不算卡顿 stallStart = performance.now(); }); video.addEventListener('playing', () => { if (!stallStart) return; const dur = performance.now() - stallStart; stallStart = 0; if (dur < cfg.stallThreshold) return; // 短于阈值的忽略 queue.push({ type: 'stall', duration: Math.round(dur), at: Date.now(), playhead: +video.currentTime.toFixed(1) // 卡在哪个时间点,定位问题时最有用 }); });playhead 这个字段看着不起眼,实际排查时价值最高。把所有用户的卡顿点按 playhead 做直方图,如果集中在 0~2 秒,那是首帧和起播缓冲的问题;如果集中在某个固定位置,多半是那一处的分片或关键帧有问题,跟用户网络无关。这个结论靠平均值是得不出来的。
4.2 采样节流与批量上报
一条采样 200 字节左右,1 秒一条,一个用户看 10 分钟就是 120KB,全量直传会把上报接口打穿。插件用攒批加定时兜底的策略:队列满 reportBatch 条就发,或者到 reportInterval 强制发,两个条件谁先满足走谁。发送优先用 navigator.sendBeacon,它在页面卸载时也能把数据送出去,这是 fetch 做不到的。
function flush(force) { if (!queue.length) return; if (!force && queue.length < cfg.reportBatch) return; const payload = JSON.stringify({ sid, // 会话 id,用于串联同一次播放 ua: navigator.userAgent, conn: navigator.connection ? navigator.connection.effectiveType : 'unknown', items: queue.splice(0, queue.length) }); if (navigator.sendBeacon) { navigator.sendBeacon(cfg.reportUrl, new Blob([payload], { type: 'application/json' })); } else { fetch(cfg.reportUrl, { method: 'POST', body: payload, keepalive: true, // 关键:允许请求在页面卸载后继续 headers: { 'Content-Type': 'application/json' } }); } } // 页面隐藏时强制冲一次,移动端切后台很可能直接被回收 document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flush(true); }); window.addEventListener('pagehide', () => flush(true));visibilitychange 和 pagehide 两个事件都要挂。移动端浏览器在内存吃紧时可能直接把后台页面杀掉,pagehide 都不一定触发,所以不能把 flush 全押在卸载事件上,reportInterval 的定时兜底才是主力。keepalive 也有体积限制,单请求一般不超过 64KB,攒批上限要按这个反推,别把队列堆到几百条再发。
4.3 上报字段与聚合口径
字段设计决定了后面能不能算出有用的结论。下面这几个是必须有的,其余的按需加。
| 字段 | 类型 | 用途 |
|---|---|---|
| sid | string | 会话唯一 id,同一次播放的所有数据靠它串起来 |
| playRate | number | 播放速率,用于算均值和 P5 分位 |
| bufferAhead | number | 缓冲余量,用于识别危险窗口 |
| stallDuration | number | 单次卡顿时长,用于算 P95 和卡顿率 |
| playhead | number | 卡顿发生位置,用于定位分片问题 |
| readyState | number | 采样时的就绪状态,辅助判断 |
聚合口径上,卡顿时长绝对不要用平均值。一百个用户里 95 个一次没卡、5 个卡了 20 秒,均值出来是 1 秒,看着一切正常,实际那 5 个人已经流失了。要看 P95 和卡顿率(发生过至少一次卡顿的会话占比),这两个数才是业务方真正关心的。播放速率倒是可以看均值,但要同时看 P5,均值 0.99 而 P5 是 0.6,说明有一批用户在被严重拖慢。
5. 避坑排查:五个让流速数据失真的现场
5.1 采集层的三个坑
现象一:本地跑得好好的,线上 playRate 常年稳定在 1.000,一点抖动都没有。原因不是播放真的完美,而是采样里混进了重复数据——某些播放器在暂停时会周期性重置 currentTime,或者页面用了双 video 标签做预加载,脚本 querySelector 抓到了那个隐藏的、根本没在播的元素。解决方法是 init 时校验 video.paused 和 video.readyState,并且在 sample 里对 deltaT 做上限保护,单拍推进超过 3 秒的直接丢弃,同时在 debug 模式下把元素的 src 打出来核对。
现象二:bufferAhead 这条曲线要么恒为 0,要么异常巨大。前者是取了 buffered.end(0),多段缓冲下第 0 段往往在播放开始后就被消耗完了;后者是没减去 currentTime,直接把 bufferedEnd 当余量报上去了。两种都是下标和公式写错,改法就是上面代码里那行 buffered.end(buffered.length - 1) 再减 currentTime,同时在 debug 面板里把 buffered.length 一起打出来,段数超过 5 就该怀疑服务端分片策略了。
现象三:帧率算出来只有源帧率的一半。这个坑最隐蔽,原因是 requestVideoFrameCallback 在某些环境下会对相同内容的帧做合并,presentedFrames 的增量比实际渲染帧数小。判断办法是拿 video.videoWidth 和 getVideoPlaybackQuality 的 totalVideoFrames 交叉验证,如果两者对不上,就以 droppedVideoFrames 的差值为准,帧率指标标记为「不可信」而不是报一个错数。
5.2 判定与上报层的两个坑
现象四:卡顿次数统计出来比用户实际感知多出一大截。除了前面说的 seek 没排除,还有一个来源是清晰度切换。ABR 切码率时播放器会短暂清空缓冲,触发一次 waiting,时长经常正好落在 300~500ms 这个阈值附近。解决办法是监听播放器的码率切换事件(或在 ratechange 前后打标),切换后 1 秒内的 waiting 单独归类为 switch_stall,不计入用户体感卡顿。
现象五:后端收到的会话数据总是缺最后一段,尤其是移动端。原因是用户切后台或直接关页面,队列里剩下的几条还没来得及发。这个问题没有完美解,只能多管齐下:visibilitychange 时 flush、pagehide 时 flush、reportInterval 调短、sendBeacon 兜底。上线后对比「有起播记录但无结束记录」的会话占比,这个比例超过 5% 就说明兜底还不够,得继续压 reportInterval。
注意:debug 模式一定要在灰度环境开,全量打开会让 console 输出成为性能瓶颈,反过来影响你正在测的播放性能。
6. 进阶:按播放进度分段对比,把曲线和真实观感对齐
把所有采样点按播放进度切成十段,每段单独算平均播放速率和卡顿率,这是我最常用的一招。整体数据看不出问题时,分段表往往一眼就能指出毛病在哪:如果第 1 段(0~10% 进度)的卡顿率是其他段的五六倍,那问题就在起播缓冲和首帧,跟中段网络无关;如果第 6、7 段异常高,多半是那个位置的码率档位切换或者分片有问题,可以拿着 playhead 直接去查源文件。
// 按播放进度分十段聚合,progress 取 0~9 function bucketOf(playhead, duration) { if (!duration) return -1; return Math.min(9, Math.floor((playhead / duration) * 10)); } function segmentReport(samples, duration) { const buckets = Array.from({ length: 10 }, () => ({ n: 0, rate: 0, stall: 0 })); samples.forEach(s => { const b = bucketOf(s.playhead ?? s.ts, duration); if (b < 0) return; buckets[b].n += 1; buckets[b].rate += s.playRate || 0; if (s.type === 'stall') buckets[b].stall += 1; }); return buckets.map((b, i) => ({ seg: `${i * 10}-${(i + 1) * 10}%`, avgRate: b.n ? +(b.rate / b.n).toFixed(3) : null, stallRate: b.n ? +(b.stall / b.n).toFixed(3) : null })); }再往前一步,是把流速数据和清晰度档位做交叉表。同一段进度上,720p 的播放速率是 1.0、1080p 掉到 0.85,那结论非常明确:这批用户的带宽扛不住高码率,要么下调默认档位,要么在起播时做一次快速探测再决定初始码率。这类结论靠单一的均值曲线永远推不出来,必须做二维切分。
最后说个验证习惯。插件报出来的数,一定要拿一次人工观感去校准:找一个弱网模拟环境,把带宽压到 1Mbps,自己完整看一遍,记下主观感知到的卡顿次数和位置,再和插件输出的 stall 记录逐条对。对不上的地方就是阈值要调的地方。从那以后我每次接入任何播放监控插件,都会先在本地把网络限速打到 1Mbps、CPU 降速 6 倍跑一遍全流程,确认 waiting、seeking、ratechange 这几个边界事件的记录都对得上,才敢让它进生产。希望帮到你。
本文还有配套的精品资源,点击获取