手上有个管理后台要加课程视频播放页,需求写得轻描淡写——"放个视频,带进度条和倍速"。真动手才发现,原生<video>标签在真实业务里几乎处处受限:m3u8 播不了、控制条样式各家浏览器长得不一样、清晰度切换要自己造轮子、播放失败连个像样的错误提示都拿不到。后来换成 video.js 重写了一遍,才算把 Vue 里的视频播放这件事做扎实。这篇就聊我在 Vue 项目里集成 video.js 的完整过程:组件怎么封装才不泄漏内存、播放源怎么按格式分流、控制条怎么改成贴合自家设计的样子、以及那些只在真机上才会冒出来的坑。前端初中级、正在给项目加视频模块的同学可以直接照着做,踩过的坑我也都写进去了。
1. 先想清楚:为什么在 Vue 里还要额外引一个播放器
1.1 原生 video 标签在业务里的四道坎
很多人第一反应是"不就是个 video 标签吗"。单机播放本地 mp4,确实够了。但只要业务稍微复杂一点,问题就成串出现。
第一道坎是流媒体格式。现在视频平台基本都用 HLS 切片分发,地址后缀是 m3u8。桌面端 Chrome、Firefox 对 m3u8 支持非常有限,你直接把 m3u8 塞进src,控制台不会报错,播放器就是一片黑,error事件也可能不触发,排查起来非常难受。要么引 hls.js 做 MSE 接管,要么找个已经把这层包好的播放器。
第二道坎是 UI 一致性。原生控制条长什么样完全由浏览器决定,Safari 一套、Chrome 一套、安卓 WebView 又是另一套,设计稿上的圆角进度条、悬浮控制栏基本没法还原。你可以加controls之外自己画,但自己画就意味着进度拖拽、缓冲区间显示、音量、全屏、键盘快捷键这些全要自己实现,工作量并不小。
第三道坎是事件与状态。原生 video 的事件命名不统一,loadedmetadata、canplay、canplaythrough的触发时机在不同浏览器上差异明显,加载失败时error对象里的code含义也得自己查表翻译。要做"加载中骨架""重试按钮""错误文案",靠原生事件拼会比较脏。
第四道坎是可扩展。倍速、画中画、字幕、截图、清晰度切换、播放进度记忆、埋点上报,这些能力原生要么没有,要么各浏览器支持程度不一。每一个都得单独做兼容,散在业务代码里很快就失控。
1.2 三个候选方案的取舍,我为什么选 video.js
当时摆在面前的选择有三个,我简单对比了一下。
| 方案 | 优点 | 短板 | 适合场景 |
|---|---|---|---|
| 原生 video + 手写控制条 | 零依赖、体积最小 | 兼容成本高,扩展全靠自己 | 简单预览、短视频列表 |
| video.js | 生态成熟、控件体系完整、插件多、内置 HLS | 包体偏大、样式全局污染需处理 | 中后台、教育、点播类业务 |
| 轻量播放器库 | 体积小、API 简洁 | 自定义控件能力弱,复杂需求要二次开发 | 移动端 H5 播放页 |
选 video.js 的核心原因,是它把"播放器"抽象成了一个可以增删组件的对象,而不是一个黑盒。player上有getChild、addChild、registerComponent这套东西,意味着我要在控制条上加一个"笔记"按钮、要把倍速菜单改成中文标签、要在右下角挂一个自定义水印,都不用改源码,注册组件挂上去就行。这个可扩展性在业务迭代里价值很大。
另一个现实考量是 HLS。video.js 从 7 开始内置了 VHS(HTTP Streaming 模块),m3u8 开箱可用,不需要额外装 hls.js 再手动桥接。少一层依赖就少一类诡异问题。
代价也要说清楚:video.js 完整包压缩后依然不小,如果你的页面只是偶尔弹个视频预览,用它有点重,这种情况我会建议按需动态加载,别打进首屏。
2. 装依赖和理清对象模型,别急着写组件
2.1 版本选择和安装
现在主流是 8.x,Vue 3 项目直接装:
npm install video.js样式文件必须单独引,这是新手最容易漏的一步——不引样式,播放器能播,但所有控件会堆在页面上,看着像坏了:
import videojs from 'video.js' import 'video.js/dist/video-js.css'如果你用的是 Vue 2,8.x 一样能用,API 没有本质差别,只是组件写法换回 Options API 而已。版本上我建议锁死小版本,video.js 在 7 到 8 之间改过一些控件类的命名,跨大版本升级时自定义组件的className可能要跟着调。
还有一个细节:video.js/dist/video-js.css是全局样式,里面定义了.video-js及其子元素的一大堆规则。它不一定和你的 UI 框架冲突,但一定会在全局作用域里存在。后面第 5 节我会讲怎么把它约束住。
2.2 player 到底是什么:三个必须先搞懂的概念
不搞懂这三个东西,后面所有问题都会变成玄学。
player 实例。videojs(el, options, readyCallback)返回的就是 player。它不是 DOM,也不是 video 元素,而是一个包装对象。调用player.play()、player.currentTime()、player.src()走的是它的方法,底层才转给真正的<video>元素。
tech(技术层)。player 底下挂着一个 tech,HTML5 环境下就是Html5tech,它才是真正持有<video>DOM 的那一层。要拿原生元素,用player.el().querySelector('video')或player.tech().el()。截图、跨域设置、playsinline这类原生属性操作,都得下沉到这层。
组件树。控制条、大播放按钮、进度条、音量、字幕菜单,在 video.js 里都是组件,挂在 player 的 children 上。player.controlBar是控制条组件,player.controlBar.playToggle是播放按钮。你要隐藏某个控件,最干净的做法是player.controlBar.removeChild('PictureInPictureToggle'),而不是用 CSSdisplay:none硬摁——CSS 摁掉后组件还在,键盘 Tab 依然能聚焦到它,无障碍上是个瑕疵。
我见过最多的事故是:把videojs()的返回值随手存在一个局部变量里,组件卸载时没 dispose。结果路由来回切几次,页面上看不见播放器,内存里堆了五六个 player 实例,每个都在后台跑定时器、监听 resize,最后页面卡到爆。第 3 节会把销毁这件事写死。
3. 封装一个真正能复用的 Vue 播放器组件
3.1 模板结构和初始化时机
模板部分我建议用原生 video 标签作为容器,让 video.js 去接管它:
<template> <div class="player-wrapper" :style="wrapperStyle"> <video ref="videoRef" class="video-js vjs-big-play-centered" playsinline webkit-playsinline x5-playsinline ></video> </div> </template>vjs-big-play-centered让大播放按钮居中,不加的话它默认在左上角,视觉上很怪。playsinline系列属性是为了让 iOS 和部分安卓 WebView 在小窗内播放而不是强制全屏——这个属性如果只在 JS 里设,有时会被 tech 初始化覆盖,所以模板上先写一遍更稳妥。
初始化时机很关键。不要在setup的同步代码里调videojs(),那时候 DOM 还没挂上。用onMounted加一层nextTick:
onMounted(() => { nextTick(() => { initPlayer() }) })nextTick在这里不是玄学保险,而是有实际作用的:如果你的播放器外面包了v-if、v-show或者处于transition动画中,DOM 的实际尺寸在 mounted 那一刻可能还是 0,video.js 会按 0 宽高算布局,导致进度条挤成一团。多等一个 tick,容器尺寸稳定了再初始化,这类"控件错位"问题基本不会出现。
3.2 props 设计:把可变的东西都外置
组件要复用,就不能把源地址、封面、自动播放这些写死。我的 props 大概是这个样子:
const props = defineProps({ src: { type: String, required: true }, type: { type: String, default: '' }, poster: { type: String, default: '' }, autoplay: { type: Boolean, default: false }, muted: { type: Boolean, default: false }, startTime: { type: Number, default: 0 }, options: { type: Object, default: () => ({}) } })其中type留空是有意为之,让组件自己去猜 MIME。原因很实际:后端返回的播放地址经常带一堆 query 参数,?sign=xxx&t=123,你在调用处手写type很容易写错,写错了 video.js 会走错解析分支,表现就是一直转圈。猜类型比人靠谱。
options这个 prop 是逃生舱。基础配置在组件里定好,特殊页面要传特殊配置时,直接在外部展开覆盖:
const player = videojs(videoRef.value, { controls: true, preload: 'auto', fluid: true, aspectRatio: '16:9', playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2], ...props.options }, () => { emit('ready', player) })这里fluid: true配合aspectRatio: '16:9'是个很实用的组合,播放器宽度跟随父容器,高度按比例自动算,不用写一行响应式 CSS。注意fluid和fill是互斥的,两个都开会出现高度计算打架,画面被拉伸。
3.3 生命周期与销毁:内存泄漏的重灾区
这段代码是整个组件里最不能省的部分:
onBeforeUnmount(() => { if (player) { try { player.pause() } catch (e) { // 播放器可能已处于异常状态,忽略 } player.dispose() player = null } })dispose()会做这些事:移除所有事件监听、清掉定时器、销毁组件树、把包装出来的 DOM 从页面上摘掉。少了它,video.js 内部注册的resize监听、VHS 的缓冲轮询都不会停。
注意:
dispose()之后不能再访问 player 的任何方法,访问会直接抛错。所以我在调用后立刻置 null,后续所有逻辑都加if (!player) return的守卫。
还有一个坑值得单独说:重复初始化。同一个 DOM 节点被videojs()调用两次,第二次会拿到一个已经初始化过的元素,行为不可预期,常见表现是控制条渲染两份或者直接报错。稳妥的写法是先查再建:
function initPlayer() { if (!videoRef.value || player) return const existed = videojs.getPlayer(videoRef.value) if (existed) { existed.dispose() } player = videojs(videoRef.value, { /* ... */ }) }videojs.getPlayer()传 DOM 元素也能查,这个用法文档里提得不多,但在 KeepAlive 场景下救过我一次——页面被缓存后重新激活,DOM 还在,player 却被上一轮的销毁逻辑干掉了,重新初始化时如果不先清残留,播放器会处于半死状态。
至于 KeepAlive,我的建议是onActivated里检查 player 是否存在,不存在就重建,onDeactivated里暂停播放但不销毁。全销毁再重建的代价是每次切回来都要重新拉流,体验很差。
4. 播放源适配:mp4、m3u8 和换源的正确姿势
4.1 MIME 类型判断,别靠肉眼
前面说了让组件自己猜类型,具体实现就是一个后缀映射:
function guessType(url) { if (!url) return 'video/mp4' const clean = url.split('?')[0].split('#')[0].toLowerCase() if (clean.endsWith('.m3u8')) return 'application/x-mpegURL' if (clean.endsWith('.mpd')) return 'application/dash+xml' if (clean.endsWith('.webm')) return 'video/webm' if (clean.endsWith('.ogv')) return 'video/ogg' if (clean.endsWith('.mov')) return 'video/quicktime' return 'video/mp4' }先切掉 query 和 hash 再判断,这一步很多人会漏。曾经有个接口返回的地址是xxx.mp4?auth=abc.m3u8这种形式,不切参数就会误判成 HLS,然后播放器一直卡在加载。
设置源的标准写法:
player.src({ src: props.src, type: props.type || guessType(props.src) })也可以传数组,video.js 会按顺序挑第一个能播的。多清晰度场景下,如果不想自己做切换 UI,直接把多个 source 塞进去,浏览器会选它支持的格式——但注意这不会生成清晰度菜单,只是格式兜底,别混淆。
4.2 换源保持进度:一个必须处理的体验细节
业务里换源太常见了:切清晰度、切换下一集、用户手动选线路。直接player.src(newUrl)会导致进度清零、播放状态变回暂停。用户点了切高清,结果回到第一秒,这个体验基本等于 bug。
我的处理模板是记录再恢复:
function switchSource(url, type) { if (!player || !url) return const resumeAt = player.currentTime() || 0 const wasPlaying = !player.paused() const rate = player.playbackRate() player.src({ src: url, type: type || guessType(url) }) player.one('loadedmetadata', () => { const duration = player.duration() if (resumeAt > 0 && Number.isFinite(duration) && resumeAt < duration - 1) { player.currentTime(resumeAt) } player.playbackRate(rate) if (wasPlaying) { player.play().catch(() => {}) } }) }几个点解释一下。用player.one而不是player.on,避免每次换源都叠加一个监听。判断resumeAt < duration - 1是为了防止源文件比原视频短时,seek 到超出末尾导致直接触发ended。playbackRate也要恢复,因为部分浏览器在换源后会重置成 1。play()返回 Promise,被自动播放策略拦截时会 reject,必须 catch,否则控制台一片红。
提示:
Number.isFinite(duration)这个判断不能省。直播流或者元数据还没就绪时,duration()返回的是Infinity或NaN,直接比较会得到意料之外的结果。
4.3 直播流的特殊处理
如果源是直播,有两个地方要改。一是控制条上不要显示进度条和总时长,二是缓冲策略要调。隐藏进度条用组件移除的方式:
player.ready(() => { const bar = player.controlBar bar.removeChild('ProgressControl') bar.removeChild('RemainingTimeDisplay') })缓冲策略在html5.vhs里配:
html5: { vhs: { overrideNative: true, maxPlaylistRetries: 3, liveRangeSafeTimeDelta: 0.5 } }overrideNative: true的意思是强制用 VHS 接管,而不是让浏览器走原生 HLS 播放。这个开关在 Safari 上尤其重要——Safari 原生支持 HLS,会绕过 VHS,导致你基于 VHS 做的清晰度切换、错误处理全部失效。代价是 MSE 的性能略逊于原生,需要自己权衡。
5. 控制条改造:让播放器长得像自家产品
5.1 样式覆盖的正确层级
video.js 的默认皮肤是深色半透明,如果产品是浅色风格,直接改会很别扭。有两种改法。
一种是覆盖 CSS,注意作用域。在 Vue 的 scoped style 里,必须用深度选择器,否则选择器打不进 video.js 内部生成的 DOM:
.player-wrapper :deep(.video-js) { width: 100%; font-family: inherit; } .player-wrapper :deep(.vjs-control-bar) { background: linear-gradient(transparent, rgba(0, 0, 0, 0.65)); height: 44px; } .player-wrapper :deep(.vjs-big-play-button) { width: 64px; height: 64px; line-height: 64px; border-radius: 50%; border: none; background: rgba(0, 0, 0, 0.5); margin-left: -32px; margin-top: -32px; }大播放按钮那个margin-left/margin-top必须跟宽高联动,用百分比写会错位。这是很常见的翻车点:改了宽高忘了改负 margin,按钮就跑偏到右下角去了。
另一种是换主题类。video.js 生态里有几套官方主题包,装完之后在播放器容器上加对应的类名即可,适合不想手写一大堆 CSS 的项目。不过主题包能改的有限,品牌色特别鲜艳的话还是得自己覆盖。
5.2 注册自定义控件
产品经理常见的需求是"控制条上加个截图按钮"或者"加个记笔记"。video.js 的组件注册就是为这个设计的:
const Component = videojs.getComponent('Component') const Button = videojs.getComponent('Button') class SnapshotButton extends Button { constructor(player, options) { super(player, options) this.controlText('截图') } buildCSSClass() { return 'vjs-snapshot-control vjs-control vjs-button' } handleClick() { this.player_.trigger('snapshot') } } videojs.registerComponent('SnapshotButton', SnapshotButton)然后在初始化时插进控制条:
player.ready(() => { player.controlBar.addChild('SnapshotButton', {}, player.controlBar.children().length - 2) })addChild的第三个参数是位置索引。放在倒数第二位,是因为最后一位通常留给全屏按钮,截图插在它前面视觉上更顺。这个索引别写死成数字,用children().length动态算更稳。
截图本身要注意跨域。如果视频源在另一个域名,浏览器出于安全策略会把 canvas 标记为污染,toDataURL直接抛错。解决办法是给 video 元素设crossOrigin = 'anonymous',同时服务端必须返回允许跨域的响应头,两者缺一不可:
player.ready(() => { const videoEl = player.el().querySelector('video') videoEl.crossOrigin = 'anonymous' })这个属性和src的加载顺序有讲究,必须在设置 src 之前设,否则第一次加载不会带上 CORS 头,截图依然失败。
5.3 倍速菜单中文化
playbackRates传到 2.0 之后,菜单里显示的是 "2x" 这种英文格式。要做中文可以在播放器 ready 后遍历菜单项改文本:
player.ready(() => { const menu = player.controlBar.getChild('PlaybackRateMenuButton') if (!menu || !menu.items) return menu.items.forEach((item) => { if (item.el_) { const rate = parseFloat(item.label_ || item.options_.label) item.el_.textContent = `${rate}倍速` } }) })这段代码依赖内部属性,跨大版本升级时有失效风险,所以务必加空值守卫。更稳的做法是自己写一个菜单按钮组件,完全掌控渲染,代价是要多写几十行。
6. 问题排查实录:这些坑我都踩过
6.1 初始化阶段的典型故障
| 现象 | 高概率原因 | 处理方式 |
|---|---|---|
| 控件全部堆在页面上,没有样式 | 没引 video-js.css | 补上样式导入 |
报错找不到vjs_getProperty | 同一节点重复初始化 | 先getPlayer判断再 dispose |
页面报window is not defined | SSR 环境直接 import | 改动态 import 或客户端渲染 |
| 播放器宽度正常,高度为 0 | 容器初始尺寸为 0 | nextTick 后再初始化,或给容器定高 |
| 大播放按钮跑到角落 | 改了宽高没同步负 margin | 联动调整 margin |
| 多个播放器互相干扰 | 容器 id 重复 | 取消手写 id,交给 video.js 生成 |
SSR 这条单独说一下。如果项目用服务端渲染,import videojs from 'video.js'在 Node 环境执行时就会碰window,页面直接 500。处理办法是延迟到客户端:
let player = null onMounted(async () => { const { default: videojs } = await import('video.js') await import('video.js/dist/video-js.css') player = videojs(videoRef.value, { /* ... */ }) })动态导入同时解决了首屏体积问题,一举两得。
6.2 播放阶段的排查顺序
播放失败时不要瞎试,按这个顺序查效率最高。
第一步看player.error()。在error事件里打出来:
player.on('error', () => { const err = player.error() console.warn('播放器错误', err && err.code, err && err.message) })错误码含义:1 是加载被中止,2 是网络错误,3 是解码失败,4 是格式不支持。遇到 4,先确认 MIME 类型猜对了没有;遇到 2,去看是不是签名过期或者跨域被拦。
第二步看网络面板。HLS 的话会有 m3u8 索引请求加一堆 ts 分片请求,如果索引请求返回 403,那是鉴权问题,跟播放器没关系;如果索引返回 200 但分片全挂,大概率是 CDN 的防盗链没放行。
第三步看自动播放策略。现在浏览器普遍禁止带声音的自动播放,autoplay: true加muted: false的组合在大多数场景下会被静默拦截。表现是画面停在首帧,控制台有一条 "play() failed" 警告。解法是先静音再播:
player.muted(true) player.play().catch(() => { player.muted(false) })先静音满足策略,播起来之后如果用户有交互,再取消静音。这个套路在弹窗型播放场景里基本是标配。
6.3 移动端专属的几个坑
iOS 上全屏行为不受控。用player.requestFullscreen()在 iOS 上会走原生全屏,此时你的自定义控件全部失效,控制权交回系统。如果产品要求全屏也要显示自定义控件,得做"伪全屏"——把播放器容器position: fixed铺满视口,然后自己加退出按钮。
安卓 WebView 上,X5 内核需要x5-playsinline属性才能真正内联播放。另外部分机型在视频开始播放后会触发页面重绘,导致滚动位置跳变,处理方式是把播放器容器设成独立层,加transform: translateZ(0)提升合成层。
还有一点,移动端timeupdate的触发频率比桌面端低,用它驱动进度条会出现"一顿一顿"的更新。要做丝滑进度条,用requestAnimationFrame在播放中主动读currentTime(),暂停时停止循环。这个改动不大,但对观感提升很明显。
7. 工程化收尾:体积、多实例和监控
7.1 按需加载与多实例管理
video.js 不适合无脑打进首屏。如果视频模块只是详情页里的一个 tab,用动态 import 把播放器拆成独立 chunk,用户点开视频再加载,首屏能省下好几百 KB。
多实例的场景要格外小心。列表页里每个卡片一个预览播放器,十几个实例同时初始化,再加上每个都在预加载,带宽和内存都会炸。我的做法是"单实例复用":只保留一个播放器实例,切换卡片时用switchSource换地址,而不是销毁重建。这样既省资源,又不会出现十几个 player 同时在后台跑缓冲。
如果确实需要多实例,给每个实例一个独立的容器 id 或者干脆都不写 id,同时限制同时预加载的数量,超过就设置preload: 'none'。
7.2 埋点与错误上报
播放相关的数据对业务很有价值:起播耗时、卡顿次数、完播率。这几个都能从事件里拿。
起播耗时是play事件触发时的时间戳减去初始化时间戳。卡顿用waiting事件计数,每触发一次加一。完播看ended。播放失败上报error事件里的 code 和 message,同时带上 src 的前半段(别带完整签名),方便定位是哪个源出问题。
有个细节容易忽略:用户频繁拖动进度条会短时间内触发大量seeking事件,直接往上报接口打会把服务打崩。加个节流,或者只在seeked时上报一次最终位置。
我在实际用下来最大的体会是,video.js 这类库的价值不在于它替你把播放做完了,而在于它把播放器拆成了可组装的零件。控件要改、行为要改、数据要采,都能找到挂载点,不用去动源码。真正花时间的从来不是初始化那几行代码,而是销毁时机、换源恢复、跨域截图这些边角,而这些恰恰是文档里最不会细说的部分。要是你的项目里视频模块不止一处,早点把播放器封成一个带 props 和完整生命周期的组件,后面每次加需求都是在调参数,而不是重写。