HTMLMediaElement API:彻底掌握视频的编程接口

上一篇讲透了 <video> 的 HTML 属性,那是"静态配置层"。这一篇进入"动态控制层"——HTMLMediaElement API。这是所有自定义播放器、进度条、弹幕系统、AI 视频流式播放的底层根基。不理解它,你写的播放器永远在靠猜。


先搞清楚继承关系

很多人以为 <video> 的 JS 接口就是 HTMLVideoElement,但实际的继承链是这样的:

EventTarget
  └── Node
        └── Element
              └── HTMLElement
                    └── HTMLMediaElement   ← 音视频共有的核心 API
                          └── HTMLVideoElement  ← video 专属扩展
                          └── HTMLAudioElement  ← audio 专属扩展

HTMLMediaElement 是真正的核心,绝大多数你需要的属性和方法都在这里——play()pause()currentTimebufferedreadyState 全部来自它。HTMLVideoElement 只是在此基础上加了几个视频专属的能力(videoWidthvideoHeightrequestVideoFrameCallbackrequestPictureInPicture)。


第一部分:状态属性 — 视频的"仪表盘"

操控视频之前,你必须先知道它当前处于什么状态。HTMLMediaElement 提供了两套独立的状态系统,分别描述数据就绪程度网络加载状态

readyState — 最重要的状态属性

readyState 是一个整数,表示视频数据的准备程度,共 5 个状态,构成一条单向递进的状态链:

0 (HAVE_NOTHING) → 1 (HAVE_METADATA) → 2 (HAVE_CURRENT_DATA)
→ 3 (HAVE_FUTURE_DATA) → 4 (HAVE_ENOUGH_DATA)
常量名含义对应触发的事件
0HAVE_NOTHING什么都没有,连元数据都没加载
1HAVE_METADATA元数据已就绪(时长、分辨率、轨道信息)loadedmetadata
2HAVE_CURRENT_DATA当前帧数据可用,但下一帧没有loadeddata
3HAVE_FUTURE_DATA当前帧和未来若干帧都有数据,可以播放但不保证流畅canplay
4HAVE_ENOUGH_DATA数据充足,且下载速度足以支撑播放到结束canplaythrough

这个属性为什么重要? 因为调用 play() 的时机必须在 readyState >= 3 时才有意义。很多"点击播放没反应"的 Bug,根源就是在 readyState 还是 0 或 1 的时候就调用了 play()

const video = document.querySelector('video');
 
// 错误写法:不判断状态直接调用
video.play(); // 可能在 readyState=0 时调用,毫无意义
 
// 正确写法:等待足够的数据
video.addEventListener('canplay', () => {
  console.log('readyState:', video.readyState); // 至少是 3
  video.play();
});
 
// 或者主动判断
if (video.readyState >= 3) {
  video.play();
} else {
  video.addEventListener('canplay', () => video.play(), { once: true });
}

networkState — 网络加载的四种状态

networkState 描述的是浏览器当前与网络的交互状态:

常量名含义
0NETWORK_EMPTY元素刚创建,还没有开始加载
1NETWORK_IDLE资源已选定,但当前没有在下载(等待中)
2NETWORK_LOADING正在下载数据
3NETWORK_NO_SOURCE没有找到可用的媒体源

NETWORK_NO_SOURCE(值为 3)是最需要关注的状态,它意味着所有 <source> 都不被支持,或者 src 指向的资源无法访问。遇到这个状态,应该触发格式降级或错误提示逻辑。


pausedendedseeking — 三个布尔快照

这三个只读布尔属性是播放器 UI 状态同步的基础:

// 实现播放/暂停按钮的状态切换
function updatePlayButton() {
  const btn = document.querySelector('.play-btn');
  if (video.paused || video.ended) {
    btn.textContent = '▶ 播放';
    btn.dataset.state = 'paused';
  } else {
    btn.textContent = '⏸ 暂停';
    btn.dataset.state = 'playing';
  }
}
 
video.addEventListener('play', updatePlayButton);
video.addEventListener('pause', updatePlayButton);
video.addEventListener('ended', updatePlayButton);

seeking 在用户拖动进度条时变为 true,跳转完成后变回 false,配合 seeking / seeked 事件可以实现跳转时的 Loading 遮罩。


errorMediaError — 错误诊断

当视频加载或播放出错时,video.error 属性会返回一个 MediaError 对象,其 code 属性是一个数字,对应四种错误类型:

code常量名含义常见原因
1MEDIA_ERR_ABORTED用户主动中止用户切换了视频源
2MEDIA_ERR_NETWORK网络错误CDN 挂了、断网
3MEDIA_ERR_DECODE解码失败文件损坏、编码不支持
4MEDIA_ERR_SRC_NOT_SUPPORTED源不支持格式错误、MIME 类型不对、文件不存在

生产环境中必须做完整的错误处理,而不是让用户看着黑屏:

video.addEventListener('error', () => {
  const error = video.error;
  if (!error) return;
 
  const messages = {
    1: '播放被中止',
    2: '网络错误,请检查连接',
    3: '视频解码失败,文件可能已损坏',
    4: '不支持的视频格式或视频源无效',
  };
 
  const msg = messages[error.code] || '未知错误';
  console.error(`[MediaError ${error.code}] ${msg}`, error.message);
 
  // 展示错误 UI
  showErrorOverlay(msg);
 
  // code=2 时尝试重试
  if (error.code === 2) {
    setTimeout(() => {
      video.load(); // 重新加载
      video.play();
    }, 3000);
  }
});

第二部分:数据属性 — 时间与缓冲

durationcurrentTime

duration 是视频的总时长(秒),只有在 readyState >= 1HAVE_METADATA)之后才能读到有效值,否则是 NaN。对于直播流,duration 的值是 Infinity

currentTime 是当前播放位置(秒),可读可写。给它赋值就是跳转:

// 读取格式化时间
function formatTime(seconds) {
  if (isNaN(seconds)) return '0:00';
  const m = Math.floor(seconds / 60);
  const s = Math.floor(seconds % 60).toString().padStart(2, '0');
  return `${m}:${s}`;
}
 
// 进度条点击跳转
progressBar.addEventListener('click', (e) => {
  const rect = progressBar.getBoundingClientRect();
  const ratio = (e.clientX - rect.left) / rect.width;
  video.currentTime = ratio * video.duration; // 直接赋值跳转
});
 
// 实时更新进度条
video.addEventListener('timeupdate', () => {
  const progress = (video.currentTime / video.duration) * 100;
  progressFill.style.width = `${progress}%`;
  timeDisplay.textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`;
});

timeupdate 的触发频率是每秒 4 ~ 66 次,不同浏览器不同。如果在回调里做大量 DOM 操作,注意节流。


buffered — 深入理解 TimeRanges

buffered 是最容易被忽视但极其重要的属性。它返回一个 TimeRanges 对象,描述已经下载到本地的视频片段。

为什么是"多段"而不是一个值?因为用户可能在视频中间跳转,导致缓冲区出现不连续的多个片段:

已缓冲:[0 ---- 30s]  [55s ---- 80s]
                         ↑ 用户跳转到这里,产生了新的缓冲段

TimeRanges 对象的用法:

// 获取当前播放位置之后的已缓冲量
function getBufferedEnd(video) {
  const buffered = video.buffered;
  const currentTime = video.currentTime;
 
  for (let i = 0; i < buffered.length; i++) {
    if (buffered.start(i) <= currentTime && currentTime <= buffered.end(i)) {
      return buffered.end(i); // 当前所在缓冲段的末端
    }
  }
  return currentTime; // 没找到,返回当前时间
}
 
// 在进度条上绘制缓冲进度
video.addEventListener('progress', () => {
  const bufferedEnd = getBufferedEnd(video);
  const bufferedPercent = (bufferedEnd / video.duration) * 100;
  bufferBar.style.width = `${bufferedPercent}%`;
});

buffered 的变化会触发 progress 事件(不是 timeupdate),监听 progress 事件来更新缓冲条才是正确做法。


playedseekable

played 也是 TimeRanges 对象,记录用户实际播放过的时间段,可用于统计用户观看行为(哪些片段看了、哪些跳过了)。

seekable 表示用户可以跳转到的时间范围。对于渐进式下载的视频,seekable 通常等于整个时长;对于不支持 Range 请求的服务器,seekable 可能只等于 buffered,用户无法跳转到未缓冲的位置。


videoWidth / videoHeight — 视频的真实分辨率

这两个属性属于 HTMLVideoElement,返回视频的编码分辨率,与 CSS 样式尺寸无关。在做 Canvas 截帧时,必须用这两个值作为 Canvas 的尺寸,否则截出来的图会变形:

video.addEventListener('loadedmetadata', () => {
  const canvas = document.createElement('canvas');
  // 必须用 videoWidth/videoHeight,不是 video.offsetWidth
  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;
  canvas.getContext('2d').drawImage(video, 0, 0);
  const dataURL = canvas.toDataURL('image/jpeg');
});

第三部分:控制属性 — 音量、速率、画中画

volumemuted

volume 是 0.0 ~ 1.0 之间的浮点数,muted 是布尔值。两者是独立的:muted = true 不会改变 volume 的值,取消静音后音量恢复原值。

// 实现音量滑块
volumeSlider.addEventListener('input', (e) => {
  video.volume = parseFloat(e.target.value); // 0.0 ~ 1.0
  video.muted = video.volume === 0;
  updateVolumeIcon();
});
 
// 实现静音切换
muteBtn.addEventListener('click', () => {
  video.muted = !video.muted;
  // 注意:muted 后 volume 不变,取消静音会恢复之前的音量
  updateVolumeIcon();
});

playbackRate — 倍速播放的完整实现

playbackRate 控制播放速度,1.0 是正常速度。理论上支持负值(倒放),但实际上大多数浏览器不支持负值倒放,设置负值不会生效或会报错。

// 倍速选择器
const rates = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
 
rateSelector.addEventListener('change', (e) => {
  video.playbackRate = parseFloat(e.target.value);
});
 
// 读取当前速率
video.addEventListener('ratechange', () => {
  console.log('当前速率:', video.playbackRate);
});

音频静音范围:Chrome 和 Firefox 在 playbackRate 超出 0.25 ~ 5.0 这个范围时会自动静音(但视频继续播放),这是浏览器的内置行为,无法通过 JS 覆盖。


requestPictureInPicture() — 画中画

画中画是 HTMLVideoElement 的专属方法,让视频脱离页面浮动播放:

pipBtn.addEventListener('click', async () => {
  try {
    if (document.pictureInPictureElement) {
      // 已经在画中画,退出
      await document.exitPictureInPicture();
    } else {
      // 进入画中画
      await video.requestPictureInPicture();
    }
  } catch (err) {
    console.error('画中画失败:', err);
  }
});
 
// 监听进入/退出事件
video.addEventListener('enterpictureinpicture', () => {
  pipBtn.textContent = '退出画中画';
});
 
video.addEventListener('leavepictureinpicture', () => {
  pipBtn.textContent = '画中画';
});

如果不想让视频支持画中画(比如有版权保护需求),在 HTML 上加 disablepictureinpicture 属性即可。


第四部分:核心方法

play() — 必须处理 Promise

play() 返回一个 Promise,这是现代浏览器的规范。不处理这个 Promise 是最常见的低级错误之一

async function safePlay(video) {
  try {
    await video.play();
  } catch (err) {
    if (err.name === 'NotAllowedError') {
      // autoplay 被浏览器策略阻止,需要用户手动触发
      showManualPlayButton();
    } else if (err.name === 'AbortError') {
      // play() 被另一个 load() 或 src 更改打断,通常可以忽略
      console.warn('播放被中断,可能是 src 发生了变化');
    } else {
      console.error('播放失败:', err);
    }
  }
}

常见的 Promise rejection 原因:

错误名原因
NotAllowedError浏览器 autoplay 策略阻止,需要用户交互
AbortErrorplay() 调用后 src 被修改或 load() 被调用
NotSupportedError媒体格式不支持

load() — 重置并重新加载

load() 会重置整个媒体元素,重新走一遍资源选择和加载流程。常用于动态切换视频源:

function switchVideo(newSrc) {
  video.src = newSrc;
  video.load(); // 必须调用 load() 才能触发新的加载
  video.play();
}
 
// 或者用 source 子标签切换
function switchVideoWithSource(newSrc) {
  const source = video.querySelector('source');
  source.src = newSrc;
  video.load(); // 同样需要 load()
}

常见坑:只改了 video.srcsource.src,但没有调用 load(),视频不会更新。


canPlayType() — 格式能力检测

在加载视频之前,可以先用 canPlayType() 探测浏览器是否支持某种格式:

function getBestVideoSrc(mp4Src, webmSrc) {
  const video = document.createElement('video');
 
  // 返回值: 'probably' | 'maybe' | ''
  if (video.canPlayType('video/webm; codecs="vp9, opus"') === 'probably') {
    return webmSrc; // 优先 WebM
  } else if (video.canPlayType('video/mp4; codecs="avc1.42E01E"') !== '') {
    return mp4Src; // 兜底 MP4
  }
  return null; // 都不支持
}

返回值的含义:'probably' 表示大概率支持,'maybe' 表示不确定(需要实际尝试),''(空字符串)表示明确不支持。


第五部分:requestVideoFrameCallback() — AI 视频的利器

这是 HTMLVideoElement 中最新也最强大的 API,Chrome 83+、Safari 15.4+ 支持。它允许你注册一个回调,在每一个新视频帧被送到合成器时精确触发。

它与 requestAnimationFrame 的区别rAF 按屏幕刷新率触发(通常 60Hz),而 requestVideoFrameCallback 按视频的实际帧率触发。对于 24fps 的视频,它每秒触发 24 次,不多不少,效率更高,时机更精准。

// 精确的逐帧 Canvas 绘制(比 rAF 更高效)
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
 
const processFrame = (now, metadata) => {
  // metadata 包含丰富的帧信息
  console.log({
    presentationTime: metadata.presentationTime, // 帧呈现时间戳
    mediaTime: metadata.mediaTime,               // 精确的媒体时间(比 currentTime 更准)
    presentedFrames: metadata.presentedFrames,   // 已呈现的总帧数
    width: metadata.width,                       // 帧宽度
    height: metadata.height,                     // 帧高度
  });
 
  // 将当前帧绘制到 Canvas
  ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
 
  // 重新注册,处理下一帧
  video.requestVideoFrameCallback(processFrame);
};
 
// 开始处理
video.requestVideoFrameCallback(processFrame);

metadata.mediaTime vs video.currentTimecurrentTime 由音频时钟驱动,存在轻微抖动;mediaTime 直接来自帧的 presentationTimestamp,更精确。如果要做帧级别的精确分析(比如 AI 视频的帧内容识别),应该用 mediaTime

功能检测与 Polyfill

if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) {
  video.requestVideoFrameCallback(processFrame);
} else {
  // 降级到 requestAnimationFrame
  const fallback = () => {
    ctx.drawImage(video, 0, 0);
    requestAnimationFrame(fallback);
  };
  requestAnimationFrame(fallback);
}

web.dev


第六部分:currentSrc — 你以为你知道在播什么

currentSrc 是只读属性,返回浏览器实际选择播放的资源 URL(绝对路径)。当你用了多个 &lt;source&gt; 标签时,video.src 返回的是你设置的值,而 currentSrc 才是浏览器真正在用的那个:

// 有多个 source 时
// video.src 可能是空字符串(因为 src 属性没有设置在 video 上)
// video.currentSrc 才是浏览器选中的那个 source 的 URL
 
video.addEventListener('loadedmetadata', () => {
  console.log('实际播放源:', video.currentSrc);
  // 输出类似: https://cdn.example.com/video.webm
});

这在调试格式兼容性问题时非常有用——用它来确认浏览器到底选了哪个格式。


完整的播放器控制器示例

把以上所有知识点整合成一个生产可用的播放器控制器骨架:

class VideoController {
  constructor(videoEl) {
    this.video = videoEl;
    this.init();
  }
 
  init() {
    const v = this.video;
 
    // 状态监控
    v.addEventListener('loadedmetadata', () => {
      console.log(`视频就绪: ${v.videoWidth}x${v.videoHeight}, 时长: ${v.duration}s`);
    });
 
    // 缓冲进度
    v.addEventListener('progress', () => this.updateBufferBar());
 
    // 播放进度
    v.addEventListener('timeupdate', () => this.updateProgressBar());
 
    // 等待缓冲
    v.addEventListener('waiting', () => this.showLoadingSpinner());
    v.addEventListener('playing', () => this.hideLoadingSpinner());
 
    // 错误处理
    v.addEventListener('error', () => this.handleError());
 
    // 播放结束
    v.addEventListener('ended', () => this.onEnded());
  }
 
  async play() {
    try {
      await this.video.play();
    } catch (err) {
      if (err.name === 'NotAllowedError') {
        this.showPlayButton(); // 需要用户手动点击
      }
    }
  }
 
  seek(time) {
    // 确保在可跳转范围内
    const clampedTime = Math.max(0, Math.min(time, this.video.duration));
    this.video.currentTime = clampedTime;
  }
 
  setVolume(val) {
    this.video.volume = Math.max(0, Math.min(1, val));
  }
 
  setRate(rate) {
    this.video.playbackRate = rate;
  }
 
  getBufferedPercent() {
    const v = this.video;
    if (!v.duration) return 0;
    const bufferedEnd = v.buffered.length
      ? v.buffered.end(v.buffered.length - 1)
      : 0;
    return (bufferedEnd / v.duration) * 100;
  }
 
  handleError() {
    const code = this.video.error?.code;
    const msgs = { 1: '已中止', 2: '网络错误', 3: '解码失败', 4: '格式不支持' };
    console.error('[播放错误]', msgs[code] || '未知');
  }
 
  // ... 其他方法
}
 
const controller = new VideoController(document.querySelector('video'));

API 速查表

属性/方法类型读/写核心用途
readyState0~4只读判断数据就绪程度
networkState0~3只读判断网络加载状态
pausedBoolean只读是否暂停
endedBoolean只读是否播放结束
seekingBoolean只读是否正在跳转
errorMediaError只读错误对象
durationNumber只读总时长(秒)
currentTimeNumber读写当前位置(赋值即跳转)
bufferedTimeRanges只读已缓冲的时间段
playedTimeRanges只读已播放的时间段
seekableTimeRanges只读可跳转的时间段
currentSrcString只读实际播放的 URL
videoWidthNumber只读视频编码宽度
videoHeightNumber只读视频编码高度
volume0.0~1.0读写音量
mutedBoolean读写是否静音
playbackRateNumber读写播放速率
play()Promise方法开始播放
pause()void方法暂停
load()void方法重置并重新加载
canPlayType()String方法检测格式支持
requestVideoFrameCallback()Number方法逐帧精确回调
requestPictureInPicture()Promise方法进入画中画

小结

HTMLMediaElement API 的设计哲学是"状态驱动"——你不是在命令视频做什么,而是在响应视频告诉你它处于什么状态。理解 readyState 的五个阶段、bufferedTimeRanges 结构、play() 的 Promise 语义,以及 requestVideoFrameCallback 的帧级精度,是构建一个健壮播放器的真正基础。

下一篇将深入事件系统,把视频从 loadstartended 的完整生命周期事件链路逐一拆解,并重点讲清楚那些最容易踩坑的事件顺序问题。