做 Vue2 项目这几年,视频模块基本是绕不过去的一关。不管是后台管理系统里的课程回放、电商详情页的商品视频,还是监控看板里的实时画面,最后都会落到同一个需求上:在页面里塞一个能播 HLS、能播 FLV、能切清晰度、能记住上次看到哪儿的播放器。行业里最常见的做法就是接一套云厂商提供的网页播放器 SDK(下文统称“云端播放器组件 SDK”),然后在 Vue2 里再包一层自己的播放器组件。这层封装看起来简单,但我见过太多项目把它写成了“能播就行”的状态:切路由不销毁实例、同一个页面开两个播放器互相抢事件、封面加载完还在闪、直播流断了不会重连、切个清晰度进度条直接归零。
这篇要聊的就是这层封装怎么包得靠谱。它不是什么高深技术,但坑特别密集,涉及 SDK 的异步加载时机、实例与 DOM 的绑定关系、Vue2 生命周期与第三方实例生命周期的对齐、事件透传的设计、以及直播和点播两条完全不同链路的参数取舍。适合正在做 Vue2 项目、需要接入视频播放能力的前端同学,也适合已经接完了但被各种线上问题反复骚扰、想回头重构一版的人。下面我把整套思路、代码骨架、参数计算和踩过的坑一次性摊开讲。
1. 先搞清楚:为什么要在 Vue2 里再包一层播放器组件
1.1 直接照抄官方示例的三个现实痛点
官方 SDK 的示例代码通常是这样的:页面里放一个<div id="player-container">,然后在script里new Player('player-container', {...}),完事。这个写法在单页静态页面里没问题,但搬到 Vue2 项目里立刻会遇到三类问题。
第一类是 DOM 选择器污染。官方示例用 ID 找容器,可 Vue 组件是可以复用的,一个页面渲染两个播放器组件,getElementById永远只会拿到第一个,第二个播放器要么不渲染,要么把第一个的实例给覆盖掉。我见过一个后台项目,列表页里每行都有个小预览播放器,结果点第二行的播放按钮,第一行开始播了,排查了半天才发现是 ID 重复。
第二类是数据驱动失效。Vue 的核心是“数据变了视图跟着变”,但第三方播放器实例是跑在框架之外的。你在业务里把videoUrl从 A 改成 B,Vue 会重新渲染组件,可播放器实例压根不知道源变了,画面还停在 A 上。于是你被迫在每个业务页面里写watch,手动调setSource,一个项目写十几遍,总有一处漏掉。
第三类是生命周期失控。播放器实例持有大量资源:解码器、网络连接、定时器、requestAnimationFrame循环。Vue 组件销毁的时候只负责把 DOM 干掉,实例本身还在后台跑。路由来回切二十次,页面上看不见播放器了,但内存里躺着二十个实例,风扇开始转,控制台开始报WebGL context lost。
1.2 封装层该管什么、不该管什么
我个人的划分原则很明确:封装层负责“让播放器像一个正常的 Vue 组件一样工作”,业务层负责“播放器放什么内容、什么时候放”。具体来说,封装层应该承担这几件事——实例的创建与销毁、SDK 脚本的加载与单例管理、props 变化到 SDK 方法调用的映射、SDK 事件到 Vue 自定义事件的透传、常见错误的兜底提示、以及全屏/画中画这类与浏览器 API 交互的逻辑。
反过来,有几件事千万别塞进封装层。一是播放凭证的获取,这属于接口请求,应该由业务层拿到 token 后再通过 prop 传进来,封装层只负责透传给 SDK;二是业务埋点,比如“用户看到第 30 秒上报一次”,这种逻辑和具体业务强绑定,放进通用组件里迟早要改;三是过度定制的皮肤,如果你的产品只在一个页面用,那随便怎么改都行,但如果是多页面复用的基础组件,皮肤定制应该通过插槽或者独立样式文件来做,别把结构和皮肤焊死在一起。这个边界划清楚,后面所有设计都会顺很多。
2. 接入方案选型:三种引入方式怎么挑
2.1 全局 script 引入与 npm 包的取舍
云端播放器组件 SDK 一般提供两种分发形式:一种是 CDN 上的独立 JS 文件,通过<script>挂到window上;另一种是 npm 包,可以import进来。这两种方式各有适用场景,我整理了一张对比表:
| 对比维度 | 全局 script 引入 | npm 包引入 |
|---|---|---|
| 接入成本 | 低,加一行标签即可 | 中,需要处理构建配置 |
| 版本管理 | 弱,靠手动改 URL | 强,走 lock 文件锁定 |
| 打包体积 | 不进入业务 bundle,走外部缓存 | 默认进 bundle,需配合 externals |
| 类型提示 | 无,需要自己写 d.ts | 包内通常自带 |
| 首屏影响 | 阻塞或异步,需自行控制 | 随主包一起加载 |
| 样式资源 | 在 HTML 里单独引 CSS | 需要额外处理 CSS 与静态资源路径 |
| 多项目复用 | 好,统一 CDN 地址即可 | 好,但要统一版本 |
我的选择习惯是:中后台项目、公司内部有多套系统共用一份播放器资源的,走全局 script + webpackexternals,好处是多个子系统共享浏览器缓存,SDK 升级只改一处 CDN 地址;面向 C 端的独立项目,走 npm 包更省心,版本可追溯,出问题能快速回滚。
这里有个细节要注意:走externals的时候,配置写成externals: { 'cloud-player': 'CloudPlayer' },意思是“遇到import CloudPlayer from 'cloud-player'时,不要打包,运行时去window.CloudPlayer上取”。这个映射名必须和 SDK 挂到window上的变量名完全一致,大小写都不能差,错了不会报编译错误,只会在运行时undefined,很难查。
2.2 按需异步加载与单例管理
不管走哪种方式,只要 SDK 体积超过 200KB,我都建议做异步加载,别让它拖慢首屏。异步加载最大的坑是并发:页面上三个组件同时mounted,每个都去插一个<script>标签,SDK 被加载三遍,第三遍还会覆盖前两遍已经创建好的实例。
解决思路是“单例 Promise”。用一个模块级的变量存住加载中的 Promise,后来者直接复用,加载完成后再统一回调。大概长这样:
// utils/player-loader.js let loadPromise = null export function loadPlayerSDK() { if (window.CloudPlayer) return Promise.resolve(window.CloudPlayer) if (loadPromise) return loadPromise loadPromise = new Promise((resolve, reject) => { const script = document.createElement('script') script.src = process.env.VUE_APP_PLAYER_CDN + '/player.min.js' script.async = true script.onload = () => { if (window.CloudPlayer) { resolve(window.CloudPlayer) } else { loadPromise = null reject(new Error('SDK_LOADED_BUT_EMPTY')) } } script.onerror = () => { loadPromise = null reject(new Error('SDK_LOAD_FAILED')) } document.head.appendChild(script) }) return loadPromise }这段代码里有两个不起眼但很关键的处理。一是加载失败时要把loadPromise置回null,否则一次网络抖动会导致整个会话期间所有播放器都起不来,用户只能刷新页面。二是onload之后二次校验window.CloudPlayer是否存在,因为 CDN 被拦截或者返回了一个 HTML 错误页的时候,onload是会触发的,但变量是空的,这种情况如果不校验,后面new CloudPlayer()就会抛出一个完全看不懂的not a constructor。
2.3 样式与静态资源路径的那些坑
播放器 SDK 通常依赖一份 CSS 和一些静态资源(控制条图标、加载动画、字体)。走 npm 包的时候,这些资源的路径往往是相对于 JS 文件的,经过 webpack 打包后路径会变,控制条上的图标就全变成方块了。
处理方式有两种。一种是把 SDK 的样式和资源整目录拷到public/下,在index.html里直接引,路径绝对可控;另一种是配置publicPath,但这要求 SDK 内部支持运行时配置,不是所有版本都行。我一般用第一种,虽然土,但最不容易出问题。
还有一个容易忽略的点:SDK 的 CSS 全局污染。有些版本的播放器样式会写video { ... }这种标签选择器,一旦引入,项目里所有<video>标签都会被影响。遇到这种情况,用scoped是没用的,因为你没法给第三方样式加scoped。可行的做法是把播放器组件挂载在一个独立的容器里,给容器加个唯一 class,然后用 PostCSS 插件(比如postcss-plugin-prefix-selector)在构建时自动给 SDK 样式加上这个前缀。这一步投入不大,但能省掉后面一堆“为什么我页面里别的视频变形了”的排查。
3. 组件骨架设计:props、事件、方法三层接口
3.1 props 设计:把业务意图翻译成 SDK 参数
props 是组件对外的契约,设计得好,业务层用起来就像用原生<video>标签;设计得烂,业务层就得对着 SDK 文档自己拼参数。我常用的这一组,覆盖了 90% 场景:
| prop 名 | 类型 | 默认值 | 作用 | 对应的 SDK 参数 |
|---|---|---|---|---|
source | String | '' | 播放地址,支持 mp4/m3u8/flv | source |
isLive | Boolean | false | 是否直播模式 | isLive |
autoplay | Boolean | false | 是否自动播放 | autoplay |
cover | String | '' | 封面图地址 | cover |
definition | String | 'auto' | 初始清晰度 | definition |
token | String | '' | 播放凭证,透传给 SDK | vid/playauth |
progressKey | String | '' | 断点续播的存储键 | 无,组件内部用 |
preload | Boolean | true | 是否预加载 | preload |
reconnect | Boolean | true | 直播断流是否自动重连 | 无,组件内部用 |
这里有几个设计考虑值得展开。source用 String 而不是 Object,是因为大部分场景只需要一个地址;但如果你需要一次性把多个清晰度的地址都传进去,可以再补一个sourceList的 Array 类型 prop,两者互斥,sourceList优先。progressKey是一个纯组件层的概念,业务方只要传一个能唯一标识这个视频的字符串(比如课程 ID + 章节 ID),组件就会自动往 localStorage 里记进度,下次进来自动 seek 回去,业务层完全不用写这段逻辑。
token这个 prop 单独说一下。很多带加密的播放器需要后端下发一个有时效的播放凭证,业务层拿到凭证后再传给组件。这里千万别在组件内部去请求凭证,一旦这么做,组件就和你的接口耦合了,换个项目根本复用不了。凭证的生命周期管理、过期刷新,都应该是业务层的事。
3.2 事件透传:别让业务层去监听原生 DOM
SDK 的事件体系通常有十几二十个,ready、play、pause、ended、timeupdate、seeking、seeked、error、resolutionChanged、liveStreamStop等等。这些事件如果全部原样透传,业务层会很痛苦;如果只透传几个,又不够用。
我的做法是“标准事件全透传 + 语义化事件再包装”。标准事件用$emit原样抛出,命名和 SDK 保持一致,业务层需要做精细控制的时候能拿到;同时再包装两三个业务真正关心的语义化事件,比如@video-ended表示“正常播完”、@video-error表示“当前源不可用需要降级”。后者的价值在于,SDK 的原始 error 回调里参数结构复杂,业务层每处都要判断错误码,包一层之后业务层只关心“能不能继续”,判断逻辑收在组件内。
透传的实现上,我用一个事件名的数组统一绑定,避免手写二十个on:
const SDK_EVENTS = [ 'ready', 'play', 'pause', 'ended', 'timeupdate', 'seeking', 'seeked', 'waiting', 'error', 'resolutionChanged' ] // 绑定 SDK_EVENTS.forEach(name => { this.player.on(name, payload => { this.$emit(name, payload) }) })注意这里存的是箭头函数,销毁的时候没法解绑,因为每次forEach产生的都是新引用。所以更严谨的写法是先把回调存到一个对象里:
this._handlers = {} SDK_EVENTS.forEach(name => { const handler = payload => this.$emit(name, payload) this._handlers[name] = handler this.player.on(name, handler) }) // beforeDestroy Object.keys(this._handlers).forEach(name => { this.player.off(name, this._handlers[name]) })这个细节看起来啰嗦,但它是内存泄漏的主要来源之一,后面第 6 节还会展开。
3.3 通过 ref 暴露方法:把常用操作做成一行调用
有些操作不适合用 props 表达,比如“跳到第 60 秒”、“截一张当前画面”、“立刻暂停”。这类命令式操作应该通过ref暴露方法,业务层用this.$refs.player.seekTo(60)这样调用。
组件内部实现很简单,就是把 SDK 的方法包装一层,加上必要的保护:
methods: { seekTo(seconds) { if (!this.player || !this.isReady) return const duration = this.player.getDuration() || 0 if (duration && seconds >= duration - 1) return // 防止跳转到末尾触发 ended this.player.seek(seconds) }, snapshot() { if (!this.player || !this.isReady) return null return this.player.getSnapshot() // 返回 base64 }, play() { if (!this.player || !this.isReady) return this.player.play() } }那段seekTo里的边界判断是实打实踩出来的。SDK 的seek如果传的值刚好等于总时长,有些版本会直接触发ended事件,导致业务层的“播完自动跳下一集”逻辑被误触发,用户点了个进度条,直接跳到下一集去了。所以跳转前要留 1 秒余量。
4. 生命周期与实例管理:最容易翻车的地方
4.1 mounted 到 beforeDestroy 的正确顺序
播放器实例的创建时机必须在 DOM 已经渲染之后,所以是mounted。但mounted的时候 SDK 可能还没加载完,所以要等loadPlayerSDK()的 Promise resolve 之后再创建实例。这中间有一个坑:如果用户在 SDK 加载完成之前就切走了页面,组件已经销毁了,Promise 回调里再去操作this.$el就会报错。
处理方式是加一个销毁标记:
async mounted() { this._destroyed = false try { await loadPlayerSDK() if (this._destroyed) return // 组件已销毁,直接放弃 this.initPlayer() } catch (e) { this.loadError = true } }, beforeDestroy() { this._destroyed = true this.destroyPlayer() }销毁顺序也很讲究,正确的顺序是:解绑所有事件 → 暂停播放 → 清空源 → 调用dispose()→ 置空实例引用。少一步都可能留下残余。特别是dispose()之后再解绑事件是无效的,因为实例内部的对象已经被清理了,off会抛错。
destroyPlayer() { if (!this.player) return try { Object.keys(this._handlers || {}).forEach(name => { this.player.off(name, this._handlers[name]) }) this.player.pause() this.player.dispose() } catch (e) { // 容错,避免一个异常阻断整个销毁流程 } finally { this.player = null this._handlers = null this.isReady = false } }try/catch/finally这一段不是防御性编程的洁癖,是因为dispose()在某些异常状态下(比如流媒体连接还挂着)会抛错,如果不兜住,后面组件树里其他组件的beforeDestroy都可能受影响。
4.2 keep-alive 缓存下的实例复用问题
后台项目大量使用keep-alive,页面 A 切到页面 B 再切回来,组件不会重新mounted,而是走activated。这时候播放器实例是保留的,看似省事,实则埋雷:如果页面 B 也用播放器,两个实例同时存在,B 的音频可能还在播,切回来之后 A 和 B 的声音叠在一起。
我的处理是在activated和deactivated里做“暂停/恢复”,而不是重新创建:
activated() { if (this.player && this.keepAliveResume) { this.player.play() } }, deactivated() { if (this.player) { this.keepAliveResume = !this.player.paused() this.player.pause() } }这里用keepAliveResume记录切走前是不是在播,切回来才决定要不要恢复播放,不能无脑调play(),否则用户明明是暂停着离开的,回来自己开始播了,体验很怪。
还有一个更隐蔽的问题:keep-alive缓存的组件,$el会被移出文档流,但播放器实例内部的一些尺寸计算(比如全屏切换后的宽高)会失效。切回来之后画面比例可能是错的,需要手动触发一次resize:
activated() { this.$nextTick(() => { if (this.player) this.player.setPlayerSize() }) }4.3 多实例与单页多播放器的资源占用
一个页面同时放多个播放器,最常见于监控墙、课程列表预览。硬件解码资源是有限的,Chrome 在同时解码超过 16 个视频流的时候,后面的会直接黑屏或者卡住。
可行的做法有三条。第一,列表里的预览播放器默认不加载 SDK,只显示封面图,hover或者点击时才初始化,离开时销毁,用v-if控制而不是v-show。第二,限制同时播放的数量上限,比如最多 4 个,超出时自动暂停最早的。第三,对分辨率做降级,预览用小码率的流,点开详情才切高清。
第三条我在一个监控面板项目里试过,效果非常明显。原本 9 宫格全部拉主码流,打开页面 CPU 直接飙到 80%,风扇狂转;改成预览拉子码流后,CPU 稳定在 25% 左右,点某一格放大才切主码流。这个改动的成本只是后端多提供一个低码率地址,前端在definition上做个判断。
5. 核心功能实操:从播放到清晰度切换的完整实现
5.1 基础播放与自动播放策略
浏览器的自动播放策略这几年越来越严,带声音的自动播放基本都会被拦。SDK 内部的play()被拦截时通常不会抛异常,只是静默失败,页面上看起来就是“点了播放没反应”。
稳妥的做法是autoplay配合muted,先静音起播,再引导用户点击音量按钮解锁。组件里我会加一个muteAutoplay的 prop,默认true:
initPlayer() { const options = { source: this.source, isLive: this.isLive, autoplay: this.autoplay, muted: this.autoplay && this.muteAutoplay, cover: this.cover, definition: this.definition, preload: this.preload } this.player = new window.CloudPlayer(this.$refs.container, options) this.player.on('ready', () => { this.isReady = true if (this.progressKey) this.restoreProgress() this.$emit('ready', this.player) }) }这里有一个判断逻辑值得解释:只有当autoplay为真的时候才需要muted,用户手动点击播放的场景不需要静音,你强行静音反而会让人以为播放器坏了。
5.2 清晰度切换与断点续播的完整计算
清晰度切换是播放器里最容易出问题的功能。核心难点在于:切换清晰度本质上是在换源,换源就意味着播放器要重新初始化,进度会丢。
我的做法是“切源前记住时间和状态,切源后恢复”。流程是:
- 记录当前播放时间
currentTime、当前是否在播wasPlaying、当前缓冲状态。 - 调用 SDK 的切清晰度方法(或者直接
setSource新地址)。 - 监听
ready事件,在回调里要么调用seek(currentTime),要么从业务层传进来的resumeFrom恢复。 - 如果切之前是播放状态,
seek之后自动play()。
这里的关键是第三步的时机。ready事件触发时播放器虽然初始化完了,但很多流媒体格式(尤其是 HLS)要等到首个分片加载完才能准确 seek。如果立刻 seek,可能被忽略或者跳到 0。稳妥的做法是监听canplay或者loadedmetadata,再延迟一点执行:
switchDefinition(def) { if (!this.player) return const t = this.player.getCurrentTime() || 0 const wasPlaying = !this.player.paused() this._pendingSeek = t this._pendingPlay = wasPlaying this.player.setDefinition(def) } onReady() { if (this._pendingSeek == null) return const target = this._pendingSeek this._pendingSeek = null this.player.seek(target) if (this._pendingPlay) this.player.play() }关于进度恢复的精度,还有一个数学上的细节:HLS 流是按分片(segment)切分的,常见分片时长 4 到 10 秒,seek 到一个分片中间的位置时,播放器实际会从该分片的起始帧开始播。所以你的实际落点会比记录的时间往回退几秒。如果你的业务对进度精度要求高(比如考试视频必须从精确位置继续),那就需要在记录时做向下取整对齐到分片边界,用一个Math.floor(t / segmentDuration) * segmentDuration的公式,让恢复的位置和实际播放位置保持一致,避免用户感觉“每次都后退一点”。
5.3 封面、进度记忆与自定义控制条
封面的坑不多但很扎眼。最常见的问题是“封面不消失”,原因通常是 SDK 依赖ready或者首次play才移除封面层,而有些场景下播放器初始化后并没有立即播放(比如预加载模式),封面就一直挂着,用户看到的是静止图片,以为加载失败。我一般会监听play事件,手动调一次hideCover,或者在 CSS 层面给封面加过渡,ready之后由组件把封面的opacity降下去。
进度记忆的实现不复杂,核心是存储键的设计。键名要能唯一定位“这一集”,我用的规则是player-progress:${bizId}:${chapterId},业务层通过progressKey传进来。写入时机不能是每次timeupdate都写,那样 localStorage 会被高频调用拖慢主线程,我的做法是节流到 5 秒一次,并且在pause、beforeDestroy、ended时强制写一次。
还有一个处理细节:如果进度超过了视频总时长的 95%,就不要恢复进度了,直接从 0 开始播。否则用户每次重看同一集,都会跳到结尾,体验极差。这个判断在restoreProgress里做:
restoreProgress() { const saved = Number(localStorage.getItem(this.progressKey) || 0) const duration = this.player.getDuration() || 0 if (!saved || !duration) return if (saved / duration > 0.95) { localStorage.removeItem(this.progressKey) return } this.player.seek(saved) }5.4 直播场景:FLV 与 HLS 的差异和延迟控制
直播和点播完全是两条链路,参数配置逻辑要分开。FLV 格式在新版浏览器上通过 MSE 播放,延迟通常能压到 1 到 3 秒,适合互动直播;HLS 兼容性好,尤其是移动端,但延迟通常在 10 秒以上,适合单向观看的场景。
组件的做法是根据source的后缀自动判断格式,把对应的参数塞进 options:
const ext = (this.source.split('?')[0].split('.').pop() || '').toLowerCase() const options = { source: this.source, isLive: this.isLive, autoplay: this.autoplay, isVBR: false, // HLS 直播场景关闭缓冲,压缩延迟 optimalBuffer: this.isLive && ext === 'hls' ? 2 : undefined, // FLV 直播场景开启追帧策略 liveRetry: this.reconnect ? 3 : 0 }几个参数的含义我解释一下。optimalBuffer控制目标缓冲区时长,设置得越小延迟越低,但网络一抖就卡;设置得越大越流畅,但延迟上升。直播场景我一般设 2 秒左右,这是个平衡点。liveRetry表示断流后的重试次数,直播断流是常态(推流端网络波动、切码率),如果不配重试,用户就得手动刷新页面。重试次数也别给太多,3 次差不多了,再多会一直转圈,还不如直接提示“直播已结束”。
还有一个直播特有的问题:切到后台再切回来,直播流的时间戳已经落后好几秒了,画面会“追上”正常时间。处理方式是监听visibilitychange,页面重新可见的时候主动调一次seek到最新时间点:
document.addEventListener('visibilitychange', () => { if (!document.hidden && this.isLive && this.player) { this.player.seekToLiveEdge && this.player.seekToLiveEdge() } })6. 常见问题排查实录:从黑屏到内存泄漏
6.1 黑屏、花屏、只有声音没画面
这三个症状看起来相似,成因完全不同,我按排查顺序整理成表:
| 症状 | 可能原因 | 排查手段 | 处理方式 |
|---|---|---|---|
| 全黑有控制条 | 源地址 403 或跨域被拒 | 网络面板看 m3u8/mp4 的响应码 | 检查播放凭证、Referer 白名单 |
| 全黑无控制条 | 实例未初始化或容器宽高为 0 | 控制台打印this.player | 检查父容器是否display:none |
| 花屏马赛克 | 编码参数不匹配或分片损坏 | 换源对比,判断是内容问题还是播放器问题 | 后端重新转码,或切换到兼容格式 |
| 有声无画 | 硬件解码失败或格式不支持 | 换 Chrome 无痕模式测试 | 关闭硬解,降级到软解 |
| 画面卡在首帧 | ready触发但未演示首帧 | 监听canplay是否触发 | 手动调用一次play后立即pause |
表格里“容器宽高为 0”这条我单拎出来说。Vue 里用v-show隐藏播放器容器时,display: none会让容器尺寸变成 0,播放器初始化时算出来的画布尺寸也是 0,等再v-show显示出来,画面就是黑的,因为画布没重新计算。解决办法是在显示之后调用一次setPlayerSize(),或者干脆用v-if控制,隐藏即销毁、显示即重建。后者虽然有点浪费,但逻辑最清晰,踩坑最少。
“有声无画”这条也值得展开。有些用户在低配设备上看高清流,硬件解码能力不够,SDK 会自动尝试硬解然后失败,表现出来就是声音正常、画面全黑。可以在初始化参数里配置useH5Prism: true或者useFlashPrism这类降级开关(不同版本命名不一样),让它优先走浏览器原生播放,稳一点。让用户自己选“高清模式/兼容模式”也是个思路,我做过一版,默认高清,播放出错自动降级一次,降级失败给个提示,用户的感知比直接黑屏好太多。
6.2 内存泄漏与事件重复绑定
内存泄漏的表现是:页面用久了越来越卡,切路由越来越慢,最后 Chrome 提示“页面无响应”。在 DevTools 的 Performance 面板录一段,如果看到内存曲线一路向上不回落,基本就是实例没销毁。
排查方法很直接:在destroyPlayer里打个console.log('dispose', this._uid),然后反复切路由,看日志数量和切路由次数是否一致。如果日志数少于切换次数,就说明某些路径下beforeDestroy没走到,或者在里面提前return了。
事件重复绑定是另一个高频问题,尤其在source变化时。很多人的实现是watch source之后直接this.initPlayer(),重新 new 一个实例,但老的实例没销毁,老实例上的timeupdate还在往组件里$emit,于是业务层收到的时间回调翻倍,进度条抖得厉害。正确做法是换源前先destroyPlayer(),或者更优雅一点,直接调用 SDK 的setSource方法,不重建实例:
watch: { source(val) { if (!this.player) return if (this.isSameFormat(val, this._currentSource)) { this.player.setSource(val) // 同格式直接换源,实例复用 } else { this.destroyPlayer() this.$nextTick(() => this.initPlayer()) } this._currentSource = val } }isSameFormat判断的原因是:FLV 和 MP4 的解码链路不同,复用实例有时候会卡在旧格式的解码器上,换源后画面不动。所以同格式(都在 HLS 体系内,或者都在 MP4 体系内)才复用,跨格式一律重建。
6.3 全屏、移动端兼容与层级问题
全屏这块分两种:SDK 自带的全屏按钮,和业务侧自定义的全屏按钮。前者一般不用管,后者容易出问题。自定义全屏的常见写法是给容器加一个position: fixed; z-index: 9999的类,但播放器内部的控制条、弹层可能 z-index 更高,导致全屏之后控制条被遮住,或者弹幕跑到画面外面去了。
处理方式是给整个容器设一个高的z-index,并且用requestFullscreen而不是 CSS 模拟,前提是容器允许全屏:
toggleFullscreen() { const el = this.$refs.wrapper if (!document.fullscreenElement) { (el.requestFullscreen || el.webkitRequestFullscreen).call(el) } else { document.exitFullscreen() } }移动端的坑集中在两点。一是 iOS 上video元素的全屏行为由系统接管,你没法自定义控制条,所以移动端要么直接用系统控制条(配置playsinline关闭自动全屏),要么用 SDK 自带的移动端皮肤。二是移动端浏览器在播放时会锁定横屏/竖屏,screen.orientation.lock('landscape')在部分机型上会抛错,必须包try/catch,不能让它阻断后面的逻辑。
层级问题还有一个隐蔽场景:弹窗里放播放器。弹窗组件通常有自己的z-index层级体系,播放器全屏之后可能被弹窗遮罩盖住。我的做法是全屏时把播放器容器appendChild到document.body下,退出全屏再挪回原位置。这中间要注意挪动之后父容器尺寸变化的问题,挪回去之后必须调一次setPlayerSize(),否则画面尺寸会残留全屏时的大小。
最后分享一个我在实际项目里养成的习惯:给播放器组件加一个debug模式。打开之后,组件会在控制台打印每一次实例创建、销毁、事件触发、参数变化,并且带时间戳和组件唯一 ID。线上出问题时,让用户开一次?debug=1刷新,把日志导出,比靠猜快十倍。这个功能加起来不到五十行,但在我手里至少省下过十几个小时的排查时间,尤其是那些“偶发”“复现不了”的黑屏问题,日志一拉,原因往往一目了然——要么是同一个页面开了两个实例,要么是某次换源没有走重建分支。