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,四个按钮的可用性就能全部推导出来,不需要第三个、第四个人的状态变量。
| 当前状态 | isPlaying | isEnded | currentTime | 可点的按钮 |
|---|---|---|---|---|
| 未开始 | false | false | 0 | 播放、停止 |
| 播放中 | true | false | 0 < t < duration | 停止 |
| 停止后 | false | false | 0 | 播放、继续播放 |
| 中途暂停 | false | false | 0 < t < duration | 播放、继续播放、停止 |
| 播放完成 | false | true | = 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 则是直接写icon="el-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)加ref="videoRef",模板自动绑定;Vue 2 的选项式 API 里则是this.$refs.videoRef,不需要预先声明一个 ref 变量。
生命周期清理的位置也不同:Vue 3 用onBeforeUnmount,Vue 2 用beforeDestroy。这个清理非常关键,后面第 4 章会详细讲,忘记清理会导致组件销毁后视频还在后台偷偷下载、音频还在响。
还有一个容易忽略的点是this的指向。Vue 2 里如果事件回调用了普通函数并且是通过@ended="handleEnded"绑定的,Vue 会自动绑定组件实例,问题不大;但如果用了setTimeout或者 Promise 的then,this就会丢。Vue 3 的组合式 API 没有这个问题,因为函数都在 setup 作用域里闭包捕获了 ref。这也是我新项目一律上 Vue 3 的原因之一。
3. 核心实现:四个动作的代码落地
3.1 模板结构与 ref 绑定
模板部分我一般分成三层:视频层、状态提示层、按钮控制层。视频层放<video>,给它一个固定宽高比避免加载时布局抖动;状态提示层放一个遮罩或者角标,显示“已暂停”“播放完成”这类文案;按钮层就是一行el-button。这种分层不是为了好看,而是为了后面加功能时不用重构结构。
<video>上有几个属性必须显式写上。preload="metadata"告诉浏览器只预加载元数据,也就是时长和尺寸,别一上来就把整个视频文件拖下来,对流量敏感的场景很重要。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,大于等于 1(HAVE_METADATA)才允许设置。实际场景中用户点按钮的时候元数据基本都加载好了,但如果是切换视频源之后立刻点,就有可能撞上这个时间窗口。
3.4 播放完成后重新播放的兜底逻辑
“重新播放”的语义和“继续播放”不同,它要求无论当前是什么状态,都必须从零开始。所以它的实现不判断isEnded,直接强制currentTime = 0然后play()。这个按钮在播放中点击也应该生效,效果相当于“从头再播一遍”,这在演示类页面里很常见——讲解人想让观众再看一次开头。
我在实际项目里给这个按钮加了一个小优化:点击后先把isEnded置 false,再归零再播。顺序很重要,如果先播再改状态,ended事件和状态更新可能在同一个事件循环里打架,导致按钮在极短时间内闪一下“重新播放”又变回“播放中”。虽然用户看不出来,但对状态的一致性来说是隐患。
4. 完整可运行代码与参数说明
4.1 单文件组件完整实现
下面这份代码是我从最近一个后台项目里抽出来的,去掉了业务相关的部分,保留了全部控制逻辑。用的是 Vue 3<script setup>加 Element Plus,直接新建一个.vue文件粘进去就能跑,视频源换成本地或你自己的地址即可。
<template> <div class="video-panel"> <div class="video-panel__stage"> <video ref="videoRef" class="video-panel__player" :src="videoSrc" preload="metadata" playsinline webkit-playsinline @loadedmetadata="handleLoadedMetadata" @play="handlePlay" @pause="handlePause" @ended="handleEnded" @timeupdate="handleTimeUpdate" ></video> <div v-if="isEnded" class="video-panel__mask">播放完成</div> </div> <div class="video-panel__bar"> <el-slider v-model="progress" :disabled="!duration" :show-tooltip="false" @input="handleSeek" /> <span class="video-panel__time">{{ currentLabel }} / {{ durationLabel }}</span> </div> <div class="video-panel__actions"> <el-button type="primary" :disabled="isPlaying || isEnded" @click="handlePlayClick" >播放</el-button> <el-button type="danger" :disabled="!isPlaying && currentTime === 0" @click="handleStop" >停止</el-button> <el-button :disabled="isPlaying || (currentTime === 0 && !isEnded)" @click="handleResume" >继续播放</el-button> <el-button type="success" @click="handleReplay" >重新播放</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; } </style>Vue 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.videoRef,onBeforeUnmount换成beforeDestroy,其余逻辑一字不改。
4.2 video 关键属性参数取舍
这几个属性看着不起眼,但选错了直接影响首屏体验和流量成本,我挨个说明白。
| 属性 | 建议值 | 为什么这么选 |
|---|---|---|
| preload | metadata | 只拉时长和尺寸,进度条能立刻显示,不浪费流量;设 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,忽略 AbortError |
| iOS 上点播放变全屏 | 缺 playsinline | 补上 playsinline 和 webkit-playsinline |
| 停止后进度条不动 | 漏写 currentTime = 0 | 停止逻辑里补齐归零 |
| 播完点播放卡在最后一帧 | 未显式归零 | play 前判断 isEnded 并设置 currentTime = 0 |
| 切换路由后还有声音 | 组件销毁未释放资源 | onBeforeUnmount 里 pause + 移除 src + load |
| 进度条拖不动 | timeupdate 覆盖了滑块值 | 加 seeking 锁定或改用 change 事件 |
| 视频加载不出来 | 格式不支持或跨域 | 转 H.264,跨域加 crossorigin |
6. 一些踩过坑之后的经验
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 和安卓各家浏览器的表现差异比想象中大,尤其内联播放和进度归零这两个点。我自己养成的习惯是每改一次控制逻辑,就在真机上按“播放、停止、继续、播完、重播”这个顺序完整走一遍,这个顺序基本能覆盖所有状态迁移的边界情况,比看代码可靠得多。