视频事件系统:彻底掌握从 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

这是加载链中最重要的节点之一。触发时,以下信息全部可用:durationvideoWidthvideoHeighttextTracksaudioTracks。这是初始化播放器 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 到达的阈值不同,也是最常被混用的一对:

canplayreadyState = 3HAVE_FUTURE_DATA)时触发,意味着可以开始播放,但不保证流畅,中途可能因缓冲不足而暂停。

canplaythroughreadyState = 4HAVE_ENOUGH_DATA)时触发,意味着浏览器估计可以流畅播放到结束,不需要停下来缓冲。

video.addEventListener('canplay', () => {
  // 可以开始播放,但可能卡顿
  // 适合:展示"开始播放"按钮,让用户决定是否等待更多缓冲
  playBtn.disabled = false;
});
 
video.addEventListener('canplaythrough', () => {
  // 数据充足,流畅播放有保障
  // 适合:自动开始播放、隐藏缓冲提示
  hideBufferingHint();
});

实践建议:在大多数场景下,监听 canplay 就够了——等 canplaythrough 可能让用户等待过久,尤其在移动网络下。对于 AI 视频的流式播放场景,canplay 是触发播放的正确时机。

重要canplaycanplaythrough 在视频的整个生命周期中可能触发多次——每次从 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 — 进度更新,高频事件的节流处理

timeupdatecurrentTime 发生变化时触发,频率大约是每秒 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 属性可以构建精确的观看行为分析。


pauseended — 停止的两种方式

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 });
});

第三部分:缓冲链 — 四个容易混淆的网络事件

这四个事件是最难区分的:waitingstalledsuspendabort。很多开发者分不清它们,导致 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 &lt; 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

seekingseeked 的完整流程

当用户拖动进度条或代码修改 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('媒体元素已重置');
});

ratechangevolumechange

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 SafariAndroid 微信 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() 的能力检测实践。