ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Vue+Element UI视频播放控制:播放停止续播与重播实现

Vue+Element UI视频播放控制:播放停止续播与重播实现

1. 先把需求拆开:四个动作背后其实只有三个按钮

做过中后台项目的人大概都有这种经历:产品经理在需求文档里写下一行“视频支持播放、停止、停止后继续播放、播放完成后重新播放”,看起来只有一句话,落到代码里却要处理播放器状态、浏览器自动播放策略、异步 Promise 异常、组件销毁等一连串问题。我在 Vue + Element UI 的技术栈里做过好几个带视频预览的模块,例如课程内容管理、素材审核后台、质检录像回放,几乎每一次都会被这四个动作卡住半天。这篇文章就把这套东西完整拆一遍,讲清楚每个按钮到底该干什么、代码怎么写、哪里会翻车。

先把最容易搞混的一点说透:标题里的四个动作,落到 UI 上其实只需要三个按钮——播放(含续播)、停止、重新播放。真正需要你动脑子的不是“怎么调 play()”,而是**“停止”这个词到底指什么**。业界至少有两种语义:

第一种,停止 = 暂停,保留播放进度,下次点播放从断点继续。这是绝大多数中后台播放器的做法,因为用户看一段素材看到一半想接个电话,回来肯定希望接着看,而不是从头再来。

第二种,停止 = 归零,把currentTime拉回 0 并暂停,下次点播放是从头开始。这种语义在早期 Flash 播放器时代很常见,但在 HTML5 视频里,它和“重新播放”几乎是同一个动作,功能就重复了。

所以“停止后继续播放”这条需求,只有当“停止”采用第一种语义时才有意义。如果产品坚持“停止就归零”,那“停止后继续播放”本质上就是“从头再播一遍”,和“播放完成后重新播放”实现上是同一段代码。这个歧义点必须在动手写代码之前跟需求方对齐,否则你做出来的东西一定有一方不满意。下面我按第一种语义(暂停保留进度)来展开实现,同时在第二章里补上“真要归零时怎么写”以及它的坑。

2. 基础实现:把 video 封装成一个可控组件

2.1 模板结构:原生 video 加 Element UI 按钮组

Element UI 本身不带视频播放器组件,所以视频渲染必须用原生<video>标签,Element UI 只负责按钮、进度条、加载遮罩这些外围控件。这种“原生内核 + UI 框架外壳”的组合是 Vue 2 项目里最稳妥的做法,因为社区里那些第三方播放器插件要么依赖过重,要么在 Vue 2 与 Vue 3 之间反复横跳,维护成本反而更高。

模板部分我一般写成下面这样,注意ref一定要加,后面所有 DOM 操作都靠它:

<template> <div class="video-player"> <div class="video-box" v-loading="loading"> <video ref="videoRef" :src="videoSrc" :poster="poster" preload="metadata" playsinline webkit-playsinline x5-playsinline class="video-el" @play="onPlay" @pause="onPause" @ended="onEnded" @timeupdate="onTimeUpdate" @loadedmetadata="onLoadedMetadata" @waiting="onWaiting" @canplay="onCanPlay" @error="onError" ></video> </div> <div class="video-controls"> <el-button type="primary" size="small" :loading="loading" @click="handlePlay"> {{ playBtnText }} </el-button> <el-button size="small" :disabled="status !== 'playing'" @click="handleStop"> 停止 </el-button> <el-button size="small" :disabled="status === 'idle'" @click="handleRestart"> 重新播放 </el-button> </div> <div class="video-progress"> <el-slider v-model="currentTime" :max="duration" :format-tooltip="formatTime" :disabled="status === 'idle'" @change="handleSeek" /> <span class="time-text">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span> </div> </div> </template>

几个属性值得单独说一下。playsinline和webkit-playsinline是给 iOS Safari 用的,不加这两个属性,iPhone 上点播放会强制全屏接管,你的自定义按钮和进度条全部失效。x5-playsinline是给部分安卓内嵌浏览器用的,属于历史上比较常见的兼容写法,加上不亏。preload="metadata"只预加载元信息(时长、尺寸),不预加载全部内容,对于后台列表页里有多个视频的场景能明显省流量。poster封面图的作用不只是好看,它能在视频加载出来之前撑住容器高度,避免布局抖动。

2.2 data 与状态字段的初始化

data 里的字段设计直接决定了后面代码好不好写。我那几年踩下来最有用的一条经验是:不要用一堆布尔值来描述播放器状态,什么isPlaying、isPaused、isEnded、isLoading各来一个,很快就会变成互相打架的烂摊子——比如isPlaying是 true 的同时isEnded也是 true,你在模板里写的v-if判断顺序就变成了玄学。

正确的做法是定义一个状态枚举,任何时刻播放器只处于其中一种状态:

data() { return { videoSrc: '', poster: '', loading: false, status: 'idle', // idle | playing | paused | ended currentTime: 0, duration: 0, lastVolume: 1, hasError: false } }

status的四个取值覆盖了全部生命周期:idle是刚进页面还没播过,playing是正在播,paused是暂停(含停止),ended是播完了。后续所有按钮的禁用状态、文案切换、图标显示全部从这个字段派生,页面里只认这一个源头,维护成本立刻降下来。这个思路和 Vuex 里“单一数据源”的理念是一脉相承的,只是缩小到了一个组件内部。

2.3 四个核心方法的逐个拆解

播放(同时承担续播职责)。播放按钮是整个组件里逻辑最重的一个,因为它要在一处处理三种入口:从未播过、从暂停处继续、播完后重播。我的写法是先判断再调 play:

handlePlay() { const video = this.$refs.videoRef if (!video || this.hasError) return // 关键判断:播完了或者已经贴着结尾,先归零 if (this.status === 'ended' || (video.duration && video.currentTime >= video.duration - 0.3)) { video.currentTime = 0 this.currentTime = 0 } const playPromise = video.play() // 老版本浏览器可能返回 undefined,必须做类型判断 if (playPromise && typeof playPromise.then === 'function') { playPromise.catch(err => { if (err.name === 'AbortError') return // 用户快速连点导致的中断,忽略 if (err.name === 'NotAllowedError') { this.$message.warning('浏览器限制了自动播放,请手动点击播放') this.status = 'paused' return } this.hasError = true this.$message.error('视频播放失败,请检查网络或视频源') }) } }

这里那个- 0.3的容差是实打实的经验值。不同浏览器对ended事件的触发时机有细微差异,有些情况下currentTime会停在duration - 0.1附近就触发结束,如果你用严格相等=== duration去判断,就会漏掉这种情况,用户点播放毫无反应,非常迷惑。给一个几百毫秒的容差能覆盖绝大多数场景。

停止(暂停保留进度)。这个最简单,但也不是一行pause()就完事:

handleStop() { const video = this.$refs.videoRef if (!video) return video.pause() // 注意:不要在这里写 video.currentTime = 0 // 否则"停止后继续播放"就变成了"从头上再播" }

pause()是同步方法,但它会异步触发pause事件,所以status的更新我放在事件回调里而不是手动赋值,这样能保证不管视频是被按钮暂停、被代码暂停还是被浏览器暂停,状态永远一致。

停止后继续播放。这个不需要单独写方法,它就是handlePlay在status === 'paused'时的分支——只要停止时没动currentTime,play()自然会从断点继续。这也是我在第一章强调语义要对齐的原因:一个方法覆盖两种场景,代码量直接砍半。

播放完成后重新播放。同样复用handlePlay,靠status === 'ended'那个判断把currentTime拉回 0。这里有个容易忽略的细节:ended状态下直接调play(),HTML5 规范里是允许的,浏览器会自动从头播,但在某些老版本内核里表现不稳定(会出现黑屏一帧),所以我倾向于显式设currentTime = 0再播,可控性更好。

如果产品经理就是要那种“停止即归零”的效果,那就得额外写一个 reset,并且要注意它和load()的区别:

resetToStart() { const video = this.$refs.videoRef video.pause() try { video.currentTime = 0 } catch (e) { // 元数据还没加载完时设 currentTime 会抛错 } }

很多人会顺手写成video.load()来重置。load()确实会把currentTime归零、状态重置为空,但它同时会重新发起网络请求去拉流,用户会看到画面闪一下黑屏再重新缓冲,体验很差,而且会多消耗一次流量。除非你确实要换源,否则别用load()做重置。还有一个更隐蔽的坑:元数据还没加载完(readyState < 1)时给currentTime赋值会抛InvalidStateError,所以外面一定要包 try/catch,或者先判断video.readyState >= 1。

3. 状态管理:让事件驱动一切,而不是靠手动赋值

3.1 用事件回调统一同步状态

为什么我坚持在事件回调里改status而不是在按钮方法里改?因为视频的状态变化来源太多了:用户点你的按钮、用户按键盘空格、系统来电打断、浏览器标签页切到后台被自动暂停、视频流中断、ended自动触发……如果你在每个业务方法里手动维护状态,迟早会出现“明明已经播完了,按钮还显示‘暂停’”这种鬼状态。

把status的写入收敛到事件里,逻辑就变成一条单向链路:方法只负责“发起意图”,事件负责“回写事实”。四个关键事件的写法大致如下:

onPlay() { this.status = 'playing' }, onPause() { // ended 之后浏览器也会触发一次 pause,需要排除,否则状态被覆盖 if (this.status !== 'ended') this.status = 'paused' }, onEnded() { this.status = 'ended' this.currentTime = this.duration this.$emit('ended') }, onTimeUpdate() { if (!this.seeking) { this.currentTime = this.$refs.videoRef.currentTime } }

onPause里那个排除判断是血泪教训。视频播放完毕后,浏览器会先触发ended,紧接着再触发一次pause(规范里是这么定义的),如果你的onPause无脑把状态写成paused,那ended状态就会被瞬间覆盖,结果就是“播完了按钮却显示要继续播”,用户点一下只能播最后一帧,然后你又得靠容差判断兜底。这类问题调试起来特别费劲,因为日志打出来的顺序是对的,但最终状态就是错的。

3.2 currentTime 与进度条的双向同步

进度条这块最容易出现拖动时来回跳动的现象。原因很简单:你拖进度条时v-model更新了currentTime,但视频本身还在播放,timeupdate事件一秒触发好几次,每次都把currentTime覆盖回视频的真实时间,滑块就被拽回去了。

解决思路是加一个“正在寻址”的锁:

handleSeek(val) { const video = this.$refs.videoRef this.seeking = true video.currentTime = val // seeked 事件触发后解锁 const unlock = () => { this.seeking = false video.removeEventListener('seeked', unlock) } video.addEventListener('seeked', unlock) // 兜底:万一 seeked 没触发,1 秒后强制解锁 setTimeout(() => { this.seeking = false }, 1000) }

另外 Element UI 的el-slider有两个容易搞混的事件:input在拖动过程中持续触发,change在松手(或点轨道)时触发。做视频进度条一定要用change,否则每拖一个像素就 seek 一次,浏览器会疯狂地发起范围请求,页面直接卡死。format-tooltip用来把秒数格式化成mm:ss,这个小细节对体验提升很明显。

timeupdate的触发频率各浏览器不同,大约是每秒 4 到 66 次。如果你的进度条上还叠了弹幕、缩略图预览之类的重组件,建议别直接把currentTime绑到它们身上,而是用requestAnimationFrame做节流,或者直接上el-progress这种轻量组件。

3.3 播放按钮的文案与禁用态派生

playBtnText我用 computed 派生,而不是在事件里硬改字符串:

computed: { playBtnText() { if (this.status === 'playing') return '暂停' if (this.status === 'paused') return '继续播放' if (this.status === 'ended') return '重新播放' return '播放' } }

但这里有个产品视角的分歧:播放按钮在播放中到底该变成“暂停”,还是说播放和暂停是两个独立按钮?标题里的需求只提了“播放”和“停止”,那就意味着播放按钮管播放,停止按钮管暂停,播放按钮在播放中应该是禁用态。两种方案都合理,关键是和视觉稿对齐。我个人偏向“播放/暂停合一 + 停止单独一个”的布局,因为用户的心智模型里“停止”通常意味着“我要退出了,别再动”,而“暂停”意味着“待会儿还回来”。如果你的场景是监控回放这类需要频繁切换的,那还是老老实实做成三个独立按钮,少一点猜测成本。

4. 那些让我加班到深夜的坑

4.1 play() 返回 Promise 引发的连锁异常

HTML5 规范在 2016 年前后给play()改了返回值,从undefined变成了 Promise。这一改动带来的直接影响是:如果你调了play()但没有 catch,一旦播放被打断,控制台就会冒出一个红色的 Uncaught (in promise),然后被测试同学截图发到群里。

常见的打断场景包括:用户点了播放又立刻点停止、视频还没缓冲好就切换了源、组件在play()的 Promise 还没 resolve 时就被销毁了。这几种情况下抛出的错误名分别是AbortError、NotSupportedError、AbortError,全都是正常业务行为,不应该弹给用户看。所以catch里必须按错误名分流处理,只有真正的解码失败、源地址 404 这种才提示用户。

还有一个更隐蔽的问题:连续快速点击播放按钮,会连续发出多个play()调用。现代浏览器对同一个 video 的重复play()是幂等的,但由于 Promise 的时序,你可能拿到一串 rejected。我在按钮上加了个 100 毫秒的防抖,或者干脆在status === 'playing'时直接 return,两种做法实测都能压住这个问题。

4.2 自动播放策略:别指望进页面就自动播

浏览器厂商这些年把自动播放策略收得越来越紧。目前的主流规则大致是:没有用户交互的情况下,带声音的视频不允许自动播放;静音视频(muted)在多数桌面浏览器里可以自动播放,移动端还要叠加playsinline。如果你的需求是“进入页面自动播放”,唯一的稳妥方案是muted+autoplay+playsinline三件套,然后在界面上放一个“取消静音”的按钮引导用户点击。

我遇到过一个特别绕的场景:某个后台要在弹窗里预览视频,弹窗一打开就自动播。结果 Chrome 上因为弹窗打开这个动作本身算作“用户手势传播”,有时能播有时不能,取决于用户是点击打开的弹窗还是键盘回车打开的。这种不一致性根本没法靠代码消除,最后改成弹窗里显示封面图加一个大播放按钮,彻底绕开了自动播放策略。能不让浏览器自动播,就别让它自动播,这是我在这个方向上最实在的一条建议。

// 静音自动播放的稳妥写法 mounted() { this.$nextTick(() => { const video = this.$refs.videoRef if (!video) return video.muted = true const p = video.play() if (p && p.catch) { p.catch(() => { // 连静音自动播都被拦了,退回到手动模式 this.status = 'idle' }) } }) }

4.3 移动端与内嵌浏览器的兼容处理

移动端的坑主要集中在三处。第一是 iOS 的全屏接管,前面说的playsinline已经解决了。第二是安卓部分内嵌浏览器的同层渲染问题,视频元素会被系统播放器“顶”到最上层,你的按钮和进度条全部被盖住——这种情况下只能放弃自定义控制条,改用controls原生控件,或者引入专门的同层渲染方案。第三是音频焦点,用户在视频播放时切到别的应用、接了个电话,回来视频可能已经暂停但status还是playing,这时候需要监听visibilitychange事件做一次校正。

还有个小细节:iOS 上video.duration在元数据加载前是NaN而不是 0,如果你直接拿它算进度条百分比,会得到NaN然后渲染出一堆奇怪的样式。稳妥的写法是const total = isFinite(video.duration) ? video.duration : 0。这个NaN的判断我在三四个项目里都栽过,明确记下来。

4.4 组件销毁时的清理动作

视频是个带外部资源的 DOM 元素,不清理会一直占着网络连接和内存。beforeDestroy里我一般做三件事:暂停、解绑监听、清空源。

beforeDestroy() { const video = this.$refs.videoRef if (!video) return video.pause() video.removeAttribute('src') video.load() // 这一步是必须的,才会真正释放底层解码器 this.videoSrc = '' }

注意这里load()是合理用法——我们本来就要释放资源。还有一个容易漏掉的点:如果组件在keep-alive里,beforeDestroy不会立刻触发,需要额外用deactivated钩子暂停视频。我见过一个后台因为列表页用keep-alive缓存了视频组件,切走之后声音还在后台响,用户以为中毒了。

4.5 换源时的状态复位

后台系统里视频通常是列表切换,同一个组件实例只换src。这时候如果只改videoSrc而不复位状态,会出现“新视频显示播放中,但画面停在第一帧”的情况。正确的顺序是:先暂停、把status置回idle、currentTime和duration清零,再换src并调load()让浏览器重新解析。

watch: { videoSrc(newVal) { const video = this.$refs.videoRef if (!video) return video.pause() this.status = 'idle' this.currentTime = 0 this.duration = 0 this.hasError = false this.$nextTick(() => { video.load() }) } }

5. 体验优化:从“能用”到“好用”的几个升级点

5.1 加载态、错误态与封面图

视频是典型的慢资源,网络稍微差一点就会白屏好几秒。我的做法是把waiting事件映射成 Element UI 的v-loading,canplay时关闭。但要注意waiting会在缓冲、seek、切源时都触发,如果每次都弹一个全屏 loading,用户会觉得页面一直在闪。我更推荐只在首次加载(status === 'idle')时显示 loading,播放过程中的卡顿用一个小圆圈转在左下角就够了。

错误态的兜底也很有必要。error事件里video.error.code会给出具体原因,1 是加载被中止,2 是网络错误,3 是解码失败,4 是源不支持。后台里最常见的其实是 4,因为用户上传的素材格式五花八门,MKV、AVI 这类浏览器原生不支持的格式传上来必然播不了。这时候不要只显示“播放失败”,而要给一句“当前浏览器不支持该视频格式”,再提供一个下载按钮,用户立刻就懂了。

5.2 倍速、音量与全屏的取舍

Element UI 没有现成的倍速组件,用el-dropdown或el-select选playbackRate就行,注意playbackRate只影响播放速度,不影响duration,所以进度条的max不用改。音量用el-slider竖排或者直接省掉,让用户用系统音量,后者其实更省事。

全屏这块,video.webkitEnterFullscreen是 iOS 专有 API,桌面端用requestFullscreen,两个要分开做兼容。但我要提醒一句:如果你的容器外层还有一层弹窗(el-dialog),全屏之后关闭弹窗,document.fullscreenElement可能还留在那里,页面会被锁死。必须在弹窗的close回调里检查并退出全屏。这个 bug 我调了一整个下午。

5.3 让组件对外暴露的接口设计好

如果这个播放器要在多个页面复用,接口设计比实现更重要。我一般只暴露三个 props 和一个事件:

名称类型说明
srcString视频地址,变化时会自动复位
posterString封面图地址
autoplayBoolean是否尝试静音自动播放
on-endedEvent播放结束,父组件可据此自动播下一条

事件只发ended和error两个就够了,播放中、暂停这些中间状态父组件基本不关心,暴露多了反而是负担。如果父组件真的需要,通过ref调getStatus()拿就行。

6. 常见问题速查表

下面这张表是我这几年攒下来的问题清单,基本都是调试时真实遇到过的,遇到类似现象可以先对照着查:

现象可能原因处理方式
点播放毫无反应play()被策略拦截,Promise 被吞catch 里检查NotAllowedError,提示用户手动点
播完点播放只播最后一帧未检测ended,currentTime停在结尾播放前判断status === 'ended'或加 0.3 秒容差
停止后从头开始播了停止方法里误写了currentTime = 0移除归零操作,保留进度
进度条拖完弹回原位timeupdate覆盖了拖动值加seeking锁,用change事件而非input
停止按钮一直可点用布尔值维护状态导致状态错乱改成 status 枚举,事件统一回写
iOS 上点播全屏了缺少playsinline补playsinline和webkit-playsinline
切走页面还有声音keep-alive缓存未暂停deactivated钩子里调pause()
视频列表切换后黑屏换源没复位状态watch src 时暂停 +load()
控制台一串 AbortError快速连点触发中断播放按钮加防抖,catch 里忽略 AbortError
duration显示 NaN元数据未加载完成isFinite判断后回退为 0

最后分享一个我个人的小习惯:在开发阶段,我会在onPlay、onPause、onEnded、onTimeUpdate(节流后)里各打一条带时间戳的日志,同时把status、currentTime、readyState一起输出。视频相关的 bug 有八成是时序问题,光看界面根本猜不出发生了什么,有了这条状态流水,绝大多数问题两分钟就能定位。等上线前把这行日志删掉就行,代价几乎为零,收益却很大。

返回列表