第三章:音频事件系统 —— 掌握媒体播放的每一个生命周期节点

前两章我们打好了基础:<audio> 标签的用法、HTMLMediaElement 的属性与方法。但一个真正健壮的播放器,靠的不是"主动轮询"状态,而是被动监听事件。本章把 HTMLMediaElement 的完整事件系统从头到尾拆解清楚,让你在正确的时机做正确的事。


一、为什么事件系统如此重要?

音频加载和播放是一个异步过程。从设置 src 到真正能听到声音,中间要经历网络请求、格式解析、解码缓冲等多个阶段,每个阶段都可能耗时数百毫秒甚至更长。

如果你用轮询来检测状态:

// 错误示范:轮询检测,浪费性能且不可靠
setInterval(() => {
  if (audio.readyState === 4) {
    doSomething();
  }
}, 100);

不仅浪费 CPU,还可能错过事件窗口。正确的方式是事件驱动

// 正确示范:事件驱动,精准响应
audio.addEventListener('canplaythrough', () => {
  doSomething();
}, { once: true });

HTMLMediaElement 一共定义了 20+ 个事件,覆盖了从资源加载到播放结束的完整生命周期。理解它们的触发顺序和触发条件,是构建专业播放器的核心能力。


二、事件触发全流程图

一次完整的"加载 → 播放 → 结束"流程,事件触发顺序如下:

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

这张图覆盖了正常播放路径。下面我们逐类详细拆解每一个事件。


三、加载阶段事件

这一组事件描述了资源从"开始请求"到"数据就绪"的完整过程。

loadstart

资源加载流程启动时触发。此时浏览器刚开始发出网络请求,还没有任何数据返回。networkState 变为 NETWORK_LOADING

audio.addEventListener('loadstart', () => {
  console.log('开始加载资源...');
  showLoadingUI();
});

durationchange

duration 属性的值发生变化时触发。通常在 metadata 加载完成后首次触发(从 NaN 变为实际时长),也可能在直播场景中多次触发:

audio.addEventListener('durationchange', () => {
  if (isFinite(audio.duration)) {
    durationDisplay.textContent = formatTime(audio.duration);
  }
});

loadedmetadata

元数据加载完成时触发。此时 duration、音频声道数等信息已可用,readyState 变为 HAVE_METADATA(1)。这是初始化进度条的最佳时机

audio.addEventListener('loadedmetadata', () => {
  console.log(`总时长: s`);
  console.log(`可 seek 范围: ~`);
  initProgressBar(audio.duration);
});

loadeddata

当前播放位置的数据加载完成时触发,readyState 变为 HAVE_CURRENT_DATA(2)。此时理论上可以渲染第一帧(对视频而言),但不代表可以流畅播放

audio.addEventListener('loadeddata', () => {
  console.log('当前帧数据已就绪');
  hideInitialPlaceholder();
});

progress

浏览器正在下载资源时持续触发(约每 500ms 一次)。通过 audio.buffered 可以获取已缓冲的范围,用来绘制缓冲进度条:

audio.addEventListener('progress', () => {
  const buffered = audio.buffered;
  if (buffered.length > 0) {
    const bufferedEnd = buffered.end(buffered.length - 1);
    const percent = (bufferedEnd / audio.duration) * 100;
    bufferBar.style.width = `%`;
  }
});

canplay

浏览器认为可以开始播放,但不确定能否不中断地播完(缓冲可能不够)。readyState 变为 HAVE_FUTURE_DATA(3):

audio.addEventListener('canplay', () => {
  playButton.disabled = false; // 解锁播放按钮
});

canplaythrough

浏览器估算当前下载速度足以不间断播放到结束readyState 变为 HAVE_ENOUGH_DATA(4)。这是自动播放逻辑的最佳触发点

audio.addEventListener('canplaythrough', () => {
  console.log('缓冲充足,可以流畅播放');
  if (shouldAutoPlay) {
    audio.play();
  }
}, { once: true });

canplay vs canplaythrough 的区别:前者是"能播",后者是"能流畅播完"。对于网络较慢的用户,两者之间可能有明显的时间差。实际项目中,canplay 用于解锁 UI,canplaythrough 用于触发自动播放。


四、播放控制事件

play

调用 audio.play() 后立即触发,表示播放意图已确认,但此时音频不一定真的在发声(可能还在缓冲):

audio.addEventListener('play', () => {
  play# 第三章:音频事件系统 —— 掌握媒体播放的每一个生命周期节点
 
> 前两章我们搞清楚了 `<audio>` 的结构和 `HTMLMediaElement` 的属性方法。但一个真正健壮的播放器,靠的不是"主动查询状态",而是**被动监听事件**——让浏览器在正确的时机告诉你发生了什么。这一章,我们把 `HTMLMediaElement` 的完整事件系统彻底梳理清楚。
 
---
 
## **一、为什么事件系统如此重要?**
 
音频加载和播放是一个**异步过程**。你设置了 `src`,但数据不会瞬间到位;你调用了 `play()`,但声音不会立刻响起。整个过程涉及网络请求、解码、缓冲、渲染管线……每一个阶段都有对应的事件通知你。
 
如果你不理解事件的触发顺序,就会写出这样的代码:
 
```javascript
audio.src = 'music.mp3';
console.log(audio.duration); // NaN —— 元数据还没加载!
audio.currentTime = 30;      // 无效 —— 还不知道总时长!

而理解了事件系统,你才能写出真正可靠的代码。HTMLMediaElement 共定义了 17 个标准媒体事件,我们按触发阶段分组来讲。


二、事件触发全生命周期

一次完整的"加载 → 播放 → 结束"流程,事件的触发顺序如下:

设置 src / load()
    │
    ▼
loadstart          ← 开始加载资源
    │
    ▼
durationchange     ← 时长信息首次可用(或发生变化)
    │
    ▼
loadedmetadata     ← 元数据加载完成(时长、声道数等)
    │
    ▼
loadeddata         ← 当前帧数据加载完成,可以渲染第一帧
    │
    ▼
progress           ← 持续触发,表示正在下载数据
    │
    ▼
canplay            ← 可以开始播放(但可能中途需要缓冲)
    │
    ▼
canplaythrough     ← 预计可以流畅播放到结束(不需要缓冲等待)
    │
    ▼
play               ← play() 被调用,播放意图确认
    │
    ▼
playing            ← 音频真正开始输出声音
    │
    ├── timeupdate(持续触发,约 250ms/次)
    ├── progress(持续触发,有新数据下载时)
    ├── waiting(缓冲不足,暂时停止)
    ├── playing(缓冲恢复,继续播放)
    │
    ▼
pause              ← 用户或代码调用了 pause()
    │
    ▼
ended              ← 播放到达结尾

这条时间线是构建播放器 UI 状态机的骨架,每一个事件节点都对应着界面上需要响应的一个变化。


三、加载阶段事件详解

loadstart — 加载开始

浏览器开始请求媒体资源时触发。此时 networkState 变为 NETWORK_LOADING

audio.addEventListener('loadstart', () => &#123;
  console.log('开始加载,networkState:', audio.networkState); // 2
  showLoadingUI();
&#125;);

durationchange — 时长变化

duration 属性的值发生变化时触发。有两个典型场景:

  • 首次加载时,durationNaN 变为实际时长
  • 直播流中,duration 可能动态增长
audio.addEventListener('durationchange', () => &#123;
  if (isFinite(audio.duration)) &#123;
    totalTimeEl.textContent = formatTime(audio.duration);
  &#125; else &#123;
    totalTimeEl.textContent = 'LIVE'; // 直播流
  &#125;
&#125;);

loadedmetadata — 元数据加载完成

这是加载阶段最重要的事件之一。触发时 readyState 变为 HAVE_METADATA(1),以下数据此时才可靠可用:

  • duration(总时长)
  • audioTracks(音轨信息)
  • videoWidth / videoHeight(视频专属)
audio.addEventListener('loadedmetadata', () => &#123;
  console.log('总时长:', audio.duration);
  console.log('可 seek 范围:', audio.seekable.end(0));
 
  // 安全地执行需要时长的初始化操作
  initProgressBar(audio.duration);
 
  // 如果有"记住上次播放位置"的需求,在这里恢复
  const savedTime = localStorage.getItem('lastPosition');
  if (savedTime) audio.currentTime = parseFloat(savedTime);
&#125;);

loadeddata — 当前帧数据就绪

readyState 变为 HAVE_CURRENT_DATA(2)。对音频而言,意味着当前播放位置的数据已经解码完毕,可以开始输出(但不保证后续数据充足)。

progress — 下载进度更新

浏览器持续下载媒体数据时周期性触发。通过 audio.buffered 可以获取当前缓冲量,用于绘制缓冲进度条:

audio.addEventListener('progress', () => &#123;
  if (audio.buffered.length > 0 && audio.duration) &#123;
    const bufferedEnd = audio.buffered.end(audio.buffered.length - 1);
    const bufferedPercent = (bufferedEnd / audio.duration) * 100;
    bufferBar.style.width = `%`;
  &#125;
&#125;);

canplay — 可以开始播放

readyState 变为 HAVE_FUTURE_DATA(3),浏览器认为当前数据足以开始播放,但不保证能播完。适合作为"解除 loading 状态"的信号:

audio.addEventListener('canplay', () => &#123;
  hideLoadingSpinner();
  playBtn.disabled = false;
&#125;);

canplaythrough — 预计可以流畅播放到结束

readyState 变为 HAVE_ENOUGH_DATA(4)。浏览器估算当前网速和缓冲量,认为可以不中断地播放到结尾。注意这只是估算,不是保证:

audio.addEventListener('canplaythrough', () => &#123;
  console.log('数据充足,可以流畅播放');
  // 如果设置了 autoplay,此时是触发自动播放的好时机
  if (shouldAutoPlay) audio.play();
&#125;);

canplay vs canplaythrough 的选择:大多数场景用 canplay 就够了,它触发更早,用户等待时间更短。canplaythrough 更适合"确保不卡顿"的场景,如自动播放的背景音乐。


四、播放控制事件详解

play — 播放意图触发

调用 audio.play() 或设置 autoplay 后立即触发,此时音频不一定真的在发声(可能还在缓冲):

audio.addEventListener('play', () => &#123;
  // 更新 UI 为"播放中"状态
  playBtn.textContent = '⏸';
  playBtn.setAttribute('aria-label', '暂停');
&#125;);

playing — 音频真正开始输出

音频从暂停/等待状态恢复为实际播放时触发。与 play 的区别:

事件触发时机是否在发声
play调用 play() 后立即不一定
playing音频真正开始输出
audio.addEventListener('playing', () => &#123;
  hideBufferingIndicator(); // 缓冲结束,隐藏 loading 圈
&#125;);

pause — 暂停

调用 audio.pause() 后触发。注意:ended 事件触发时,paused 会变为 true,但不会触发 pause 事件:

audio.addEventListener('pause', () => &#123;
  playBtn.textContent = '▶';
  // 保存播放位置
  localStorage.setItem('lastPosition', audio.currentTime);
&#125;);

ended — 播放结束

音频播放到末尾时触发(loop = true 时不触发):

audio.addEventListener('ended', () => &#123;
  playBtn.textContent = '▶';
  audio.currentTime = 0;
 
  // 自动播放下一首
  playNextSong();
&#125;);

timeupdate — 播放时间更新

播放过程中以约 4 次/秒(250ms) 的频率触发,是更新进度条 UI 的核心事件:

audio.addEventListener('timeupdate', () => &#123;
  const percent = (audio.currentTime / audio.duration) * 100;
  progressFill.style.width = `%`;
  currentTimeEl.textContent = formatTime(audio.currentTime);
&#125;);

性能注意事项timeupdate 触发频率不算高,但如果在回调中执行复杂的 DOM 操作或计算,仍然可能造成性能问题。建议配合 requestAnimationFrame 做节流:

let rafId = null;
 
audio.addEventListener('timeupdate', () => &#123;
  if (rafId) return; // 上一帧还没渲染完,跳过
  rafId = requestAnimationFrame(() => &#123;
    updateProgressBar();
    rafId = null;
  &#125;);
&#125;);

ratechange — 播放速率变化

playbackRatedefaultPlaybackRate 改变时触发:

audio.addEventListener('ratechange', () => &#123;
  rateDisplay.textContent = `x`;
&#125;);

volumechange — 音量变化

volumemuted 属性改变时触发:

audio.addEventListener('volumechange', () => &#123;
  if (audio.muted || audio.volume === 0) &#123;
    volumeIcon.textContent = '🔇';
  &#125; else if (audio.volume &lt; 0.5) &#123;
    volumeIcon.textContent = '🔉';
  &#125; else &#123;
    volumeIcon.textContent = '🔊';
  &#125;
  volumeSlider.value = audio.muted ? 0 : audio.volume;
&#125;);

五、缓冲与网络事件详解

waiting — 等待缓冲

播放过程中数据不足# 第三章:音频事件系统 —— 掌握播放器的"神经网络"

如果说 HTMLMediaElement 是播放器的控制台,那么事件系统就是它的神经网络。一个健壮的播放器,不是靠轮询 currentTime 来感知状态变化,而是靠精准监听正确的事件、在正确的时机做出响应。本章把 HTMLMediaElement 的全部事件彻底梳理清楚。


一、为什么事件系统如此重要?

媒体播放是一个典型的异步状态机过程。从你设置 audio.src 到音频真正发出声音,中间经历了网络请求、格式解析、解码初始化、缓冲填充等一系列异步步骤。你无法用同步代码感知这些变化,只能依赖事件。

错误地使用事件——比如在错误的时机读取 duration,或者没有监听 waiting 事件导致 loading 状态缺失——是播放器 bug 的最大来源之一。把事件系统吃透,是从"能用"到"好用"的分水岭。


二、事件全景:按生命周期分类

HTMLMediaElement 定义了超过 20 个事件。把它们按照媒体生命周期分组,远比死记硬背有效得多。

2.1 资源加载阶段

这是音频从"一个 src 字符串"变成"可播放数据"的过程。

loadstart — 浏览器开始加载媒体资源时触发。这是加载流程的起点,适合在这里显示 loading 状态:

audio.addEventListener('loadstart', () => &#123;
  showSkeleton(); // 显示骨架屏或 loading
  console.log('开始加载,networkState:', audio.networkState);
&#125;);

durationchangeduration 属性发生变化时触发。初始加载时会从 NaN 变为实际时长,seek 到不同位置有时也会触发:

audio.addEventListener('durationchange', () => &#123;
  if (isFinite(audio.duration)) &#123;
    durationEl.textContent = formatTime(audio.duration);
  &#125; else if (audio.duration === Infinity) &#123;
    durationEl.textContent = '直播'; // 直播流
  &#125;
&#125;);

loadedmetadata — 元数据加载完成时触发。此时 durationreadyState(变为 1)已可用,是初始化播放器 UI 的最佳时机:

audio.addEventListener('loadedmetadata', () => &#123;
  // 此时 duration 已经可靠
  initProgressBar(audio.duration);
  // 如果需要从特定位置开始播放
  if (startTime) audio.currentTime = startTime;
&#125;);

loadeddata — 当前播放位置的数据加载完毕时触发(readyState 变为 2)。注意这不代表可以流畅播放,只代表"当前帧有数据":

audio.addEventListener('loadeddata', () => &#123;
  console.log('当前帧数据就绪,readyState:', audio.readyState); // 2
&#125;);

progress — 浏览器正在下载媒体数据时周期性触发。用于更新缓冲进度条:

audio.addEventListener('progress', () => &#123;
  updateBufferBar(audio.buffered, audio.duration);
&#125;);

canplay — 浏览器认为可以开始播放了(readyState 变为 3),但不保证能播放到结尾而不卡顿:

audio.addEventListener('canplay', () => &#123;
  playBtn.disabled = false; // 解锁播放按钮
  hideLoading();
&#125;);

canplaythrough — 浏览器估计以当前速率播放,数据足够支撑播放到结尾而不需要暂停缓冲(readyState 变为 4)。是"可以放心播放"的信号:

audio.addEventListener('canplaythrough', () => &#123;
  console.log('数据充足,可流畅播放到结尾');
  // 如果设置了 autoplay 且用户已交互,可在此触发播放
&#125;);

canplay vs canplaythrough 的选择:如果你想尽早开始播放(优先低延迟),监听 canplay;如果你想确保流畅体验(优先不卡顿),监听 canplaythrough。对于大多数播客/音乐场景,canplay 已经足够。


2.2 播放控制阶段

play — 调用 audio.play() 后立即触发(在实际音频输出之前)。注意它不代表声音已经响起,只代表"播放意图"被接受:

audio.addEventListener('play', () => &#123;
  playBtn.textContent = '⏸';
  playBtn.setAttribute('aria-label', '暂停');
&#125;);

playing — 音频真正开始输出声音时触发。包括首次播放、从暂停/缓冲中恢复播放。这是"声音已经响起"的可靠信号:

audio.addEventListener('playing', () => &#123;
  hideBufferingSpinner(); // 隐藏缓冲中的 loading
  console.log('音频正在输出');
&#125;);

pause — 调用 audio.pause() 后触发,或者音频因缓冲不足自动暂停时也会触发:

audio.addEventListener('pause', () => &#123;
  playBtn.textContent = '▶';
  playBtn.setAttribute('aria-label', '播放');
&#125;);

ended — 音频播放到结尾时触发(looptrue 时不触发):

audio.addEventListener('ended', () => &#123;
  if (playlist.hasNext()) &#123;
    playNext();
  &#125; else &#123;
    resetPlayer();
  &#125;
&#125;);

timeupdate — 播放过程中周期性触发,约每 250ms 一次(浏览器实现有差异)。用于更新进度条和当前时间显示:

audio.addEventListener('timeupdate', () => &#123;
  const progress = audio.currentTime / audio.duration;
  progressFill.style.width = `%`;
  currentTimeEl.textContent = formatTime(audio.currentTime);
&#125;);

性能注意timeupdate 触发非常频繁,回调里不要做重计算或 DOM 批量操作。如果需要高频更新(如音频可视化),应使用 requestAnimationFrame 而非依赖 timeupdate

ratechangeplaybackRatedefaultPlaybackRate 发生变化时触发:

audio.addEventListener('ratechange', () => &#123;
  rateDisplay.textContent = `x`;
&#125;);

volumechangevolumemuted 发生变化时触发:

audio.addEventListener('volumechange', () => &#123;
  if (audio.muted || audio.volume === 0) &#123;
    volumeIcon.textContent = '🔇';
  &#125; else if (audio.volume &lt; 0.5) &#123;
    volumeIcon.textContent = '🔉';
  &#125; else &#123;
    volumeIcon.textContent = '🔊';
  &#125;
&#125;);

2.3 缓冲与网络阶段

这组事件是实现"缓冲中..."提示的核心,也是最容易被忽略的一组。

waiting — 因数据不足而暂停播放时触发(网络慢、卡顿)。这是显示"缓冲中"转圈的正确时机:

audio.addEventListener('waiting', () => &#123;
  showBufferingSpinner();
  console.log('数据不足,等待缓冲...');
&#125;);

stalled — 浏览器尝试获取数据但数据停止到来时触发。比 waiting 更严重,通常意味着网络问题:

audio.addEventListener('stalled', () => &#123;
  console.warn('数据获取停滞,可能是网络问题');
  showNetworkWarning();
&#125;);

suspend — 浏览器主动暂停数据加载时触发(比如已缓冲足够多的数据,暂时不需要继续下载)。这是正常行为,不是错误:

audio.addEventListener('suspend', () => &#123;
  console.log('浏览器暂停加载,已缓冲足够数据');
&#125;);

abort — 媒体加载被中止(不是因为错误,而是因为主动中断,如切换 src):

audio.addEventListener('abort', () => &#123;
  console.log('加载被中止');
&#125;);

emptied — 媒体元素被清空时触发。调用 load() 方法、或者将 src 设为空字符串时触发:

audio.addEventListener('emptied', () => &#123;
  resetUI(); // 重置播放器 UI
  console.log('媒体元素已清空,networkState:', audio.networkState); // 0
&#125;);

2.4 跳转(Seek)阶段

seeking — 开始执行跳转时触发(用户拖动进度条时):

audio.addEventListener('seeking', () => &#123;
  showSeekingIndicator();
&#125;);

seeked — 跳转完成,新位置的数据已就绪时触发:

audio.addEventListener('seeked', () => &#123;
  hideSeekingIndicator();
  console.log('已跳转到:', audio.currentTime);
&#125;);

2.5 错误阶段

error — 发生错误时触发。通过 audio.error 属性获取 MediaError 对象:

audio.addEventListener('error', (event) => &#123;
  const err = audio.error;
  const messages = &#123;
    1: '用户中止了加载',
    2: '网络错误,请检查网络连接',
    3: '音频解码失败,文件可能已损坏',
    4: '不支持的音频格式或无效的 src',
  &#125;;
  const msg = messages[err?.code] || '未知错误';
  showErrorMessage(msg);
  console.error('MediaError:', err?.code, err?.message);
&#125;);

三、事件触发顺序:完整时序图

理解事件的触发顺序,是排查"事件没触发"或"触发时机不对"问题的基础。

正常加载播放的完整时序

设置 src / load()
      ↓
  [emptied]          ← 清空旧资源
      ↓
  [loadstart]        ← 开始加载新资源
      ↓
  [durationchange]   ← duration 从 NaN 变为实际值
      ↓
  [loadedmetadata]   ← 元数据就绪,readyState = 1
      ↓
  [loadeddata]       ← 当前帧数据就绪,readyState = 2
      ↓
  [canplay]          ← 可以开始播放,readyState = 3
      ↓
  [progress]         ← 持续下载中(多次触发)
      ↓
  [canplaythrough]   ← 数据充足,readyState = 4
      ↓
  调用 play()
      ↓
  [play]             ← 播放意图被接受
      ↓
  [playing]          ← 音频真正开始输出
      ↓
  [timeupdate]       ← 每 ~250ms 触发一次
      ↓
  [progress]         ← 持续下载(多次触发)
      ↓
  [ended]            ← 播放到结尾

卡顿缓冲时的时序

正常播放中
      ↓
  [timeupdate]
      ↓
  [waiting]          ← 数据不足,播放暂停
      ↓
  [progress]         ← 继续下载数据
      ↓
  [playing]          ← 数据充足,恢复播放(注意:不是 play 事件)
      ↓
  [timeupdate]

用 Mermaid 展示完整状态转换

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

四、play vs playingpause vs waiting 的本质区别

这是最容易混淆的两组事件,必须专门说清楚。

playplaying

事件触发时机代表含义
play调用 play() 后立即"我打算播放"
playing音频真正输出声音时"声音已经响起"

play 触发时,音频可能还在缓冲,还没有实际发声。playing 才是"声音已经出来了"的信号。如果你想隐藏 loading 动画,应该监听 playing 而不是 play

pausewaiting

事件触发原因用户感知
pause主动调用 pause()用户点了暂停
waiting缓冲不足自动停止播放卡顿转圈

两者都会导致音频停止输出,但原因完全不同。waiting 之后浏览器会自动恢复(触发 playing),而 pause 需要用户主动点击播放才能恢复。


五、实战:基于事件的健壮播放器状态管理

用一个状态机来管理播放器的各种状态,是工程化的最佳实践:

class PlayerStateMachine &#123;
  constructor(audioEl) &#123;
    this.audio = audioEl;
    // 定义所有可能的状态
    this.states = &#123;
      IDLE: 'idle',           // 空闲,未加载
      LOADING: 'loading',     // 加载中
      READY: 'ready',         // 就绪,可播放
      PLAYING: 'playing',     // 播放中
      PAUSED: 'paused',       // 已暂停
      BUFFERING: 'buffering', // 缓冲中
      ENDED: 'ended',         // 播放结束
      ERROR: 'error',         // 错误
    &#125;;
    this.current = this.states.IDLE;
    this._bindEvents();
  &#125;
 
  _transition(newState) &#123;
    const prev = this.current;
    this.current = newState;
    console.log(`状态变更: [$&#123;prev&#125;] → [$&#123;newState&#125;]`);
    this._onStateChange(newState, prev);
  &#125;
 
  _bindEvents() &#123;
    const a = this.audio;
 
    a.addEventListener('loadstart',      () => this._transition(this.states.LOADING));
    a.addEventListener('canplay',        () => this._transition(this.states.READY));
    a.addEventListener('play',           () => this._transition(this.states.LOADING)); // 可能还在缓冲
    a.addEventListener('playing',        () => this._transition(this.states.PLAYING));
    a.addEventListener('pause',          () => &#123;
      // 区分主动暂停和缓冲暂停
      if (!this.audio.ended) &#123;
        this._transition(this.states.PAUSED);
      &#125;
    &#125;);
    a.addEventListener('waiting',        () => this._transition(this.states.BUFFERING));
    a.addEventListener('ended',          () => this._transition(this.states.ENDED));
    a.addEventListener('error',          () => this._transition(this.states.ERROR));
    a.addEventListener('emptied',        () => this._transition(this.states.IDLE));
  &#125;
 
  _onStateChange(state, prev) &#123;
    const ui = &#123;
      [this.states.IDLE]:      () => resetUI(),
      [this.states.LOADING]:   () => showLoading(),
      [this.states.READY]:     () => &#123; hideLoading(); enablePlayBtn(); &#125;,
      [this.states.PLAYING]:   () => &#123; hideLoading(); setPlayIcon('pause'); &#125;,
      [this.states.PAUSED]:    () => setPlayIcon('play'),
      [this.states.BUFFERING]: () => showBufferingSpinner(),
      [this.states.ENDED]:     () => &#123; setPlayIcon('replay'); onEnded(); &#125;,
      [this.states.ERROR]:     () => showError(this.audio.error),
    &#125;;
    ui[state]?.();
  &#125;
 
  get isPlaying() &#123; return this.current === this.states.PLAYING; &#125;
  get isBuffering() &#123; return this.current === this.states.BUFFERING; &#125;
  get hasError() &#123; return this.current === this.states.ERROR; &#125;
&#125;
 
// 使用
const audio = document.getElementById('myAudio');
const player = new PlayerStateMachine(audio);

六、事件监听的性能优化

timeupdate 的节流

timeupdate 每秒触发约 4 次,如果回调里有复杂操作,需要节流:

let lastUpdate = 0;
audio.addEventListener('timeupdate', () => &#123;
  const now = Date.now();
  if (now - lastUpdate &lt; 100) return; // 最多每 100ms 更新一次
  lastUpdate = now;
  updateProgressBar();
&#125;);

requestAnimationFrame 替代 timeupdate 做可视化

对于音频可视化等高频更新场景,timeupdate 的频率远远不够,应该用 requestAnimationFrame

function renderLoop() &#123;
  if (!audio.paused) &#123;
    drawWaveform(); // 每帧绘制波形
    requestAnimationFrame(renderLoop);
  &#125;
&#125;
 
audio.addEventListener('playing', () => requestAnimationFrame(renderLoop));

使用 { once: true } 避免重复绑定

对于只需要触发一次的事件,使用 once 选项自动移除监听器:

audio.addEventListener('loadedmetadata', () => &#123;
  initUI(audio.duration);
&#125;, &#123; once: true &#125;); // 触发一次后自动移除

统一清理事件监听器

播放器组件销毁时,必须移除所有事件监听,防止内存泄漏:

class AudioPlayer &#123;
  constructor(el) &#123;
    this.audio = el;
    this._handlers = &#123;&#125;;
    this._on('timeupdate', this._onTimeUpdate.bind(this));
    this._on('ended', this._onEnded.bind(this));
  &#125;
 
  _on(event, handler) &#123;
    this._handlers[event] = handler;
    this.audio.addEventListener(event, handler);
  &#125;
 
  destroy() &#123;
    Object.entries(this._handlers).forEach(([event, handler]) => &#123;
      this.audio.removeEventListener(event, handler);
    &#125;);
    this._handlers = &#123;&#125;;
  &#125;
&#125;

七、本章知识图谱

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

小结

音频事件系统是播放器开发中最需要"心中有图"的部分。理解每个事件的触发时机、区分容易混淆的事件对(play vs playingpause vs waiting)、掌握完整的时序流程,能让你的播放器在各种网络条件和用户操作下都表现得稳定可靠。

下一章我们将深入音频格式与编解码——为什么同一首歌有 MP3、AAC、OGG 这么多格式?它们的压缩原理是什么?在 Web 上如何做出最优的格式选择策略?这些问题的答案,将直接影响你的产品在音质、体积和兼容性之间的取舍。