视频事件系统:彻底掌握从 loadstart 到 ended 的完整生命周期
很多开发者写播放器靠的是"感觉"——觉得差不多能播了就调
play(),觉得卡了就显示 loading,觉得结束了就做下一步。这种写法在简单场景能跑,但一旦遇到网络波动、跳转操作、移动端兼容,就开始出现各种诡异的 Bug。根治这些问题的唯一方法,是真正理解视频的事件系统——它是视频播放器的神经网络。
先建立一个整体观
HTMLMediaElement 的事件可以分成四条主线,它们并行运转,共同描述视频的完整状态:
主线一:加载链 loadstart → durationchange → loadedmetadata → loadeddata → canplay → canplaythrough
主线二:播放链 play → playing → timeupdate → pause / ended
主线三:缓冲链 progress → waiting → playing(恢复)/ stalled → suspend
主线四:跳转链 seeking → seeked
另外还有几条"特殊线":error(错误)、emptied(重置)、ratechange(速率变化)、volumechange(音量变化)、enterpictureinpicture / leavepictureinpicture(画中画)。
理解这四条主线的触发时机和相互关系,是构建健壮播放器的核心。
第一部分:加载链 — 视频从无到可播的完整过程
loadstart — 一切的起点
当浏览器开始请求媒体资源时触发。注意:这个事件在网络请求发出时触发,不是在 <video> 元素插入 DOM 时。如果设置了 preload="none",这个事件会延迟到 video.play() 或 video.load() 被调用时才触发。
video.addEventListener('loadstart', () => {
console.log('开始请求资源:', video.currentSrc);
showLoadingSpinner(); // 展示加载动画
});durationchange — 时长就绪
当 duration 属性的值发生变化时触发。这个事件会触发两次:第一次是从 NaN 变成实际时长,第二次在某些情况下(如直播流动态更新)会再次触发。
video.addEventListener('durationchange', () => {
if (!isNaN(video.duration)) {
console.log('视频时长:', video.duration, '秒');
// 此时可以安全初始化进度条的总长度
progressBar.max = video.duration;
}
});loadedmetadata — 元数据就绪,readyState = 1
这是加载链中最重要的节点之一。触发时,以下信息全部可用:duration、videoWidth、videoHeight、textTracks、audioTracks。这是初始化播放器 UI 的最佳时机。
video.addEventListener('loadedmetadata', () => {
// 此刻 readyState === 1 (HAVE_METADATA)
console.log(`分辨率: x`);
console.log(`总时长: s`);
// 初始化 Canvas 尺寸(必须在这里设置,否则 videoWidth 是 0)
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// 设置进度条最大值
progressBar.setAttribute('max', video.duration);
// 此时可以安全地设置 currentTime(在 readyState=0 时设置会被忽略)
if (savedTime) {
video.currentTime = savedTime;
}
});跨平台坑:iOS Safari 在
preload="none"时,即使调用了play(),也不一定会触发loadedmetadata——它可能直接跳到canplay。不要假设loadedmetadata一定在canplay之前触发。
loadeddata — 第一帧就绪,readyState = 2
当前播放位置的帧数据已可用,但不足以继续播放(只有当前这一帧)。这是显示视频第一帧、隐藏 poster 图的最佳时机。
video.addEventListener('loadeddata', () => {
// readyState === 2 (HAVE_CURRENT_DATA)
// 第一帧已可显示,可以隐藏封面图
poster.style.display = 'none';
console.log('第一帧已就绪');
});canplay vs canplaythrough — 最容易混淆的两个事件
这两个事件的区别是 readyState 到达的阈值不同,也是最常被混用的一对:
canplay 在 readyState = 3(HAVE_FUTURE_DATA)时触发,意味着可以开始播放,但不保证流畅,中途可能因缓冲不足而暂停。
canplaythrough 在 readyState = 4(HAVE_ENOUGH_DATA)时触发,意味着浏览器估计可以流畅播放到结束,不需要停下来缓冲。
video.addEventListener('canplay', () => {
// 可以开始播放,但可能卡顿
// 适合:展示"开始播放"按钮,让用户决定是否等待更多缓冲
playBtn.disabled = false;
});
video.addEventListener('canplaythrough', () => {
// 数据充足,流畅播放有保障
// 适合:自动开始播放、隐藏缓冲提示
hideBufferingHint();
});实践建议:在大多数场景下,监听 canplay 就够了——等 canplaythrough 可能让用户等待过久,尤其在移动网络下。对于 AI 视频的流式播放场景,canplay 是触发播放的正确时机。
重要:
canplay和canplaythrough在视频的整个生命周期中可能触发多次——每次从waiting状态恢复后,只要readyState重新达到阈值,就会再次触发。不要用{ once: true }来监听它们,除非你明确知道只需要处理第一次。
第二部分:播放链 — 正在播放时发生了什么
play vs playing — 意图 vs 实际
这是另一对极其容易混淆的事件,但它们描述的是两件完全不同的事:
play 事件在 video.play() 被调用或用户点击播放按钮时立即触发,此时视频不一定真的在播——它可能还在缓冲。
playing 事件在视频真正开始输出画面时触发,包括从暂停恢复、从 waiting 状态恢复、以及首次开始播放。
video.addEventListener('play', () => {
// 播放"意图"已触发,更新按钮图标
playBtn.innerHTML = '⏸';
console.log('play 触发,但视频可能还在缓冲');
});
video.addEventListener('playing', () => {
// 视频真的在播了!
hideLoadingSpinner(); // 隐藏 loading
console.log('playing 触发,画面真实输出中');
});一个典型的错误:在 play 事件里隐藏 loading spinner。正确做法是在 playing 里隐藏,因为 play 触发后视频可能还在缓冲,此时 loading 还应该显示。
timeupdate — 进度更新,高频事件的节流处理
timeupdate 在 currentTime 发生变化时触发,频率大约是每秒 4 ~ 66 次(W3C 规范没有规定精确频率,浏览器自行决定)。这意味着在 1 分钟的视频播放过程中,它可能触发 240 ~ 3960 次。
直接在 timeupdate 里操作 DOM 是性能杀手,必须做节流:
// 错误写法:每次 timeupdate 都操作 DOM
video.addEventListener('timeupdate', () => {
progressBar.value = video.currentTime; // 高频 DOM 操作
timeLabel.textContent = formatTime(video.currentTime); // 高频 DOM 操作
});
// 正确写法一:用 requestAnimationFrame 限流
let rafId = null;
video.addEventListener('timeupdate', () => {
if (rafId) return; // 已有待执行的帧,跳过
rafId = requestAnimationFrame(() => {
progressBar.value = video.currentTime;
timeLabel.textContent = formatTime(video.currentTime);
rafId = null;
});
});
// 正确写法二:用时间戳节流(每 250ms 最多更新一次)
let lastUpdate = 0;
video.addEventListener('timeupdate', () => {
const now = Date.now();
if (now - lastUpdate < 250) return;
lastUpdate = now;
progressBar.value = video.currentTime;
timeLabel.textContent = formatTime(video.currentTime);
});timeupdate 的另一个重要用途是定时打点统计——记录用户实际观看了哪些片段,结合 video.played 属性可以构建精确的观看行为分析。
pause 与 ended — 停止的两种方式
pause 在视频暂停时触发(包括用户主动暂停、waiting 状态导致的内部暂停不会触发 pause)。
ended 在视频播放到末尾时触发。注意:如果设置了 loop 属性,视频会直接从头开始,不会触发 ended。
video.addEventListener('pause', () => {
playBtn.innerHTML = '▶';
// 保存播放进度
localStorage.setItem('lastTime', video.currentTime);
});
video.addEventListener('ended', () => {
playBtn.innerHTML = '↺';
// 播放结束,此时 video.currentTime === video.duration
// 推荐下一个视频
showNextVideoRecommendation();
// 上报完播事件
analytics.track('video_completed', { duration: video.duration });
});第三部分:缓冲链 — 四个容易混淆的网络事件
这四个事件是最难区分的:waiting、stalled、suspend、abort。很多开发者分不清它们,导致 loading 状态管理混乱。
waiting — 缓冲不足,临时停播
waiting 在视频因为数据不足而被迫暂停时触发。这是最常见的"卡顿"信号。触发后,一旦数据足够,会自动恢复播放并触发 playing。
video.addEventListener('waiting', () => {
// 缓冲不足,显示 loading 转圈
showLoadingSpinner();
console.log('缓冲中... readyState:', video.readyState);
});
video.addEventListener('playing', () => {
// 缓冲恢复,隐藏 loading
hideLoadingSpinner();
});waiting → playing 这对事件是管理缓冲 loading 状态的标准组合,必须成对处理。
stalled — 数据获取停滞
stalled 在浏览器尝试获取数据但数据迟迟没有到来时触发(通常是网络问题或服务器无响应)。与 waiting 的区别在于严重程度:waiting 是"数据还没到",stalled 是"数据根本没在来的路上"。
stalled 触发后不会自动恢复,通常需要开发者介入:
let stalledTimer = null;
video.addEventListener('stalled', () => {
console.warn('数据获取停滞,可能是网络问题');
// 等待 5 秒后尝试重新加载
stalledTimer = setTimeout(() => {
const currentTime = video.currentTime;
video.load();
video.currentTime = currentTime;
video.play();
}, 5000);
});
video.addEventListener('playing', () => {
// 恢复播放,清除重试计时器
if (stalledTimer) {
clearTimeout(stalledTimer);
stalledTimer = null;
}
});suspend — 浏览器主动暂停下载
suspend 是浏览器主动决定暂停数据下载时触发,通常是因为浏览器认为已经缓冲了足够的数据(不需要继续下载了)。这是正常行为,不是错误。
很多开发者看到 suspend 就以为出错了,其实大多数情况下可以忽略它。区分方式:如果 suspend 触发时 video.readyState >= 3,那就是正常的缓冲暂停;如果 readyState < 2,才需要关注。
abort — 用户主动中止
abort 在媒体数据加载被用户主动中止时触发(不是因为错误,而是用户操作,比如切换了视频源)。
video.addEventListener('abort', () => {
console.log('加载被中止(用户操作或 src 变更)');
// 清理相关状态
});四个事件的区别速查:
| 事件 | 触发原因 | 严重程度 | 是否自动恢复 |
|---|---|---|---|
waiting | 缓冲数据不足,播放暂停 | 低,正常现象 | 是,数据到了自动恢复 |
stalled | 网络停滞,数据没有传输 | 中,需要关注 | 否,需要手动干预 |
suspend | 浏览器主动停止下载 | 低,正常行为 | — |
abort | 用户/代码主动中止加载 | — | — |
progress — 下载进度更新
progress 在浏览器正在下载视频数据时周期性触发,用于更新缓冲进度条。注意它的触发时机不规律,不要依赖它的触发频率做精确计算:
video.addEventListener('progress', () => {
if (!video.duration) return;
// 找到当前播放位置所在的缓冲段
let bufferedEnd = 0;
for (let i = 0; i < video.buffered.length; i++) {
if (video.buffered.start(i) <= video.currentTime) {
bufferedEnd = video.buffered.end(i);
}
}
const bufferedPercent = (bufferedEnd / video.duration) * 100;
bufferBar.style.width = `%`;
});第四部分:跳转链 — seeking 与 seeked
seeking → seeked 的完整流程
当用户拖动进度条或代码修改 currentTime 时,触发顺序是:
currentTime 被修改
↓
seeking 触发(seeking = true)
↓
浏览器开始寻找目标位置的数据
↓
seeked 触发(seeking = false)
↓
继续播放(如果之前在播放状态)
let seekingOverlay = null;
video.addEventListener('seeking', () => {
// 正在跳转,显示 loading 遮罩
seekingOverlay = showLoadingOverlay();
console.log('跳转到:', video.currentTime);
});
video.addEventListener('seeked', () => {
// 跳转完成,隐藏遮罩
hideLoadingOverlay(seekingOverlay);
console.log('跳转完成,当前位置:', video.currentTime);
});跨平台差异:Android 在修改 currentTime 后会触发 canplay 事件,iOS 不会。如果你的逻辑依赖跳转后的 canplay 来恢复播放,在 iOS 上会失效。正确做法是依赖 seeked 事件,而不是 canplay。
第五部分:特殊事件
emptied — 媒体元素被重置
当 video.load() 被调用,或者 src 被修改导致媒体元素重置时触发。触发时 readyState 变为 0(HAVE_NOTHING),networkState 变为 0(NETWORK_EMPTY)。
video.addEventListener('emptied', () => {
// 媒体元素已重置,清理所有 UI 状态
progressBar.value = 0;
timeLabel.textContent = '0:00 / 0:00';
hideLoadingSpinner();
console.log('媒体元素已重置');
});ratechange 与 volumechange
video.addEventListener('ratechange', () => {
rateDisplay.textContent = `x`;
});
video.addEventListener('volumechange', () => {
// volume 或 muted 发生变化时触发
volumeSlider.value = video.muted ? 0 : video.volume;
updateVolumeIcon(video.muted, video.volume);
});第六部分:跨平台事件差异对照
这是移动端开发最需要关注的部分。同样的操作,在不同平台上触发的事件序列可能完全不同。 lizh.gitbook.io cloud.tencent.com
| 场景 | Chrome(桌面) | iOS Safari | Android 微信 X5 |
|---|---|---|---|
修改 currentTime 后 | 触发 canplay | 不触发 canplay | 触发 canplay |
| 切换到后台 | 视频继续播放 | 视频暂停 | 视频暂停 |
| 切回前台 | 继续播放 | 需手动恢复 | 部分机型自动续播 |
preload="none" 下的 loadedmetadata | 正常触发 | 可能跳过 | 正常触发 |
第七部分:visibilitychange — 页面切换的正确处理
visibilitychange 不是 video 自身的事件,而是 document 的事件,但它是处理"切换标签页/切换 App"场景的关键:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// 页面不可见:记录当前状态
wasPlayingBeforeHide = !video.paused;
video.pause(); // 主动暂停,避免后台耗电
} else {
// 页面重新可见:恢复之前的状态
if (wasPlayingBeforeHide) {
// 注意:某些 Android 机型切回后 video 状态可能已重置
// 需要重新检查 readyState
if (video.readyState >= 3) {
video.play().catch(() => {
// autoplay 策略可能阻止恢复,展示播放按钮
showPlayButton();
});
} else {
// readyState 不够,等 canplay 再播
video.addEventListener('canplay', () => video.play(), { once: true });
}
}
}
});第八部分:完整的事件驱动播放器骨架
把所有事件整合成一个状态管理完整的播放器核心:
class EventDrivenPlayer {
constructor(video) {
this.video = video;
this.state = 'idle'; // idle | loading | ready | playing | paused | buffering | ended | error
this.wasPlaying = false;
this.bindEvents();
}
setState(newState) {
const prev = this.state;
this.state = newState;
console.log(`[状态] ${prev} → ${newState}`);
this.onStateChange(prev, newState);
}
bindEvents() {
const v = this.video;
// ── 加载链 ──
v.addEventListener('loadstart', () => this.setState('loading'));
v.addEventListener('loadedmetadata', () => this.onMetadataReady());
v.addEventListener('loadeddata', () => this.onFirstFrameReady());
v.addEventListener('canplay', () => this.setState('ready'));
v.addEventListener('emptied', () => this.setState('idle'));
// ── 播放链 ──
v.addEventListener('play', () => this.wasPlaying = true);
v.addEventListener('playing', () => this.setState('playing'));
v.addEventListener('pause', () => {
this.wasPlaying = false;
this.setState('paused');
});
v.addEventListener('ended', () => this.setState('ended'));
// ── 缓冲链 ──
v.addEventListener('waiting', () => this.setState('buffering'));
v.addEventListener('stalled', () => this.onStalled());
v.addEventListener('progress', () => this.updateBufferBar());
// ── 跳转链 ──
v.addEventListener('seeking', () => this.showSeekOverlay());
v.addEventListener('seeked', () => this.hideSeekOverlay());
// ── 进度更新(节流) ──
v.addEventListener('timeupdate', this.throttle(() => {
this.updateProgressBar();
}, 250));
// ── 错误 ──
v.addEventListener('error', () => {
this.setState('error');
this.handleError(v.error);
});
// ── 页面可见性 ──
document.addEventListener('visibilitychange', () => {
this.handleVisibilityChange();
});
}
onMetadataReady() {
console.log(`元数据就绪: x, s`);
}
onFirstFrameReady() {
console.log('第一帧就绪,可隐藏封面');
}
onStalled() {
console.warn('数据停滞,5s 后重试');
this._stalledTimer = setTimeout(() => {
const t = this.video.currentTime;
this.video.load();
this.video.currentTime = t;
this.video.play();
}, 5000);
}
handleError(error) {
const msgs = { 1: '已中止', 2: '网络错误', 3: '解码失败', 4: '格式不支持' };
console.error('[播放错误]', msgs[error?.code] || '未知错误');
}
handleVisibilityChange() {
if (document.visibilityState === 'hidden') {
this.wasPlaying = !this.video.paused;
this.video.pause();
} else if (this.wasPlaying) {
this.video.play().catch(() => {});
}
}
updateProgressBar() { /* 更新进度条 UI */ }
updateBufferBar() { /* 更新缓冲条 UI */ }
showSeekOverlay() { /* 显示跳转遮罩 */ }
hideSeekOverlay() { /* 隐藏跳转遮罩 */ }
onStateChange() { /* 响应状态变化,更新 UI */ }
throttle(fn, delay) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last < delay) return;
last = now;
fn(...args);
};
}
}
const player = new EventDrivenPlayer(document.querySelector('video'));事件速查表
| 事件 | 触发时机 | readyState | 核心用途 |
|---|---|---|---|
loadstart | 开始请求资源 | 0 | 展示初始 loading |
durationchange | 时长变化 | ≥1 | 初始化进度条总长 |
loadedmetadata | 元数据就绪 | 1 | 初始化播放器 UI |
loadeddata | 第一帧就绪 | 2 | 隐藏封面图 |
canplay | 可开始播放(可能卡顿) | 3 | 激活播放按钮 |
canplaythrough | 可流畅播放到结束 | 4 | 自动播放 / 隐藏缓冲提示 |
play | 播放意图触发 | — | 更新按钮图标 |
playing | 画面真实输出 | — | 隐藏 loading spinner |
timeupdate | 播放位置变化(高频) | — | 更新进度条(需节流) |
pause | 暂停 | — | 更新按钮 / 保存进度 |
ended | 播放结束 | — | 推荐下一个视频 |
waiting | 缓冲不足暂停 | — | 显示缓冲 loading |
stalled | 数据传输停滞 | — | 触发重试逻辑 |
suspend | 浏览器暂停下载 | — | 通常可忽略 |
abort | 加载被中止 | — | 清理状态 |
progress | 下载进度更新 | — | 更新缓冲进度条 |
seeking | 开始跳转 | — | 显示跳转遮罩 |
seeked | 跳转完成 | — | 隐藏跳转遮罩 |
emptied | 媒体元素重置 | 0 | 清理所有 UI 状态 |
error | 播放错误 | — | 展示错误提示 |
ratechange | 播放速率变化 | — | 更新倍速显示 |
volumechange | 音量或静音变化 | — | 更新音量 UI |
小结
视频事件系统的本质是一个状态机的外部通知接口。每个事件都对应视频内部状态的一次转变,而你的播放器 UI 就是这个状态机的"镜像"。把 waiting / playing 对应到缓冲 loading,把 seeking / seeked 对应到跳转遮罩,把 loadedmetadata 对应到 UI 初始化,把 visibilitychange 对应到后台暂停——这套映射关系建立起来之后,99% 的播放器 Bug 都能找到根源。
下一篇将深入视频格式与编解码,讲清楚 H.264、VP9、AV1 的选型逻辑,以及 type 属性中 codec 字符串的完整写法和 canPlayType() 的能力检测实践。