资讯详情

资讯详情

Vue Element UI视频控制:四按钮状态机与HTML5 video避坑

1. 需求拆解四个按钮到底在控制什么Vue Element UI 里做视频播放控制看起来是个很小的需求页面上放个 video 标签下面排一行 el-button点“播放”就播点“停止”就停点“继续”接着上次的位置播播完了点“重新播放”从头再来。这个需求在后台管理系统、课程学习页、设备演示页出现频率极高几乎每个做过 Vue 中后台的开发者都接到过类似的需求单。但真正动手写的时候大量的人会在第二天收到测试反馈点了停止再点继续视频不动了播完之后点播放画面卡在最后一帧连点两次播放控制台多出一条红色的 Promise 报错。这四个按钮本质上不是四个独立的功能而是同一个播放状态机的四个入口。播放、停止、继续播放、重新播放分别对应“播放中”“已暂停且位置归零”“已暂停且位置保留”“已结束”这几种状态下的状态迁移。如果只把它们当成四个孤立的 click 回调去写就一定会出现状态错乱——按钮显示的是“暂停”实际视频已经播完停在了结尾或者点了停止进度归零了但控制按钮还亮着“继续播放”的样式。所以这篇内容我会按照实际做项目的顺序讲先拆清楚 HTML5 video 原生 API 提供了哪些能力和哪些坑再讲状态怎么设计然后给出可以直接复制进项目的完整单文件组件代码最后把我在几个项目里踩到的、测试同学不会告诉你但线上一定会暴露的问题一次性摊开讲。适合刚接触 Vue 音视频开发的同学也适合已经有 Vue 基础但被 video 的异步行为折腾过的朋友。代码以 Vue 3 Element Plus 为主线Vue 2 Element UI 的等价写法会在对应位置标注出来两个版本都能落地。1.1 原生 controls 为什么不够用浏览器给 video 标签自带了一个controls属性加上去就有一整套原生控件播放、暂停、进度条、音量全都有零代码成本。但实际项目里基本没人这么用原因不在于它丑而在于它不可控。原生控件的样式没法跟 Element UI 的设计规范统一深色主题下它是一套灰白色系统 UI浅色主题下又不变视觉上非常突兀。更关键的是原生控件只暴露“播放”和“暂停”两个动作没有“停止”这个概念——HTML5 规范里根本没有 stop 事件和 stop 方法“停止”这个功能是业务方脑补出来的实现方式只能是pause()加currentTime 0两件事拼起来。原生控件还有一个隐蔽的问题它的播放状态的“唯一真相源”在 DOM 元素上而不是在你的组件状态里。如果你自己画按钮就必须在play、pause、ended这几个事件里把 DOM 的真实状态同步回ref否则就会出现“数据驱动视图”和“浏览器自己行为”两套逻辑打架的情况。举个例子用户按下了键盘上的空格键、或者点了视频画面本身触发了原生控件的暂停这时候你的按钮还显示“暂停中”但这已经跟视频的真实状态脱节了。所以结论很明确自绘按钮 监听原生事件 单一状态源这套组合是 Vue 项目里做视频控制的标准解法。它多写几十行代码但换来了完全可控的样式、完全可控的状态、以及后期加进度条、倍速、全屏的扩展空间。1.2 用状态机把四个动作的边界划清楚在写代码之前我习惯先在纸上把这几个状态和动作画出来因为视频播放的状态边界比想象中模糊。比如“停止后继续播放”和“播放完成后重新播放”这两个动作在代码上看起来都是“调用 play()”但语义完全不同前者要保留进度后者要重置进度。如果状态没拆清楚很容易写出“停止之后点继续视频从零开始播”这种让产品经理抓狂的 bug。我通常用两个布尔变量来表达全部状态isPlaying表示视频此刻是否在走isEnded表示视频是否已经自然播完。再配合视频元素自身的currentTime四个按钮的可用性就能全部推导出来不需要第三个、第四个人的状态变量。当前状态isPlayingisEndedcurrentTime可点的按钮未开始falsefalse0播放、停止播放中truefalse0 t duration停止停止后falsefalse0播放、继续播放中途暂停falsefalse0 t duration播放、继续播放、停止播放完成falsetrue duration重新播放、停止这张表的价值在于它把“按钮 disabled 该不该置灰”这件事从拍脑袋变成了查表。很多项目里按钮永远是亮的用户点了没反应体验很差。有了这张表按钮的disabled属性直接绑定表达式就行逻辑清楚测试也容易写。注意isEnded这个状态在原生ended事件里置为 true但在用户点击停止、拖动进度条、重新播放之后必须立刻置回 false。这是最容易被漏掉的一行代码漏了就会出现“播完之后点继续视频从头开始”的诡异现象。2. 整体设计与技术选型2.1 video 元素与 Element UI 按钮的职责边界这套方案里video只负责四件事加载资源、解码播放、暴露当前进度、抛出事件。所有用户交互全部交给外面的 Element UI 按钮所有状态全部收敛到 Vue 的响应式变量里。这种分工听起来理所当然但实际写的时候很多人会把职责搞混比如在timeupdate事件里直接操作 DOM 改按钮的文字或者在按钮的 click 里直接读video.duration去算进度条宽度。我把这个边界定得很死模板里只读 ref 状态事件回调里只改 ref 状态和调用 video 的原生方法绝不在回调里做 DOM 查询。这样做的好处是可测试性好逻辑部分可以抽成一个组合式函数复用坏处是要多写几个handleXxx函数但对中大型项目来说这点冗余完全值得。在 Element UI 这一侧el-button提供的是样式、禁用态、图标和加载态它本身跟视频毫无关系。我一般会用type区分主次操作播放和继续播放用primary停止用danger或者默认样式重新播放用warning或success让用户在视觉上能一眼分辨出“这个按钮点了会中断当前行为”。图标方面Element Plus 需要从element-plus/icons-vue里按需引入VideoPlay、VideoPause、RefreshRight这些Element UI 则是直接写iconel-icon-video-play这类 class 名这一点两个版本差异比较大迁移时要注意。2.2 为什么不用第三方播放器组件市面上有不少封装好的 Vue 播放器组件功能齐全开箱即用。我在选型时确实会考虑它们但判断标准很明确如果需求只是四个按钮控制一个视频引入第三方库是负收益。一个典型的播放器组件打包进来少说几十 KB还带着自己的样式体系和事件模型你要改它的按钮布局、要跟 Element UI 的设计规范对齐反而要读它的源码、覆盖它的 CSS成本比手写高得多。手写的好处是完全透明。总共一百多行代码每个事件什么时候触发、每次状态怎么变全在自己手里。后续产品要加“播放到第 10 秒自动弹出测验题”要加“切换视频源时保留进度”手写方案改起来是线性的第三方组件改起来就得翻文档甚至翻源码。当然也有该用库的场景需要 HLS 或 DASH 这类流媒体协议、需要画中画、需要字幕轨切换、需要倍速且要求音调不变。这些需求靠原生 video 加大量胶水代码也能做但性价比不高。判断标准就是协议层和渲染层的高级能力交给专业库业务层的交互控制自己写。2.3 Vue 2 与 Vue 3 的写法映射项目里 Vue 2 和 Vue 3 我都维护过两者的差异主要集中在获取 DOM 元素和状态声明上视频相关的 API 完全一样。Vue 3 的script setup里用const videoRef ref(null)加refvideoRef模板自动绑定Vue 2 的选项式 API 里则是this.$refs.videoRef不需要预先声明一个 ref 变量。生命周期清理的位置也不同Vue 3 用onBeforeUnmountVue 2 用beforeDestroy。这个清理非常关键后面第 4 章会详细讲忘记清理会导致组件销毁后视频还在后台偷偷下载、音频还在响。还有一个容易忽略的点是this的指向。Vue 2 里如果事件回调用了普通函数并且是通过endedhandleEnded绑定的Vue 会自动绑定组件实例问题不大但如果用了setTimeout或者 Promise 的thenthis就会丢。Vue 3 的组合式 API 没有这个问题因为函数都在 setup 作用域里闭包捕获了 ref。这也是我新项目一律上 Vue 3 的原因之一。3. 核心实现四个动作的代码落地3.1 模板结构与 ref 绑定模板部分我一般分成三层视频层、状态提示层、按钮控制层。视频层放video给它一个固定宽高比避免加载时布局抖动状态提示层放一个遮罩或者角标显示“已暂停”“播放完成”这类文案按钮层就是一行el-button。这种分层不是为了好看而是为了后面加功能时不用重构结构。video上有几个属性必须显式写上。preloadmetadata告诉浏览器只预加载元数据也就是时长和尺寸别一上来就把整个视频文件拖下来对流量敏感的场景很重要。playsinline和webkit-playsinline是移动端的必备项不加的话在 iOS 上点播放会强制全屏跟你的页面布局完全脱节。src用响应式变量绑定方便后面做视频切换。事件绑定我用了模板上的play、pause、ended、timeupdate、loadedmetadata这几个。有人喜欢在onMounted里用addEventListener统一注册我没这么写原因是模板绑定在组件销毁时 Vue 会自动解绑少一类内存泄漏的风险而且看模板就知道监听了哪些事件可读性更好。timeupdate的触发频率不高大约每秒 4 次走模板绑定没问题。3.2 播放与停止的实现细节播放按钮的核心就一行videoRef.value.play()但这一行背后有坑。现代浏览器里play()返回的是一个 Promise如果播放被拒绝——比如浏览器自动播放策略拦了、或者用户快速点了暂停导致请求被中断——这个 Promise 会 reject。如果不接住控制台就会出现一条未捕获的 Promise 错误虽然不影响功能但会在错误监控平台里刷出一堆噪音。我封装了一个safePlay函数专门处理这件事。里面判断返回值是不是 Promise老浏览器可能返回 undefined然后 catch 住错误对AbortError这种“正常的中断”静默忽略对其他错误打日志。这个函数后面所有需要播放的地方都复用它避免重复写。停止按钮的实现是pause()加上currentTime 0两行缺一不可。只pause()那是暂停进度条还停在原地只把currentTime归零不pause()视频会从零开始继续播。另外我在停止里顺手把isEnded置回 false否则如果在播放完成后点停止isEnded还是 true后面继续播放的逻辑就会误判。3.3 停止后继续播放pause 与 ended 的差异“继续播放”这个按钮是四个里最容易写错的。它的语义是“从上次停下的地方接着播”实现上就是play()看起来没什么技术含量。但这里有个必须处理的分支如果视频是自然播放结束的currentTime此时等于总时长直接调play()在部分浏览器上会表现不一致——Chrome 和 Firefox 一般会自动回到开头重新播但我遇到过某些环境下它确实卡在最后一帧不动用户点了没反应。我的处理方式是不依赖浏览器的默认行为显式判断如果isEnded为 true先currentTime 0再play()。这样无论浏览器怎么实现行为都是确定的。多写两行少一堆兼容性排查。还有一个细节是currentTime的赋值时机。如果视频的元数据还没加载完直接设置currentTime是无效的甚至会抛异常。稳妥的写法是先判断readyState大于等于 1HAVE_METADATA才允许设置。实际场景中用户点按钮的时候元数据基本都加载好了但如果是切换视频源之后立刻点就有可能撞上这个时间窗口。3.4 播放完成后重新播放的兜底逻辑“重新播放”的语义和“继续播放”不同它要求无论当前是什么状态都必须从零开始。所以它的实现不判断isEnded直接强制currentTime 0然后play()。这个按钮在播放中点击也应该生效效果相当于“从头再播一遍”这在演示类页面里很常见——讲解人想让观众再看一次开头。我在实际项目里给这个按钮加了一个小优化点击后先把isEnded置 false再归零再播。顺序很重要如果先播再改状态ended事件和状态更新可能在同一个事件循环里打架导致按钮在极短时间内闪一下“重新播放”又变回“播放中”。虽然用户看不出来但对状态的一致性来说是隐患。4. 完整可运行代码与参数说明4.1 单文件组件完整实现下面这份代码是我从最近一个后台项目里抽出来的去掉了业务相关的部分保留了全部控制逻辑。用的是 Vue 3script setup加 Element Plus直接新建一个.vue文件粘进去就能跑视频源换成本地或你自己的地址即可。template div classvideo-panel div classvideo-panel__stage video refvideoRef classvideo-panel__player :srcvideoSrc preloadmetadata playsinline webkit-playsinline loadedmetadatahandleLoadedMetadata playhandlePlay pausehandlePause endedhandleEnded timeupdatehandleTimeUpdate /video div v-ifisEnded classvideo-panel__mask播放完成/div /div div classvideo-panel__bar el-slider v-modelprogress :disabled!duration :show-tooltipfalse inputhandleSeek / span classvideo-panel__time{{ currentLabel }} / {{ durationLabel }}/span /div div classvideo-panel__actions el-button typeprimary :disabledisPlaying || isEnded clickhandlePlayClick 播放/el-button el-button typedanger :disabled!isPlaying currentTime 0 clickhandleStop 停止/el-button el-button :disabledisPlaying || (currentTime 0 !isEnded) clickhandleResume 继续播放/el-button el-button typesuccess clickhandleReplay 重新播放/el-button /div /div /template script setup import { ref, computed, onBeforeUnmount } from vue const videoRef ref(null) const videoSrc ref(/media/demo.mp4) const isPlaying ref(false) const isEnded ref(false) const currentTime ref(0) const duration ref(0) const progress ref(0) const currentLabel computed(() formatTime(currentTime.value)) const durationLabel computed(() formatTime(duration.value)) function formatTime(sec) { if (!Number.isFinite(sec) || sec 0) return 00:00 const m Math.floor(sec / 60) const s Math.floor(sec % 60) return ${String(m).padStart(2, 0)}:${String(s).padStart(2, 0)} } function safePlay() { const el videoRef.value if (!el) return const result el.play() if (result typeof result.then function) { result.catch((err) { if (err err.name AbortError) return console.warn([video] 播放失败:, err err.name, err err.message) }) } } function canSeek(el) { return el el.readyState 1 } function handleLoadedMetadata() { const el videoRef.value if (!el) return duration.value Number.isFinite(el.duration) ? el.duration : 0 } function handleTimeUpdate() { const el videoRef.value if (!el) return currentTime.value el.currentTime if (duration.value 0) { progress.value (el.currentTime / duration.value) * 100 } } function handlePlay() { isPlaying.value true isEnded.value false } function handlePause() { isPlaying.value false } function handleEnded() { isPlaying.value false isEnded.value true currentTime.value duration.value progress.value 100 } function handlePlayClick() { safePlay() } function handleStop() { const el videoRef.value if (!el) return el.pause() if (canSeek(el)) el.currentTime 0 currentTime.value 0 progress.value 0 isEnded.value false } function handleResume() { const el videoRef.value if (!el) return if (isEnded.value) { if (canSeek(el)) el.currentTime 0 currentTime.value 0 progress.value 0 isEnded.value false } safePlay() } function handleReplay() { const el videoRef.value if (!el) return isEnded.value false if (canSeek(el)) el.currentTime 0 currentTime.value 0 progress.value 0 safePlay() } let seeking false function handleSeek(val) { const el videoRef.value if (!el || !duration.value || seeking) return seeking true el.currentTime (val / 100) * duration.value setTimeout(() { seeking false }, 0) } onBeforeUnmount(() { const el videoRef.value if (!el) return el.pause() el.removeAttribute(src) el.load() }) /script style scoped .video-panel { max-width: 720px; } .video-panel__stage { position: relative; background: #000; aspect-ratio: 16 / 9; } .video-panel__player { width: 100%; height: 100%; display: block; } .video-panel__mask { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #fff; background: rgba(0, 0, 0, 0.45); font-size: 16px; letter-spacing: 2px; } .video-panel__bar { display: flex; align-items: center; gap: 12px; padding: 8px 0; } .video-panel__bar :deep(.el-slider) { flex: 1; } .video-panel__time { font-size: 13px; color: #606266; min-width: 96px; text-align: right; } .video-panel__actions { display: flex; gap: 8px; flex-wrap: wrap; } /styleVue 2 加 Element UI 的项目里把script setup换成export default { data() { return { isPlaying: false, isEnded: false, currentTime: 0, duration: 0, progress: 0, videoSrc: /media/demo.mp4 } }, methods: { ... } }方法体里的videoRef.value全部换成this.$refs.videoRefonBeforeUnmount换成beforeDestroy其余逻辑一字不改。4.2 video 关键属性参数取舍这几个属性看着不起眼但选错了直接影响首屏体验和流量成本我挨个说明白。属性建议值为什么这么选preloadmetadata只拉时长和尺寸进度条能立刻显示不浪费流量设 auto 会在用户没点播放时就把整片拖下来playsinline保留iOS Safari 不加会强制全屏播放页面布局彻底失控webkit-playsinline保留老版本 iOS 内核的兼容写法写上没坏处controls不写自绘按钮场景下必须去掉否则会出现两套控件打架muted按需需要自动播放时就必须加静音状态下自动播放策略才放行poster建议加视频首帧往往是黑屏加一张封面图观感好很多crossorigin跨域时加视频源和页面不同域时不加会导致某些操作受限preload这个参数我特别多说一句。有些人为了“点了播放立刻能播”直接设成auto在管理后台这种内网环境没问题但在公开页面里一个几十兆的视频在首屏就偷偷下载用户流量和服务器带宽都会难受。metadata是个折中点进度条能正常显示总时长真正需要缓冲的只有点击播放之后的几秒。poster属性很多人忽略。不加的话视频区域在元数据加载完之前是一片纯黑加载慢的时候用户会以为页面坏了。加一张与视频主题相关的封面图视觉效果立刻不一样而且能起到“这里可以点”的暗示作用。4.3 进度条联动与销毁清理进度条用的是el-slider双向绑定progress在timeupdate里更新它在input里反过来设置video.currentTime。这里有个死循环风险用户拖动滑块触发input改了视频进度视频触发timeupdate又回头改progress滑块在用户拖动过程中会被弹回去。我的处理是用一个seeking标志位做短暂锁定拖动期间不让timeupdate覆盖滑块值setTimeout(0)之后解锁。这个方案不算最优更严谨的做法是监听滑块的change事件而不是input只在松手时一次性 seek。两种都能用看产品对拖动手感的容忍度。另外timeupdate里我特意判断了duration 0因为流媒体或者还没加载完的情况下duration可能是Infinity或NaN直接除会得到NaN滑块会跳到异常位置。销毁清理这一段是很多线上事故的源头。组件被v-if切走或者路由跳转时如果只是把 DOM 移除视频元素的解码器可能还在后台跑音频还在响。我在onBeforeUnmount里做了三件事pause()停掉播放、removeAttribute(src)断开资源引用、load()让浏览器真正释放资源。这三步少任何一步在某些浏览器上都会留下“页面都切走了还有声音”的投诉。5. 常见问题与排查实录5.1 play() 报错与 Promise 未捕获这个现象我见过太多次控制台一片红全是Uncaught (in promise) DOMException: The play() request was interrupted by a call to pause()。原因是play()是一个异步过程浏览器要先缓冲数据再开始渲染这个过程中如果用户又点了暂停或者切换到别的视频请求就被中断Promise 以AbortError拒绝。功能上没有任何问题但错误监控平台会把它们全收集起来导致告警疲劳。处理方式就是前面safePlay里那段判断返回值是 Promise 就 catch遇到AbortError直接return其他错误再打日志。另一个常见的是NotAllowedError属于自动播放策略拦截这种情况要么加muted要么必须由用户手势触发不能在onMounted里直接调play()。注意判断err.name AbortError比判断 message 文本更可靠message 在不同浏览器语言环境下会变name 是固定的。5.2 首帧黑屏、进度条不回零、按钮状态错乱首帧黑屏的根因通常是preload设成了none或者视频编码不被支持。前者加poster就能缓解后者要看具体格式MP4 加 H.264 编码的兼容性最好基本通吃。如果视频是 H.265 编码Chrome 大概率放不了控制台会报MEDIA_ERR_SRC_NOT_SUPPORTED这个只能转码解决前端无能为力。进度条不回零的问题我排查过一次原因是停止逻辑里只写了pause()忘了写currentTime 0测试同学点停止之后看到进度条还停在中途。还有一种情况是写了currentTime 0但视频元数据没加载完赋值无效这种要等loadedmetadata之后再操作或者加载过程中禁用停止按钮。按钮状态错乱基本都出在isEnded没及时复位。典型表现是播放完成后点停止再点继续播放视频从头开始播了——因为停止时没把isEnded清掉“继续播放”走的是重新播放的分支。这类问题靠肉眼很难发现我是靠单元测试兜住的把四个动作按各种顺序排列组合跑一遍断言currentTime和状态变量符合预期。5.3 问题速查表现象大概率原因处理办法点播放没反应且无报错浏览器自动播放策略拦截加 muted 或确保由用户点击触发控制台 AbortError 刷屏play() 的 Promise 未 catch封装 safePlay忽略 AbortErroriOS 上点播放变全屏缺 playsinline补上 playsinline 和 webkit-playsinline停止后进度条不动漏写 currentTime 0停止逻辑里补齐归零播完点播放卡在最后一帧未显式归零play 前判断 isEnded 并设置 currentTime 0切换路由后还有声音组件销毁未释放资源onBeforeUnmount 里 pause 移除 src load进度条拖不动timeupdate 覆盖了滑块值加 seeking 锁定或改用 change 事件视频加载不出来格式不支持或跨域转 H.264跨域加 crossorigin6. 一些踩过坑之后的经验6.1 高频事件里的性能账timeupdate每秒触发大约 4 次单看频率不高但它在回调里做的事如果不够轻累积起来就有感知。我见过有同学在这个回调里重新计算整个组件的派生数据、甚至触发一次接口请求做埋点页面放个视频 CPU 就上去了。正确的做法是这个回调里只更新必要的几个数值重活放到别的地方做。埋点这块我现在的做法是只在关键节点上报首次播放、暂停、播完、重新播放而不是在timeupdate里按秒上报。后者的数据量是前者的几十倍但分析价值并不会更高反而会把日志系统压垮。如果确实需要观看时长我在暂停和销毁的时候用当前currentTime减一下开始时间就够了精度完全够用。还有一点是滑块更新。el-slider的v-model每变一次都会触发一次组件内部更新4 次/秒问题不大但如果页面上同时有三四个视频在跑加起来渲染压力就上来了。多视频场景我一般只让“当前激活”的那个视频绑定timeupdate其他的监听直接摘掉切回来再挂上。6.2 多视频场景与可复用封装思路单视频的代码写到这个程度就够用了。但项目稍微大一点页面上就会出现“主视频 几个小视频”或者一个列表里每行一个视频。这时候如果把上面那套逻辑复制粘贴 N 遍维护会失控。我的做法是把这套逻辑抽成一个组合式函数useVideoPlayer(videoRef)内部返回isPlaying、isEnded、currentTime、progress这些状态以及play、stop、resume、replay、seek这些方法事件绑定也由这个函数在onMounted里统一注册。组件里只负责把videoRef传进去然后在模板上渲染按钮。这样每个视频实例的状态天然隔离因为每个实例调用一次这个函数就产生一份独立的状态闭包。多实例还要注意“互斥播放”这个业务规则同一时间只允许一个视频在播。这个逻辑不能塞进useVideoPlayer内部因为它不知道外面有几个实例。我一般在外层用一个activeId变量在播放前遍历其他实例调它们的stop()或者干脆把activeId作为参数传进组合式函数播放时判断自己是不是当前的活跃实例。这套写法我在一个课程学习页里用过一页八个视频互斥切换很流畅没有出现声音叠加的问题。再往下扩展加倍速、加全屏、加画质切换都是在组合式函数里增加方法和状态组件层几乎不用改。这套结构我用了两三年加需求的时候基本没推翻过。最后说个小事调试视频的时候别只在自己电脑上测iOS Safari 和安卓各家浏览器的表现差异比想象中大尤其内联播放和进度归零这两个点。我自己养成的习惯是每改一次控制逻辑就在真机上按“播放、停止、继续、播完、重播”这个顺序完整走一遍这个顺序基本能覆盖所有状态迁移的边界情况比看代码可靠得多。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →