搞前端这些年,video 标签算是被问得最多的媒体标签,没有之一。早两年接在线教育平台的项目,十有八九的需求绕不开这几件事:视频总时长怎么拿、当前播放到哪一秒了怎么实时更新、播完一集怎么自动切到下一集。后来帮朋友做 H5 游戏(就是照着超级玛丽同人复刻版那种怀旧风),video 的 ended 事件又成了关键,动画剧情一结束就得切进游戏主场景,玩过 Flappy Bird 的朋友也可以想象一下,游戏开场放一段提示动画再用事件衔接,全是这套逻辑。今天就把这些最常用的 video 属性和事件一次讲透,最后附一个可以直接抄走的播放器 Demo,代码量不大,但足够撑起绝大多数业务场景。
1. 先把 video 标签的几个核心属性摸清楚
1.1 播放器的基本骨架长什么样
一个最基础的 video 标签,写出来还不到十行:
<video id="myVideo" src="./assets/demo.mp4" controls preload="auto" muted ></video>controls属性是让你直接用浏览器自带的那套控制条,播放、暂停、进度拖拽都有了,省事是真省事。但实际做项目时,产品经理很少会让你一直顶着浏览器原生控件,样式不统一、交互不可控,尤其做在线教育或者视频站点时,十有八九要自定义控制条。所以真正要做的是:把 controls 拿掉,自己用 JS 写一套控制逻辑。这时候 duration、currentTime、ended 这几个属性和背后的一堆事件就成了核心武器。
其他几个常用内置属性也顺带说下:
preload:告诉浏览器视频怎么预加载,可选 none / metadata / auto。想快速拿到时长又不愿意浪费流量,用 metadata。muted:静音属性,移动端自动播放政策下几乎是标配,很多浏览器不允许带声音自动播放,但静音播放可以。loop:循环播放,设置之后视频播完会自动从头开始。autoplay:自动播放,配合 muted 才更容易生效。
1.2 duration、currentTime、ended 到底是个啥
这三个是今天的主角,先把概念掰扯清楚。
duration:视频总时长,单位是秒,带小数的浮点数。比如一段 1 分 30 秒的视频,它的值可能是 90.036。它不是一上来就有值,要等浏览器加载到媒体元数据(metadata)之后才能拿到准确数字,初始状态下是 NaN。
currentTime:当前播放位置,同样以秒为单位。它是可读可写的属性,读取的时候返回当前播放到的位置,写入的时候可以实现跳转播放,比如video.currentTime = 30就是把播放进度调到第 30 秒。这也是做进度条拖拽最核心的 API。
ended:播放是否已经结束,一个只读的布尔值。当视频播放到最后一帧停下来后,ended 变成 true,同时会触发 ended 事件。注意一个细节:如果设置了 loop,视频不会停下来,ended 基本不会触发。
paused / muted / volume / playbackRate这几个也顺带提一下。paused 表示是否暂停,经常用来判断当前该显示播放图标还是暂停图标;muted 是静音开关;volume 是音量,取值 0 到 1;playbackRate 是播放速率,做倍速播放就靠它,比如 2 就是两倍速。
用表格整理一下,方便后面翻查:
| 属性名 | 含义 | 可读写 | 单位/取值 | 备注 |
|---|---|---|---|---|
| duration | 视频总时长 | 只读 | 秒,浮点数 | 元数据加载完成后才有效 |
| currentTime | 当前播放位置 | 可读可写 | 秒,浮点数 | 写入即跳转 |
| ended | 是否播放结束 | 只读 | true / false | 播放到结尾变为 true |
| paused | 是否暂停 | 只读 | true / false | 常用于切换播放按钮状态 |
| muted | 是否静音 | 可读可写 | true / false | 移动端自动播放常配合使用 |
| volume | 音量 | 可读可写 | 0 ~ 1 | 0 到 1 的浮点数 |
| playbackRate | 播放速率 | 可读可写 | 1 为正常速度 | 2 为两倍速,0.5 为半速 |
1.3 为什么读 duration 之前要等 loadedmetadata
很多新手上来就写console.log(video.duration),结果打出来是 NaN,然后就开始怀疑人生。这不是代码写错了,而是浏览器加载视频是分阶段的。
你可以把视频理解成一本厚重的书:浏览器先拿到的是封面和目录(元数据),里面写着这本书多少页、多少字,然后才是正文内容一页页加载进来。duration 就是写在目录上的信息,你连目录都没翻到,自然读不到页数。标签一渲染出来就去读,大概率什么都拿不到。
要等浏览器触发loadedmetadata事件后再去读取,这才能在第一时间拿到稳定准确的总时长。后面我写 Demo 的时候会再演示一次完整写法。
2. 把时长和播放时间真正拿到手
2.1 最稳的时长获取方式:等 loadedmetadata
获取总时长最标准的写法长这样:
const video = document.getElementById('myVideo'); video.addEventListener('loadedmetadata', function () { console.log('总时长(秒):', video.duration); const totalMinutes = Math.floor(video.duration / 60); const totalSeconds = Math.floor(video.duration % 60); console.log(`总时长: ${totalMinutes}:${totalSeconds.toString().padStart(2, '0')}`); });loadedmetadata事件触发的时候,浏览器已经拿到了视频的基本信息,duration 这时就是准的,后续基本不会变。把秒数换算成“分:秒”格式的操作,是所有播放器页面都会写的工具函数,建议直接封装成formatTime留着复用。
这里有个实际开发中的细节:loadedmetadata并不是 duration 可能变化的唯一时机。某些流媒体资源、动态生成的视频,在加载过程中 duration 会更新,这时候会触发durationchange事件。常规 MP4 文件不用担心这个,但如果你做的项目要对接 HLS、DASH 这类流协议,建议durationchange也监听一下,在里面同步时长显示,避免用户看到 00:00 的尴尬。
video.addEventListener('durationchange', function () { if (!isNaN(video.duration)) { totalTimeEl.textContent = formatTime(video.duration); } });2.2 currentTime 的读和写,进度条和跳转都靠它
读取当前播放时间,最推荐的方式是监听timeupdate事件。这个事件会随着播放进度周期性触发,浏览器会尽可能在视频帧更新时同步通知你,频率大概每帧一次,正常播放时每秒能触发好几次,足够用来刷新进度条和时间文字。
const currentTimeEl = document.getElementById('currentTime'); const progressBar = document.getElementById('progressBar'); video.addEventListener('timeupdate', function () { const current = video.currentTime; currentTimeEl.textContent = formatTime(current); const percent = (current / video.duration) * 100; progressBar.style.width = percent + '%'; });currentTime的写入操作,最典型的场景是点击进度条跳转。先算出鼠标点击位置占进度条总宽度的比例,再用这个比例乘总时长,最后赋值给currentTime:
progressWrap.addEventListener('click', function (e) { const rect = progressWrap.getBoundingClientRect(); const ratio = (e.clientX - rect.left) / rect.width; video.currentTime = ratio * video.duration; });看起来简单,但有几个关键点要留意。第一,赋值currentTime之前最好确认duration不是 NaN 也不是 Infinity,否则算出来的跳转目标就是非法值。第二,跳转行为是异步的,赋值后播放位置不会立刻更新,而是等浏览器真正把数据加载到那个位置,触发seeking然后seeked事件,这时候才算跳转完成。所以如果要跳转后立刻做点什么,别在赋值后马上读currentTime,要在seeked回调里处理。
2.3 ended 事件:播放结束后的正确衔接姿势
播放结束最直观的表现,是屏幕停在最后一帧,播放按钮变成可播放的状态,同时ended属性变为 true,全局触发ended事件。
video.addEventListener('ended', function () { // 场景一:显示重播按钮 replayBtn.style.display = 'block'; // 场景二:自动切到下一集 loadNextEpisode(); // 场景三:H5 游戏里,从开场动画切进主场景 startGame(); });这个事件在业务里用途极广。在线教育里播完自动弹出下一集;短视频场景里播完自动播下一条;H5 游戏里用一段预告片做完开场,播完就切进主流程。做超级玛丽同人复刻版那会儿,我就是把 end 事件当作“剧情动画完成”的信号,事件一触发就释放键盘控制、初始化关卡数据、演奏游戏背景音乐,一套流程行云流水。
有一个容易踩的坑想提前说明:如果 video 标签设置了loop属性,视频播完会静默地从头开始,根本就不会触发 ended 事件。如果你一边做“播完自动切换”一边又在标签上留着 loop,代码永远不生效。正确的做法是,不要在 HTML 上写死 loop,需要用循环播放的交互时,在ended事件里手动调用video.play(),这样既实现了循环效果,又能自己在事件里加额外逻辑。
3. 常用 video 事件清单与搭配场景
3.1 一张表看懂所有关键事件
video 标签的事件不少,真正高频使用的大概十五六个。我把它们整理成了一张速查表,按“加载与元数据”、“播放与状态”、“进度与缓冲”、“错误与异常”四类分组,开发的时候直接查表就行。
| 事件名 | 触发时机 | 最常用的场景 |
|---|---|---|
| loadstart | 开始加载媒体数据 | 显示 loading 状态 |
| loadedmetadata | 元数据加载完成,时长等可用 | 获取 duration,初始化 UI |
| loadeddata | 当前帧数据加载完成 | 准备播放前的界面提示 |
| canplay | 缓冲到可以开始播放 | 隐藏 loading,亮起播放按钮 |
| canplaythrough | 预计可以不停顿播完 | 提前隐藏缓冲提示 |
| play | play() 被调用,播放开始 | 同步播放按钮状态 |
| playing | 真正开始播放(从暂停或缓冲恢复) | 隐藏 loading,更新状态 |
| pause | 暂停事件触发 | 切换播放/暂停图标 |
| ended | 播放到结尾 | 播完切换、重播、下一集 |
| timeupdate | 播放位置更新(约每秒多次) | 更新进度条、当前时间 |
| progress | 浏览器加载媒体数据时 | 更新缓冲进度条 |
| durationchange | duration 属性变化 | 重新显示总时长 |
| waiting | 数据不足,播放被阻塞 | 显示 loading |
| seeking | 跳转开始,seeking 属性变 true | 更新 UI 为跳转中 |
| seeked | 跳转完成,seeking 变 false | 隐藏跳转提示 |
| volumechange | volume 或 muted 变化 | 同步音量图标 |
| ratechange | playbackRate 变化 | 更新倍速按钮高亮 |
| error | 媒体加载或播放出错 | 错误提示与容错 |
| stalled | 浏览器尝试拉取数据但未获取到 | 提示网络异常或弱网 |
| emptied | 资源被清空(如重置 src) | 重置播放器状态 |
实际开发中不需要把上面所有事件都用上,但loadedmetadata、timeupdate、playing、pause、ended、waiting、error这七个,基本每个播放器项目都会碰见。
3.2 播放与暂停按钮的状态联动,Promise 的坑要提前避
控制播放暂停的按钮逻辑,看起来是最简单的,但很容易因为忽略 play() 的异步行为而出 bug:
playBtn.addEventListener('click', function () { if (video.paused) { video.play(); playBtn.textContent = '暂停'; } else { video.pause(); playBtn.textContent = '播放'; } });这个写法在多数浏览器里没问题,但在移动端或某些自动播放策略严格的环境里,video.play()会返回一个 Promise,如果播放失败(比如没有用户手势、音量过大被策略拦截),Promise 会 reject,并且抛出一个 Unhandled Rejection 警告,同步把按钮文字改成“暂停”就变成误报状态。更稳的写法是:
playBtn.addEventListener('click', function () { if (video.paused) { video.play().catch(function () { // 播放失败时恢复按钮状态,并给出提示 playBtn.textContent = '播放'; showToast('播放失败,请检查网络后重试'); }); playBtn.textContent = '暂停'; } else { video.pause(); playBtn.textContent = '播放'; } });更稳妥的状态同步方式其实是完全依赖事件驱动。监听play事件把按钮切成“暂停图标”,监听pause事件把按钮切成“播放图标”,点击时只负责调用play()/pause(),UI 的状态交给事件去改。这样任何情况下播放状态变化,按钮都能自动对齐,包括浏览器手势被拦截、被系统电话打断、被后台切走等边界情况。
3.3 高频事件 timeupdate 的性能处理和 buffered 进度
timeupdate是高频事件,正常播放时一秒触发 4 到 66 次不等,如果每次都直接操作 DOM,叠加起来还是有点性能压力的。做进度条时我习惯用requestAnimationFrame做一个简单的节流:事件来了先把目标时间存到一个变量里,具体更新 UI 的操作放到下一帧再去执行,避免一秒钟几十次的强制重排。
let lastCurrentTime = 0; video.addEventListener('timeupdate', function () { lastCurrentTime = video.currentTime; requestAnimationFrame(updateUI); }); function updateUI() { currentTimeEl.textContent = formatTime(lastCurrentTime); progressBar.style.width = (lastCurrentTime / video.duration * 100) + '%'; }更多时候还需要展示缓冲进度,就是进度条上那条淡色的加载进度。这个要监听progress事件,通过video.buffered对象去读取缓冲范围:
video.addEventListener('progress', function () { if (video.buffered.length > 0) { const bufferedEnd = video.buffered.end(video.buffered.length - 1); const percent = bufferedEnd / video.duration * 100; bufferBar.style.width = percent + '%'; } });buffered是一个 TimeRanges 对象,可能包含多段缓冲区间,通常取最后一段的结束时间,基本上就是当前浏览器已经下载到的位置。
4. 完整播放器 Demo:从零写一个带进度条的播放器
4.1 先搭一个能跑的页面结构
理论说了一大堆,不如直接上能跑的 Demo。下面这个例子做到了三件事:显示总时长和当前时间、播放/暂停切换、点击进度条跳转、倍速切换、播放结束提示。代码不复杂,但覆盖了前面讲到的绝大多数细节。
HTML 部分:
<video id="myVideo" src="./assets/demo.mp4" preload="metadata" playsinline muted></video> <div class="player-wrap"> <div class="progress-wrap" id="progressWrap"> <div class="buffer-bar" id="bufferBar"></div> <div class="progress-bar" id="progressBar"></div> </div> <div class="controls"> <button id="playBtn">播放</button> <span id="currentTime">00:00</span> <span>/</span> <span id="totalTime">00:00</span> <select id="speedSelect"> <option value="1">1x 正常</option> <option value="1.5">1.5x</option> <option value="2">2x</option> </select> </div> <div id="endedTip" style="display:none;">播放结束,点击重播</div> </div>核心 JS 部分,我把整个逻辑拆成三个模块:工具函数、事件绑定、UI 更新。事件绑定和 UI 更新分开,是为了后面加功能时不用去大改监听逻辑。
const video = document.getElementById('myVideo'); const playBtn = document.getElementById('playBtn'); const currentTimeEl = document.getElementById('currentTime'); const totalTimeEl = document.getElementById('totalTime'); const progressWrap = document.getElementById('progressWrap'); const progressBar = document.getElementById('progressBar'); const bufferBar = document.getElementById('bufferBar'); const speedSelect = document.getElementById('speedSelect'); const endedTip = document.getElementById('endedTip'); // 工具函数:秒数转 mm:ss function formatTime(seconds) { if (isNaN(seconds)) return '00:00'; const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; } // 1. 元数据加载完成后显示总时长 video.addEventListener('loadedmetadata', function () { totalTimeEl.textContent = formatTime(video.duration); }); // 2. 播放进度更新 video.addEventListener('timeupdate', function () { const current = video.currentTime; currentTimeEl.textContent = formatTime(current); if (video.duration) { progressBar.style.width = (current / video.duration * 100) + '%'; } }); // 3. 缓冲进度 video.addEventListener('progress', function () { if (video.buffered.length > 0) { const bufferedEnd = video.buffered.end(video.buffered.length - 1); bufferBar.style.width = (bufferedEnd / video.duration * 100) + '%'; } }); // 4. 播放 / 暂停按钮 playBtn.addEventListener('click', function () { if (video.paused) { video.play().catch(() => { playBtn.textContent = '播放'; }); } else { video.pause(); } }); // 5. 播放状态联动 video.addEventListener('play', function () { playBtn.textContent = '暂停'; }); video.addEventListener('pause', function () { playBtn.textContent = '播放'; }); // 6. 点击进度条跳转 progressWrap.addEventListener('click', function (e) { const rect = progressWrap.getBoundingClientRect(); const ratio = (e.clientX - rect.left) / rect.width; if (video.duration && isFinite(video.duration)) { video.currentTime = ratio * video.duration; } }); // 7. 倍速切换 speedSelect.addEventListener('change', function () { video.playbackRate = parseFloat(this.value); }); // 8. 播放结束 video.addEventListener('ended', function () { endedTip.style.display = 'block'; playBtn.textContent = '播放'; }); endedTip.addEventListener('click', function () { video.currentTime = 0; video.play(); endedTip.style.display = 'none'; });4.2 这个 Demo 里藏的几个细节,值得展开讲讲
第一,video标签上我加了preload="metadata",意思是页面加载后先只拉元数据,不急着下载整段视频内容。这样页面一进来就能显示总时长,同时不会浪费额外流量。第二,playsinline属性是给 iOS Safari 用的,不加的话 iPhone 上视频动不动就全屏播放,交互非常割裂。第三,进度条跳转前我判断了isFinite(video.duration),这是为了过滤掉 duration 为 Infinity 的边界情况,防止算出一个不可用的跳转位置。
第四,播放结束提示这一块,点击“重播”时先用video.currentTime = 0回到开头,再调video.play()。这里有个细节:手动设置 currentTime 后要等seeked事件触发再调 play 更稳妥,但大多数场景下连续调用也能正常工作,因为浏览器会把播放请求排在跳转后面。想在极端情况下做到万无一失,可以把 play 放到 seeked 回调里执行。
4.3 顺手可以扩容的玩法
这个 Demo 是很基础的骨架,往上面加东西非常顺。比如做倍速播放时,除了playbackRate,有的浏览器会出现画面快进但声音还正常的“声画不同步”错觉,这时候可以监听ratechange事件,在事件里刷新倍速按钮的高亮状态。再比如想加画中画功能,一行代码就够了:
video.requestPictureInPicture();进入画中画之后,页面切到别的 Tab 视频仍然悬浮展示,做网课播放器的时候体验很好。还有全屏,直接调用video.requestFullscreen()就行,跟普通 DOM 全屏逻辑一致。
5. 常见问题与排查技巧实录
5.1 视频时长要么拿不到,要么显示 Infinity
duration返回 Infinity,这个坑大概率是遇到了流媒体资源(比如没配置范围的 MP4 或某些直播流),浏览器无法预知总时长。解决办法是区分场景:如果是点播文件,检查服务器是否正确响应 Range 请求,视频文件建议用 Nginx 等静态服务器托管并开启 Range 支持;如果本身就是直播或无限流,界面就不要显示总时长,改成“直播中”之类的标识。代码层面可以用一下判断兜底:
if (isFinite(video.duration)) { totalTimeEl.textContent = formatTime(video.duration); } else { totalTimeEl.textContent = '直播中'; }5.2 currentTime 赋值了但跳不过去
赋值video.currentTime = 100后没有反应,排查方向有三步:第一步确认duration不是 NaN,跳转目标不能超过总时长;第二步确认视频不是用MediaSource动态追加数据的流,这类资源如果缓冲区没有对应位置的数据,跳转会卡住或自动失效;第三步检查有没有别的事件把 currentTime 又重置了,比如emptied事件触发时资源被清空,所有播放状态都会复位。
5.3 ended 一直不触发
优先查三件事:标签上有没有写死loop属性;视频有没有真的播到最后一帧(比如中途卡住了就没法触发);是不是用了MediaStream这种实时流,实时流不存在“播完”的概念。还有一种情况是写代码时把ended事件绑定放在了src赋值之前,并且绑的是同一个函数但作用域里变量还没初始化,导致报错中断。做视频组件时建议把事件绑定逻辑统一在一个initEvents函数里,video 元素挂载完成后立即调用。
5.4 移动端视频播放的一堆“怪癖”
安卓和 iOS 对 video 的自动播放策略不尽相同,但有一条通用规律:带声音的自动播放大概率被拦截,静音自动播放几乎都能过。很多运营活动页要求“进页面自动播放宣传视频”,稳妥方案是标签上先muted,等用户点击后或播放进行中再取消静音。还有一个坑是安卓某些浏览器在视频离开视口后会自动画中画,需要监听leavepictureinpicture之类的事件做兼容处理,这个不同浏览器行为差异比较大,设计页面时留好交互兜底。
5.5 页面卡顿:别让小视频拖垮了主线程
前面讲过timeupdate的高频特性。如果页面上同时有好几个视频,或者视频播放之外还要做复杂的动画,高频刷新 DOM 很容易造成掉帧。建议统一用requestAnimationFrame节流,并且把 progress 节流到一秒最多一两次。另一个容易忽略的点是 CSS 里大量使用width加百分比布局,progress 条更新会触发 Layout,数据量上来后性能就崩了,可以改用transform: scaleX()来做进度条伸缩,性能会好很多。
5.6 事件绑定的常见野路子
有些同学为了方便,直接在 HTML 标签上写onended="myFun()",这种做法在当前项目里快速验证可以,但复用到工程化项目就很别扭,全局函数污染、作用域混乱、难以移除监听。建议统一用addEventListener。如果用了 Vue 或 React,注意在组件卸载时移除事件监听,或者直接依赖框架的生命周期钩子去管理绑定和解绑,否则会带来重复触发的隐患。
最后聊几句实在的
说句大实话,video 标签的官方文档一页纸就能翻完,属性事件也就那些,但真到项目里要踩的坑一点不比业务逻辑少。我体会最深的一点是:不要把 video 当黑盒,凡是和 UI 状态同步相关的,全都以事件为准,别自己开定时器去轮询属性。浏览器已经替你调度好了播放时机、缓冲节奏和事件触发顺序,你非要再造一套轮子,只会给自己添堵。
再分享一个小技巧:做 H5 游戏开发的朋友,如果要用 video 当开场动画,记得在ended触发后调用一次video.pause()再切场景,有些浏览器播放结束后会停在最后一帧并保留解码资源,提前释放内存能让游戏主场景加载更快。真要把这套东西玩出更多花样,可以试试把 video 和 Canvas 结合,每帧把视频画面 draw 到 Canvas 里做滤镜特效,那又是另一个大坑了。总之,先把基础属性和事件吃透,后面扩展什么都稳。