首页 / 资讯中心 / 文章详情

Vue2 视频播放器组件封装:HLS/FLV、清晰度切换与生命周期治理

Vue2 视频播放器组件封装:HLS/FLV、清晰度切换与生命周期治理 ★ FEATURED ARTICLE
做 Vue2 项目这几年视频模块基本是绕不过去的一关。不管是后台管理系统里的课程回放、电商详情页的商品视频还是监控看板里的实时画面最后都会落到同一个需求上在页面里塞一个能播 HLS、能播 FLV、能切清晰度、能记住上次看到哪儿的播放器。行业里最常见的做法就是接一套云厂商提供的网页播放器 SDK下文统称“云端播放器组件 SDK”然后在 Vue2 里再包一层自己的播放器组件。这层封装看起来简单但我见过太多项目把它写成了“能播就行”的状态切路由不销毁实例、同一个页面开两个播放器互相抢事件、封面加载完还在闪、直播流断了不会重连、切个清晰度进度条直接归零。这篇要聊的就是这层封装怎么包得靠谱。它不是什么高深技术但坑特别密集涉及 SDK 的异步加载时机、实例与 DOM 的绑定关系、Vue2 生命周期与第三方实例生命周期的对齐、事件透传的设计、以及直播和点播两条完全不同链路的参数取舍。适合正在做 Vue2 项目、需要接入视频播放能力的前端同学也适合已经接完了但被各种线上问题反复骚扰、想回头重构一版的人。下面我把整套思路、代码骨架、参数计算和踩过的坑一次性摊开讲。1. 先搞清楚为什么要在 Vue2 里再包一层播放器组件1.1 直接照抄官方示例的三个现实痛点官方 SDK 的示例代码通常是这样的页面里放一个div idplayer-container然后在script里new Player(player-container, {...})完事。这个写法在单页静态页面里没问题但搬到 Vue2 项目里立刻会遇到三类问题。第一类是 DOM 选择器污染。官方示例用 ID 找容器可 Vue 组件是可以复用的一个页面渲染两个播放器组件getElementById永远只会拿到第一个第二个播放器要么不渲染要么把第一个的实例给覆盖掉。我见过一个后台项目列表页里每行都有个小预览播放器结果点第二行的播放按钮第一行开始播了排查了半天才发现是 ID 重复。第二类是数据驱动失效。Vue 的核心是“数据变了视图跟着变”但第三方播放器实例是跑在框架之外的。你在业务里把videoUrl从 A 改成 BVue 会重新渲染组件可播放器实例压根不知道源变了画面还停在 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 参数sourceString播放地址支持 mp4/m3u8/flvsourceisLiveBooleanfalse是否直播模式isLiveautoplayBooleanfalse是否自动播放autoplaycoverString封面图地址coverdefinitionStringauto初始清晰度definitiontokenString播放凭证透传给 SDKvid/playauthprogressKeyString断点续播的存储键无组件内部用preloadBooleantrue是否预加载preloadreconnectBooleantrue直播断流是否自动重连无组件内部用这里有几个设计考虑值得展开。source用 String 而不是 Object是因为大部分场景只需要一个地址但如果你需要一次性把多个清晰度的地址都传进去可以再补一个sourceList的 Array 类型 prop两者互斥sourceList优先。progressKey是一个纯组件层的概念业务方只要传一个能唯一标识这个视频的字符串比如课程 ID 章节 ID组件就会自动往 localStorage 里记进度下次进来自动 seek 回去业务层完全不用写这段逻辑。token这个 prop 单独说一下。很多带加密的播放器需要后端下发一个有时效的播放凭证业务层拿到凭证后再传给组件。这里千万别在组件内部去请求凭证一旦这么做组件就和你的接口耦合了换个项目根本复用不了。凭证的生命周期管理、过期刷新都应该是业务层的事。3.2 事件透传别让业务层去监听原生 DOMSDK 的事件体系通常有十几二十个ready、play、pause、ended、timeupdate、seeking、seeked、error、resolutionChanged、liveStreamStop等等。这些事件如果全部原样透传业务层会很痛苦如果只透传几个又不够用。我的做法是“标准事件全透传 语义化事件再包装”。标准事件用$emit原样抛出命名和 SDK 保持一致业务层需要做精细控制的时候能拿到同时再包装两三个业务真正关心的语义化事件比如video-ended表示“正常播完”、video-error表示“当前源不可用需要降级”。后者的价值在于SDK 的原始 error 回调里参数结构复杂业务层每处都要判断错误码包一层之后业务层只关心“能不能继续”判断逻辑收在组件内。透传的实现上我用一个事件名的数组统一绑定避免手写二十个onconst 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会被移出文档流但播放器实例内部的一些尺寸计算比如全屏切换后的宽高会失效。切回来之后画面比例可能是错的需要手动触发一次resizeactivated() { 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默认trueinitPlayer() { 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的后缀自动判断格式把对应的参数塞进 optionsconst 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。线上出问题时让用户开一次?debug1刷新把日志导出比靠猜快十倍。这个功能加起来不到五十行但在我手里至少省下过十几个小时的排查时间尤其是那些“偶发”“复现不了”的黑屏问题日志一拉原因往往一目了然——要么是同一个页面开了两个实例要么是某次换源没有走重建分支。
阅读完成 · 觉得有帮助?
咨询建站