ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

HTML5 video事件详解:duration、timeupdate、ended的坑与实战

HTML5 video事件详解:duration、timeupdate、ended的坑与实战 先直接抛个结论HTML5 的 video 标签难点从来不是怎么把视频放上去而是怎么处理和播放相关的各种状态和时间信息。你去看网上一堆教程翻来覆去就那几个 API 名字duration、currentTime、ended事件。但真到项目里一做马上会遇到“duration 怎么是 NaN”、“timeupdate 到底多久触发一次”、“播完了怎么不触发 ended”这类问题。我早期做视频播放页的时候就因为这些折腾了不少时间。当时客户要求很简单显示视频总时长、当前播放时间播放结束后自动跳转下一个视频。我照着文档写代码没几行跑起来却全不对。后来把 video 的事件机制彻底捋了一遍才算真正搞懂。这篇文章就把我从头到尾的实践、踩坑和最终方案全部分享出来希望能帮你少走弯路。1. 先从事件驱动说起video标签一堆事件到底谁先谁后很多人第一次接触 video 标签习惯性地把它当成一个“对象”直接读属性就行。比如想知道总时长就写video.duration想知道当前播放到哪里就写video.currentTime。想法没错但忽略了关键的一点HTML5 video 里的这些属性不是一开始就有值的而且值会随着播放动态变化。打个比方你就明白了。video.duration就像外卖 App 里的预计送达时间你刚下单的时候它显示不出来得等商家接单、骑手取餐之后才会慢慢更新。如果你在页面加载完的一瞬间去读duration拿到的基本都是NaN。同样的道理currentTime也会随着播放不停地变化。所以 video 标签的核心使用方式不是“读取静态属性”而是“监听状态变化的事件”。视频从开始加载到真正播放结束会经历元数据加载、数据缓冲、播放、暂停、快进、报错等一系列过程每一步都有对应的事件。你的业务逻辑就是围绕这些事件去做响应。看几张我在项目里常用的核心事件表先建立整体印象事件名触发时机常用场景loadedmetadata视频元数据加载完成后获取duration、videoWidth等loadeddata当前帧数据加载完成显示第一帧画面timeupdate播放位置改变时周期性触发更新进度条、当前时间playplay()被调用或自动播放启动时按钮状态切换playing暂停后数据充足恢复播放时隐藏加载提示pause播放被暂停时按钮状态切换waiting因缺少数据而暂停播放时显示缓冲 Loadingcanplay有足够数据开始播放时允许用户点播放canplaythrough有足够数据可完整播放时预加载完成提示seeking用户拖动进度条、开始跳转时显示跳转 Loadingseeked跳转完成后隐藏跳转 Loadingended播放到结尾时播完后的业务逻辑error加载或播放发生错误时错误提示progress浏览器下载视频时周期性触发更新缓冲进度条看到这些事件你应该能感觉到做 video 播放器更像是在处理一个异步流程加载、缓冲、播放、跳转、结束每个环节都需要监听对应事件才能做出正确的界面反馈和业务处理。实际操作里我见过不少同行图省事用setInterval去轮询currentTime来更新进度条。不是说绝对不能这么做但有几个明显问题一是轮询频率不好选设高了浪费性能设低了进度条明显卡顿二是你轮询不到“元数据加载完成”这种关键时点更没法知道视频是不是因为缓冲停住了。用事件驱动代码逻辑清晰得多性能也更优。2. 拿时长和时间戳之前先避开设计里的几个坑这一节是全文的核心之一把“获取视频总时长”和“当前播放时间”这两个需求掰开揉碎地讲清楚。2.1 duration在loadedmetadata前是NaNduration返回的是视频总时长单位秒。它看起来是个普通属性实际上是一个“延迟就绪”的属性。视频的元数据是从网络上下载的在loadedmetadata事件触发之前duration的值是NaN。这也是网上最常见的一段错误代码const video document.getElementById(video); console.log(video.duration); // 很可能输出 NaN正确做法是等loadedmetadata触发后再读取const video document.getElementById(video); video.addEventListener(loadedmetadata, () { console.log(视频总时长, video.duration); // 这里才能安全地拿到时长 });顺带提一个坑中坑如果视频是直播流或未正确返回 Content-Length 的流媒体duration会一直是Infinity永远不会变成一个有限数字。所以拿到duration后要做一次判断不能直接拿来算进度条百分比。我一般这样处理video.addEventListener(loadedmetadata, () { const duration video.duration; if (Number.isFinite(duration)) { // 普通点播视频走正常逻辑 initProgressBar(duration); } else { // 直播流或者未知时长禁用进度条显示 LIVE enterLiveMode(); } });2.2 timeupdate的触发频率没有你想象的那么快currentTime表示当前播放时间单位也是秒。这个属性是可读写的给它赋值就能实现拖动跳转。但要注意浏览器不会每时每刻都通知你“当前时间变了”而是通过timeupdate事件周期性地告诉你。这个周期在不同浏览器里略有差异实测下来大约每 250ms 触发一次也就是一秒钟触发 34 次左右。这个频率对更新进度条、显示时间文本来说完全够用。但如果你想做逐帧动画、或者做精确到帧的互动效果光靠timeupdate是不行的需要结合requestAnimationFrame或者视频帧绘制相关的 API。我见过一个真实案例有人想做一个“在视频播放的同时同步跳动一个动画角色”的效果他把角色位置的更新写在了timeupdate里结果动画一卡一卡的。这其实不是timeupdate卡而是它本身的粒度就决定了达不到 60fps 的流畅度。2.3 浮点精度和格式化显示的问题另一个容易忽略的细节是浮点精度问题。视频播放到最后currentTime并不会严格等于duration可能会差 0.001 秒甚至更多。比如视频总时长 12 秒播放结束时currentTime可能是 11.999999。如果你用currentTime duration去判断是否播完几乎永远等不到 true。正确的判断方式是设一个容差范围const isAtEnd video.duration - video.currentTime 0.5;显示时间文本的时候也要注意格式化。常见的格式是分钟:秒比如03:25。我写了一个通用的格式化函数可以直接抄function formatTime(seconds) { if (!Number.isFinite(seconds) || Number.isNaN(seconds)) { return 00:00; } const min Math.floor(seconds / 60); const sec Math.floor(seconds % 60); return ${String(min).padStart(2, 0)}:${String(sec).padStart(2, 0)}; }注意Number.isFinite的判断这能同时过滤掉NaN和Infinity避免直播流把时间文本搞成00:00 / Infinity。2.4 设置currentTime跳转不是随时都可以想实现点击进度条跳转直接给currentTime赋新值就行。但如果时机不对代码会报错。常见错误是视频还没加载好就尝试设置currentTime。在loadedmetadata之前浏览器根本不知道视频的可跳转范围此时赋值会抛异常。所以比较稳妥的做法是在loadedmetadata之后启用进度条交互并且在设置currentTime时用try/catch兜一下。progressBar.addEventListener(input, () { const duration video.duration; if (!Number.isFinite(duration) || duration 0) return; const targetTime (progressBar.value / 1000) * duration; video.currentTime targetTime; // 必要时套 try/catch });3. 播放结束一个ended不够边界情况比想象多标题明确提到了“播放结束触发的事件”对应的事件就是ended。这一节我要讲清楚这个事件的触发条件以及几类不容易想到的边界情况。3.1 ended事件的触发链路ended事件在视频播放到结尾时触发。这里说的“播放到结尾”是指currentTime到达duration的终点位置而且视频是按正常顺序播放完的。一个很自然的场景就是“视频课程学习”用户把一节课看完了你要记录学习进度、弹出“完成”按钮、或者自动跳到下一集。在ended事件里写这些业务逻辑非常合适video.addEventListener(ended, () { // 1. 标记课程完成 markCourseComplete(courseId, lessonId); // 2. 显示重播按钮 replayButton.classList.remove(hidden); // 3. 自动播放下一个视频 if (currentIndex playlist.length - 1) { video.src playlist[currentIndex]; video.play(); } });3.2 设置了loop不会触发endedloop属性可以让视频循环播放这在做背景视频、广告展示墙时很常用。但注意设置了loop之后视频播放到结尾会无缝从头开始浏览器不会触发ended事件。也就是说如果你既想让视频循环播放又想在“每一遍播完”时做一些统计比如记录循环了几次光靠ended是不行的得自己用timeupdate去监听播放位置接近终点的情况video.addEventListener(timeupdate, () { if (video.duration - video.currentTime 0.05) { loopCount; } });3.3 手动把currentTime设成duration并不会触发ended这个坑我印象特别深。有一次做自定义播放器我想实现“拖动进度条到最右边 播放结束”的效果于是在timeupdate里判断如果拖动到末尾就把currentTime赋值为duration以为这样能触发ended。结果发现什么都不触发。原因在于浏览器对currentTime的赋值有一套内部限制它会把你的值限制在一个可视作的范围内通常不允许你精确地设置到duration而是停在duration之前的某个极小偏差位置。赋值成功之后视频并不会自动推进到最后所以ended不触发。正确的做法是当你检测到用户拖到末尾时直接调用业务逻辑或者把视频暂停并手动触发你想要的“结束”动作而不是指望系统ended事件。3.4 直播流和MSE流不会自然触发ended前面提到过直播流的duration是Infinity它永远不会播完所以ended永远不会触发。这个好理解。容易踩坑的是基于 Media Source ExtensionsMSE实现的点播播放器。通过 MSE 喂数据的话视频播完并不会自动触发ended你需要主动调用mediaSource.endOfStream()告诉播放器“数据流结束了”之后浏览器才会触发ended。如果你在用第三方播放器内核遇到ended不触发的问题优先检查是不是 MSE 模式下漏掉了这一步。3.5 视频损坏、网络中断不会触发ended而是触发errorended只在正常播放完毕时触发。如果视频文件损坏、解码失败、网络中断触发的是error事件而不是ended。所以业务逻辑里不能只监听ended还要监听error给用户一个友好的提示同时做好异常上报。video.addEventListener(error, () { const error video.error; const errorMap { 1: 用户主动中断了下载, 2: 网络错误加载中断, 3: 视频解码失败可能文件损坏, 4: 浏览器不支持该视频格式 }; console.error(VideoError:, errorMap[error.code] || 未知错误); });4. 常用事件速查与真实玩法不止play和pause除了timeupdate和ended实际项目里高频使用的还有一批事件。它们不一定要全部用到但每个都可能在特定场景下帮你解决问题。4.1 play、playing、pause三兄弟别混用这三个事件新手最容易搞混。play当play()方法被调用或浏览器收到自动播放指令时触发。注意这仅仅表示“开始播放的请求已发起”不代表视频真的在播放了。在移动端或浏览器自动播放策略下play()即使被调用也可能因为策略限制而失败。playing视频真正开始播放以后触发。如果视频因为缓冲数据不足暂停了等数据够了恢复播放也会触发playing。pause视频暂停时触发。一个很实际的应用场景是播放按钮的文案切换。点击播放按钮后你希望在视频真正播放时显示“暂停”在缓冲卡住时显示“加载中”。如果只用play和pause缓冲等待那段时间按钮文案会停留在“暂停”体验不好。正确做法是结合playing、waiting、pause三个事件来更新按钮状态。playButton.addEventListener(click, () { if (video.paused) { video.play().catch(() { button.textContent 播放; }); } else { video.pause(); } }); video.addEventListener(play, () { button.textContent 暂停; }); video.addEventListener(playing, () { button.textContent 暂停; }); video.addEventListener(waiting, () { button.textContent 加载中…; }); video.addEventListener(pause, () { button.textContent 播放; });另外强调一个验证过很多次的事实现代浏览器都不允许带声音的视频自动播放只有静音状态下才能通过autoplay属性自动播放。所以前端做自动播放之前一定要设置muted属性或者用video.muted true再调用play()。play()返回的是一个 Promise如果被拦截会 reject建议加.catch()兜底。4.2 waiting与canplay理解缓冲这把双刃剑waiting在“视频播放到一半后面的数据还没下载好不得不暂停等待缓冲”时触发。这个过程用户体验很不好但前端能做的就是展示一个 Loading 动画再配合playing事件把它关掉。canplay和canplaythrough是另外两个容易被忽略的事件。canplay表示有足够的数据可以开始播放但不保证能一口气播完canplaythrough表示预计能持续播放到结束。在做“预加载完成后自动播放”这类需求时canplaythrough是一个很有用的信号。4.3 progress与缓冲进度条的实现progress事件在浏览器下载媒体数据时周期性触发。配合video.buffered属性可以拿到已经缓冲的范围从而画出类似 YouTube 那种灰色缓冲进度条。function updateBuffered() { const duration video.duration; if (!Number.isFinite(duration) || duration 0) return; if (video.buffered.length 0) { const bufferedEnd video.buffered.end(video.buffered.length - 1); bufferedBar.style.width ${(bufferedEnd / duration) * 100}%; } } video.addEventListener(progress, updateBuffered);buffered是一个TimeRanges对象可能有多段缓冲区间。一般我们取最后一段的end值表示目前下载到哪个位置了这样画的缓冲条更贴近用户感知。4.4 seeking与seeked拖动进度条的正确响应方式拖动进度条设置currentTime后会先触发seeking跳转完成后触发seeked。如果你的视频文件很大、网络又不好跳转可能需要等待。在这期间可以在界面上显示转圈 Loading等seeked再隐藏。另外拖动input[typerange]时input事件会连续触发很多次。如果每次input都去设置currentTime会产生大量跳转请求播放器会卡顿甚至崩溃。我的实践是拖动过程中只更新 UI 的预览值等用户松手change事件再一次性设置currentTime。4.5 loadedmetadata与loadeddata拿完时长别忘了宽高loadedmetadata除了能获取duration还能拿到视频的原始宽高video.addEventListener(loadedmetadata, () { const width video.videoWidth; const height video.videoHeight; // 根据宽高比调整播放器尺寸避免画面变形 });在做视频列表页时这个信息很有用。你可以提前用宽高比占位防止页面布局抖动。videoWidth是原始分辨率宽度不是元素显示宽度记得区分。loadeddata表示当前帧的数据已经加载完成也就是画面已经可以显示了。如果你想在视频封面图上做“加载完自动隐藏封面”监听这个事件很合适。4.6 由热搜词带出来的几个实用操作写这篇的时候正好看到一堆跟 video 相关的高频搜索词其中有几个在项目里也确实很常用顺便提一下倍速播放video.playbackRate可以控制播放速度支持小数比如 0.5、1.5、2。在做上网课、刷视频这类产品时是刚需。切换时注意先把当前速度读出来再设置新值。视频旋转有些视频是竖屏拍摄的在电脑上播放默认是横的。单纯用 CSS 旋转视频元素时注意把视频容器尺寸也做适配。搜索词里那行v.style.rotate -90deg确实能转但要配合容器宽高调整才好看。画中画video.requestPictureInPicture()可以让视频悬浮在小窗里用户切到其他标签页还能继续看。这个 API 的兼容性已经足够好值得用起来。全屏video.requestFullscreen()加上document.fullscreenElement判断基本就是浏览器原生全屏的标准玩法。这些操作本质上都绕不开事件监听倍速设置以后进度条更新逻辑不用变因为timeupdate会按照playbackRate的比例来汇报时间进度旋转只是视觉变化不影响事件流画中画有独立的enterpictureinpicture和leavepictureinpicture事件要在事件里处理界面上“进入画中画”和“退出画中画”的状态。5. 把知识点串起来自定义播放器实战代码前面讲了这么多事件和属性最终还是要落在可运行的项目里。这一节我提供一个完整的自定义播放器实现集成了上面讲到的绝大部分知识点总时长显示、当前播放时间、可拖动进度条、缓冲进度条、播放/暂停切换、倍速切换、全屏、播放结束重播、错误处理。代码可以直接粘贴到 HTML 里跑起来。5.1 HTML结构与基础样式div classplayer video idvideo srchttps://www.w3schools.com/html/mov_bbb.mp4 playsinline preloadmetadata /video div classcontrols button idplayBtn播放/button div classprogress-wrap div idbufferedBar classbuffered-bar/div input idprogressBar typerange min0 max1000 value0 / /div span idtimeText00:00 / 00:00/span button idspeedBtn1.0x/button button idfullBtn全屏/button /div /div.player { width: 100%; max-width: 720px; background: #000; } .player video { display: block; width: 100%; } .controls { display: flex; align-items: center; gap: 8px; padding: 10px; background: #222; color: #fff; } .progress-wrap { position: relative; flex: 1; height: 20px; } .progress-wrap input[typerange] { position: relative; z-index: 2; width: 100%; margin: 0; background: transparent; } .buffered-bar { position: absolute; top: 8px; left: 0; width: 0; height: 4px; background: #555; z-index: 1; }5.2 JavaScript完整逻辑const video document.getElementById(video); const playBtn document.getElementById(playBtn); const progressBar document.getElementById(progressBar); const bufferedBar document.getElementById(bufferedBar); const timeText document.getElementById(timeText); const speedBtn document.getElementById(speedBtn); const fullBtn document.getElementById(fullBtn); const speeds [0.5, 1, 1.25, 1.5, 2]; function formatTime(seconds) { if (!Number.isFinite(seconds) || Number.isNaN(seconds)) return 00:00; const m Math.floor(seconds / 60); const s Math.floor(seconds % 60); return ${String(m).padStart(2, 0)}:${String(s).padStart(2, 0)}; } function updateTimeText() { const current video.currentTime || 0; const total video.duration; if (Number.isFinite(total)) { timeText.textContent ${formatTime(current)} / ${formatTime(total)}; } else { timeText.textContent ${formatTime(current)} / LIVE; } } function updateBuffered() { const total video.duration; if (!Number.isFinite(total) || total 0) return; if (video.buffered.length 0) { const bufferedEnd video.buffered.end(video.buffered.length - 1); bufferedBar.style.width ${(bufferedEnd / total) * 100}%; } } // 1. 播放/暂停 function togglePlay() { if (video.paused) { video.play().catch(() { playBtn.textContent 播放; }); } else { video.pause(); } } playBtn.addEventListener(click, togglePlay); video.addEventListener(click, togglePlay); video.addEventListener(play, () { playBtn.textContent 暂停; }); video.addEventListener(playing, () { playBtn.textContent 暂停; }); video.addEventListener(waiting, () { playBtn.textContent 加载中…; }); video.addEventListener(pause, () { playBtn.textContent 播放; }); // 2. 元数据加载完成后初始化 video.addEventListener(loadedmetadata, () { updateTimeText(); if (!Number.isFinite(video.duration)) { progressBar.disabled true; } }); // 3. 更新进度条和时间文本 video.addEventListener(timeupdate, () { const total video.duration; if (Number.isFinite(total) total 0) { progressBar.value (video.currentTime / total) * 1000; } updateTimeText(); updateBuffered(); }); // 4. 缓冲进度更新 video.addEventListener(progress, updateBuffered); // 5. 进度条拖动input只更新UIchange才真正跳转 progressBar.addEventListener(input, () { const total video.duration; if (!Number.isFinite(total) || total 0) return; const previewTime (progressBar.value / 1000) * total; timeText.textContent ${formatTime(previewTime)} / ${formatTime(total)}; }); progressBar.addEventListener(change, () { const total video.duration; if (!Number.isFinite(total) || total 0) return; video.currentTime (progressBar.value / 1000) * total; }); // 6. 跳转时的seeking/seeked处理 video.addEventListener(seeking, () { playBtn.textContent 加载中…; }); video.addEventListener(seeked, () { playBtn.textContent video.paused ? 播放 : 暂停; }); // 7. 倍速切换 speedBtn.addEventListener(click, () { const current video.playbackRate; let idx speeds.indexOf(current); if (idx -1) idx 1; const nextSpeed speeds[(idx 1) % speeds.length]; video.playbackRate nextSpeed; speedBtn.textContent ${nextSpeed.toFixed(2)}x; }); // 8. 全屏切换 fullBtn.addEventListener(click, () { if (document.fullscreenElement) { document.exitFullscreen(); } else { video.requestFullscreen(); } }); // 9. 播放结束显示重播并支持一键重播 video.addEventListener(ended, () { playBtn.textContent 重播; }); playBtn.addEventListener(click, () { if (playBtn.textContent 重播) { video.currentTime 0; video.play(); } }); // 10. 错误处理 video.addEventListener(error, () { const mediaError video.error; if (!mediaError) return; const errorMap { 1: 用户中断了下载, 2: 网络错误, 3: 解码失败文件可能已损坏, 4: 浏览器不支持此视频格式 }; alert(errorMap[mediaError.code] || 未知播放错误); });5.3 代码走读几个值得注意的设计这个播放器里藏了几个不容易直接看出来的细节展开说一下进度条的 input 和 change 分离input事件在拖动过程中高频触发我只用它来更新显示层的时间预览。change事件在松手后才触发这时候才真正写入currentTime。这样避免高频跳转拖垮播放器。ended 之后的“重播”处理播放结束我把按钮文字改成“重播”然后单独拦截按钮点击。这个思路比在ended里强制play()更稳因为用户可能并不想立即重播他可能想先点暂停。seeked 里用 video.paused 判断跳转完成后播放状态可能是暂停的也可能是播放的不能写死。所以通过video.paused反推按钮文案比硬编码更准确。在 React 或 Vue 里这套逻辑不用大改只是事件绑定方式略有不同。以 React 为例video onLoadedMetadata{handleLoadedMetadata} onTimeUpdate{handleTimeUpdate} onEnded{handleEnded} ref{videoRef} /核心的事件处理函数是一样的框架只是帮我们把addEventListener的生命周期管理掉了。5.4 写完之后的测试清单代码写完后建议手动过一遍测试清单能帮你发现很多潜在问题首次打开页面时间显示是否为00:00 / 00:00而不是NaN / NaN。视频加载中播放按钮点击后是否显示“加载中…”。播放过程中直接切换倍速按钮文本和时间进度是否正确。拖动进度条到末尾松手后是否触发了ended。把视频源src改成一个不存在的地址错误提示是否正常弹出。播放结束后点“重播”能否正常回到开头重新播放。窗口进入全屏再退出播放状态是否保持正常。这七项如果全部通过这个播放器的基础交互就算稳了。最后再说点实际操作里的体会做了几年视频相关的功能我最大的感受是video 标签的事件其实就那么多难的不是记住事件名而是搞清楚每个事件在什么时机触发、什么时机不触发。比如duration要等loadedmetadataended对直播流无效currentTime不能直接赋成duration来模拟结束。这些细节光看文档很容易忽略但在项目里碰到了又特别耽误时间。建议你把这个播放器代码跑起来在控制台把loadedmetadata、timeupdate、playing、waiting、ended这些事件全部console.log出来切身感受一下它们的触发顺序和频率。搞懂一遍之后后面再遇到任何视频类的需求比如加字幕、多音轨、清晰度切换、视频列表自动连播你都能很快找到对应的实现路径。
返回列表