第二章:HTMLMediaElement 音频 API —— <audio> 背后真正的控制台

上一章我们掌握了 <audio> 标签的基础用法。但你有没有想过,当你调用 audio.play() 时,背后究竟发生了什么?为什么 <audio><video> 能共用那么多 API?答案就在 HTMLMediaElement 里。这一章,我们把这个接口彻底拆开来看。


一、什么是 HTMLMediaElement?

HTMLMediaElement 是浏览器定义的一个抽象基类接口,它是 HTMLAudioElementHTMLVideoElement 的共同父类。换句话说,<audio><video> 标签之所以能共用 play()pause()currentTime 这些 API,正是因为它们都继承自 HTMLMediaElement

EventTarget
  └── Node
        └── Element
              └── HTMLElement
                    └── HTMLMediaElement
                          ├── HTMLAudioElement   (<audio>)
                          └── HTMLVideoElement   (<video>)

这个继承链意味着:你在本章学到的所有内容,对 &lt;video&gt; 同样适用。这是 Web 标准设计的精妙之处——音视频共享同一套底层控制逻辑。


二、媒体加载状态机:readyState

这是整个 HTMLMediaElement 中最容易被忽视、但又最重要的概念之一。readyState 描述了媒体资源当前的加载与可用程度,是一个从 0 到 4 的整数值。

console.log(audio.readyState); // 0 ~ 4
常量名含义
HAVE_NOTHING0没有任何媒体数据
HAVE_METADATA1已获取元数据(时长、尺寸等)
HAVE_CURRENT_DATA2当前播放位置有数据,但下一帧没有
HAVE_FUTURE_DATA3当前和未来位置都有足够数据可以播放
HAVE_ENOUGH_DATA4数据足够,可以持续播放到结束

理解这五个状态,能帮你解决大量"为什么我的 API 调用没有效果"的问题。例如,duration 属性只有在 readyState >= HAVE_METADATA(即 ≥ 1)时才有意义;play() 只有在 readyState >= HAVE_FUTURE_DATA(即 ≥ 3)时才能真正开始输出音频。

function safeSeek(audio, time) {
  if (audio.readyState >= HTMLMediaElement.HAVE_METADATA) {
    audio.currentTime = time;
  } else {
    // 等待 metadata 加载完成后再跳转
    audio.addEventListener('loadedmetadata', () => {
      audio.currentTime = time;
    }, { once: true });
  }
}

三、网络状态:networkState

readyState 并列的另一个状态属性,描述的是网络层面的资源加载情况

console.log(audio.networkState);
常量名含义
NETWORK_EMPTY0元素未初始化,还未设置 src
NETWORK_IDLE1已选定资源,但当前未在加载
NETWORK_LOADING2正在下载资源
NETWORK_NO_SOURCE3找不到合适的资源(所有 source 都失败)

networkState === 3 是排查"音频播放不出来"问题的重要线索——如果是这个值,说明 src 路径有问题或所有格式都不被支持。


四、完整属性手册

4.1 资源相关属性

src — 直接设置音频地址,赋值后浏览器立即开始资源选择流程:

audio.src = 'new-music.mp3';
audio.load(); // 重新加载新资源

currentSrc — 只读,浏览器最终选定的实际播放地址。当你用了多个 &lt;source&gt; 标签时,currentSrc 告诉你浏览器选了哪个:

console.log(audio.currentSrc);
// "https://example.com/music.ogg"(如果 ogg 被选中)

srcObject — 用于直接绑定 MediaStreamMediaSource 对象,这是第六章(MSE)和第十章(WebRTC)的核心入口:

// 将麦克风流直接绑定到 audio 元素播放
navigator.mediaDevices.getUserMedia({ audio: true })
  .then(stream => {
    audio.srcObject = stream;
    audio.play();
  });

4.2 播放控制属性

currentTime — 当前播放位置(秒),可读写。写入时触发跳转(seek)操作:

audio.currentTime = 60; // 跳到第 1 分钟

duration — 只读,音频总时长(秒)。如果未知则返回 NaN,如果是直播流则返回 Infinity

// 判断是否为直播流
const isLive = audio.duration === Infinity;

playbackRate — 播放速率,默认 1.00.5 是半速,2.0 是两倍速。大多数浏览器支持 0.25 ~ 16 的范围,且音调会随速率变化(不像某些专业软件会保持音调):

audio.playbackRate = 1.5; // 1.5 倍速播放

defaultPlaybackRate — 设置默认播放速率,在 load() 重置后生效,通常用于"记住用户的速率偏好":

audio.defaultPlaybackRate = 1.5;
audio.load(); // 重新加载后速率仍为 1.5

paused — 只读布尔值,是否处于暂停状态。注意:音频结束后 paused 也为 true

if (audio.paused) {
  audio.play();
}

ended — 只读布尔值,是否已播放到结尾:

audio.addEventListener('ended', () => {
  console.log(audio.ended); // true
});

seeking — 只读布尔值,是否正在执行跳转操作(用户拖动进度条时为 true):

audio.addEventListener('seeking', () => {
  showLoadingSpinner(); // 跳转中显示 loading
});
audio.addEventListener('seeked', () => {
  hideLoadingSpinner(); // 跳转完成隐藏 loading
});

4.3 音量相关属性

volume — 音量,范围 0.0 ~ 1.0。设置超出范围会抛出 IndexSizeError

audio.volume = 0.8;

muted — 是否静音,布尔值。静音时 volume 值不变,只是不输出声音:

audio.muted = !audio.muted; // 切换静音

defaultMuted — 对应 HTML 中的 muted 属性,表示初始静音状态。与 muted 的区别在于它会被 load() 重置:

audio.defaultMuted = true;
audio.load(); // 重置后仍为静音

4.4 缓冲相关属性

buffered — 只读,返回一个 TimeRanges 对象,表示已缓冲的时间段。这在实现进度条缓冲显示时非常有用:

const buffered = audio.buffered;
for (let i = 0; i < buffered.length; i++) {
  console.log(`缓冲段 ${i}: ${buffered.start(i)}s ~ ${buffered.end(i)}s`);
}

seekable — 只读,TimeRanges 对象,表示可以 seek 的时间范围。直播流的 seekable 通常是空的或只有很小的范围:

if (audio.seekable.length > 0) {
  console.log('可 seek 范围:', audio.seekable.start(0), '~', audio.seekable.end(0));
}

played — 只读,TimeRanges 对象,记录所有已播放过的时间段(跳过的部分不计入)。


4.5 其他重要属性

autoplay — 对应 HTML 属性,JS 中可动态设置:

audio.autoplay = true;

loop — 是否循环播放:

audio.loop = true;

controls — 是否显示原生控制条:

audio.controls = false; // 隐藏原生控件,使用自定义 UI

preload — 预加载策略,值为 "none" / "metadata" / "auto"

audio.preload = 'metadata';

crossOrigin — 跨域设置,注意 JS 中是驼峰命名 crossOrigin,HTML 中是 crossorigin

audio.crossOrigin = 'anonymous';

error — 只读,返回 MediaError 对象或 null。发生错误时通过它获取错误信息:

audio.addEventListener('error', () => {
  const err = audio.error;
  console.error(`错误码: ${err.code}, 信息: ${err.message}`);
});

MediaError.code 的值:

常量含义
1MEDIA_ERR_ABORTED用户中止了加载
2MEDIA_ERR_NETWORK网络错误
3MEDIA_ERR_DECODE解码失败
4MEDIA_ERR_SRC_NOT_SUPPORTED格式不支持或 src 无效

五、核心方法详解

play() — 开始播放

返回 Promise&lt;void&gt;,这是现代浏览器中最常见的异步陷阱:

async function playAudio(audio) {
  try {
    await audio.play();
    console.log('播放中');
  } catch (err) {
    if (err.name === 'NotAllowedError') {
      // 自动播放策略拦截,需要用户交互
      showPlayButton();
    } else if (err.name === 'NotSupportedError') {
      // 格式不支持
      showFormatError();
    } else {
      console.error('未知播放错误:', err);
    }
  }
}

pause() — 暂停播放

同步方法,无返回值。但如果在 play() 的 Promise resolve 之前调用,会引发 AbortError

// 安全的停止方法
async function stopAudio(audio) {
  try {
    await audio.play(); // 确保 play 已 resolve
  } catch (e) {
    // 忽略
  } finally {
    audio.pause();
    audio.currentTime = 0;
  }
}

load() — 重置并重新加载

将元素重置到初始状态,重新开始资源选择和加载流程。切换歌曲时必须调用:

function changeSong(audio, newSrc) {
  audio.src = newSrc;
  audio.load();       // 重置状态,重新加载
  audio.play();
}

canPlayType(type) — 格式支持检测

返回 ""(不支持)"maybe""probably" 三个字符串之一。注意没有 "yes",这是历史遗留设计:

function getBestFormat(audio) {
  const formats = [
    { type: 'audio/ogg; codecs="opus"', src: 'music.opus' },
    { type: 'audio/ogg; codecs="vorbis"', src: 'music.ogg' },
    { type: 'audio/mpeg', src: 'music.mp3' },
    { type: 'audio/aac', src: 'music.aac' },
  ];
 
  for (const fmt of formats) {
    const support = audio.canPlayType(fmt.type);
    if (support === 'probably' || support === 'maybe') {
      return fmt.src;
    }
  }
  return null; // 没有支持的格式
}
 
const audio = new Audio();
const bestSrc = getBestFormat(audio);
if (bestSrc) audio.src = bestSrc;

fastSeek(time) — 快速跳转(非精确)

相比 currentTime = time 的精确跳转,fastSeek() 会跳到最近的关键帧,速度更快,适合用户拖动进度条时的实时预览:

// 拖动进度条时用 fastSeek,松手后用 currentTime 精确定位
progressBar.addEventListener('mousemove', (e) => {
  if (isDragging) {
    const ratio = e.offsetX / progressBar.offsetWidth;
    audio.fastSeek(ratio * audio.duration);
  }
});

注意:fastSeek() 目前主要在 Firefox 和 Safari 中支持,Chrome 中等同于 currentTime 赋值。


六、TimeRanges 对象深入解析

bufferedseekableplayed 都返回 TimeRanges 对象,理解它对实现专业播放器至关重要。

TimeRanges 代表一组不连续的时间区间(因为用户可能跳着播放,导致多段缓冲)。它有以下接口:

timeRanges.length          // 区间数量
timeRanges.start(index)    // 第 index 段的开始时间(秒)
timeRanges.end(index)      // 第 index 段的结束时间(秒)

实战:实现带缓冲显示的进度条

function renderBufferBar(audio, canvas) {
  const ctx = canvas.getContext('2d');
  const width = canvas.width;
  const duration = audio.duration;
 
  ctx.clearRect(0, 0, width, canvas.height);
 
  // 绘制已缓冲区域(灰色)
  ctx.fillStyle = '#555';
  for (let i = 0; i < audio.buffered.length; i++) {
    const startX = (audio.buffered.start(i) / duration) * width;
    const endX = (audio.buffered.end(i) / duration) * width;
    ctx.fillRect(startX, 0, endX - startX, canvas.height);
  }
 
  // 绘制已播放区域(主色)
  ctx.fillStyle = '#667eea';
  const playedX = (audio.currentTime / duration) * width;
  ctx.fillRect(0, 0, playedX, canvas.height);
}
 
audio.addEventListener('progress', () => renderBufferBar(audio, progressCanvas));
audio.addEventListener('timeupdate', () => renderBufferBar(audio, progressCanvas));

七、媒体资源选择算法(了解底层)

当浏览器遇到一个 &lt;audio&gt; 元素时,它会执行一套标准定义的资源选择算法,理解这个过程能帮你排查很多奇怪的问题:

第一步:确定资源来源

浏览器按以下优先级决定用哪个资源:

  1. 如果 srcObject 不为 null,直接使用(MSE / MediaStream 场景)
  2. 如果 src 属性有值,使用 src
  3. 如果有 &lt;source&gt; 子元素,遍历它们

第二步:遍历 &lt;source&gt; 时的判断逻辑

对每个 &lt;source&gt; 元素,浏览器会检查:

  • type 属性是否声明了浏览器支持的 MIME 类型(如果没写 type,则跳过格式检查直接尝试)
  • media 属性(媒体查询)是否匹配当前环境
<!-- media 属性示例:移动端用低码率版本 -->
<audio controls>
  <source src="music-hq.ogg" type="audio/ogg" media="(min-width: 768px)" />
  <source src="music-lq.ogg" type="audio/ogg" />
</audio>

第三步:触发加载

选定资源后,浏览器根据 preload 属性决定立即加载还是延迟加载,随后依次触发 loadstartdurationchangeloadedmetadataloadeddatacanplaycanplaythrough 等事件(事件系统在第三章详细展开)。


八、实战:功能完整的播放器类封装

HTMLMediaElement 的核心 API 封装成一个可复用的类,是工程化实践的关键一步:

class AudioPlayer {
  constructor(src, options = {}) {
    this.audio = new Audio();
    this.audio.preload = options.preload || 'metadata';
    this.audio.crossOrigin = options.crossOrigin || null;
    this._playPromise = null;
    this._listeners = {};
 
    if (src) this.load(src);
    this._bindEvents();
  }
 
  // 加载新音频
  load(src) {
    this.audio.src = src;
    this.audio.load();
    return this;
  }
 
  // 安全播放
  async play() {
    try {
      this._playPromise = this.audio.play();
      await this._playPromise;
      this._emit('play');
    } catch (err) {
      this._emit('error', err);
      throw err;
    }
    return this;
  }
 
  // 安全暂停
  async pause() {
    if (this._playPromise) {
      await this._playPromise.catch(() => {});
    }
    this.audio.pause();
    this._emit('pause');
    return this;
  }
 
  // 跳转
  seek(time) {
    if (this.audio.readyState >= HTMLMediaElement.HAVE_METADATA) {
      this.audio.currentTime = Math.max(0, Math.min(time, this.audio.duration));
    }
    return this;
  }
 
  // 设置音量(带边界保护)
  setVolume(vol) {
    this.audio.volume = Math.max(0, Math.min(1, vol));
    return this;
  }
 
  // 设置播放速率
  setRate(rate) {
    this.audio.playbackRate = rate;
    return this;
  }
 
  // 获取播放进度(0 ~ 1)
  get progress() {
    if (!this.audio.duration) return 0;
    return this.audio.currentTime / this.audio.duration;
  }
 
  // 获取缓冲进度(0 ~ 1)
  get bufferedProgress() {
    const b = this.audio.buffered;
    if (!b.length || !this.audio.duration) return 0;
    return b.end(b.length - 1) / this.audio.duration;
  }
 
  // 格式支持检测(静态方法)
  static canPlay(mimeType) {
    const a = new Audio();
    return a.canPlayType(mimeType) !== '';
  }
 
  // 简易事件系统
  on(event, fn) {
    if (!this._listeners[event]) this._listeners[event] = [];
    this._listeners[event].push(fn);
    return this;
  }
 
  _emit(event, data) {
    (this._listeners[event] || []).forEach(fn => fn(data));
  }
 
  _bindEvents() {
    ['timeupdate', 'ended', 'error', 'waiting', 'playing'].forEach(evt => {
      this.audio.addEventListener(evt, (e) => this._emit(evt, e));
    });
  }
 
  // 销毁,释放资源
  destroy() {
    this.audio.pause();
    this.audio.src = '';
    this.audio.load();
    this._listeners = {};
  }
}

使用方式:

const player = new AudioPlayer('music.mp3', { preload: 'metadata' });
 
player
  .on('timeupdate', () => {
    progressBar.style.width = `%`;
  })
  .on('ended', () => {
    console.log('播放结束');
  });
 
// 用户点击播放按钮
playBtn.addEventListener('click', () => player.play());
 
// 格式检测
console.log(AudioPlayer.canPlay('audio/ogg; codecs="opus"')); // true/false

九、本章知识图谱

流程图画布 · 115%
Mermaid 流程图加载中...

小结

HTMLMediaElement 是整个 Web 媒体系统的核心接口。从状态机(readyState / networkState)到资源管理(src / srcObject / load()),从播放控制(play() / pause() / currentTime)到缓冲分析(TimeRanges),每一块都有其独特的设计逻辑和使用场景。

真正掌握这个接口,你会发现很多"玄学 bug"都变得有迹可循——因为你终于看到了 &lt;audio&gt; 背后真正运作的那台机器。

下一章我们将深入音频事件系统——HTMLMediaElement 能抛出十几种事件,它们的触发顺序、触发条件、以及如何在正确的时机做正确的事,是构建健壮播放器的关键所在。