第三章:音频事件系统 —— 掌握媒体播放的每一个生命周期节点
前两章我们打好了基础:
<audio>标签的用法、HTMLMediaElement的属性与方法。但一个真正健壮的播放器,靠的不是"主动轮询"状态,而是被动监听事件。本章把HTMLMediaElement的完整事件系统从头到尾拆解清楚,让你在正确的时机做正确的事。
一、为什么事件系统如此重要?
音频加载和播放是一个异步过程。从设置 src 到真正能听到声音,中间要经历网络请求、格式解析、解码缓冲等多个阶段,每个阶段都可能耗时数百毫秒甚至更长。
如果你用轮询来检测状态:
// 错误示范:轮询检测,浪费性能且不可靠
setInterval(() => {
if (audio.readyState === 4) {
doSomething();
}
}, 100);不仅浪费 CPU,还可能错过事件窗口。正确的方式是事件驱动:
// 正确示范:事件驱动,精准响应
audio.addEventListener('canplaythrough', () => {
doSomething();
}, { once: true });HTMLMediaElement 一共定义了 20+ 个事件,覆盖了从资源加载到播放结束的完整生命周期。理解它们的触发顺序和触发条件,是构建专业播放器的核心能力。
二、事件触发全流程图
一次完整的"加载 → 播放 → 结束"流程,事件触发顺序如下:
这张图覆盖了正常播放路径。下面我们逐类详细拆解每一个事件。
三、加载阶段事件
这一组事件描述了资源从"开始请求"到"数据就绪"的完整过程。
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 });
canplayvscanplaythrough的区别:前者是"能播",后者是"能流畅播完"。对于网络较慢的用户,两者之间可能有明显的时间差。实际项目中,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', () => {
console.log('开始加载,networkState:', audio.networkState); // 2
showLoadingUI();
});durationchange — 时长变化
duration 属性的值发生变化时触发。有两个典型场景:
- 首次加载时,
duration从NaN变为实际时长 - 直播流中,
duration可能动态增长
audio.addEventListener('durationchange', () => {
if (isFinite(audio.duration)) {
totalTimeEl.textContent = formatTime(audio.duration);
} else {
totalTimeEl.textContent = 'LIVE'; // 直播流
}
});loadedmetadata — 元数据加载完成
这是加载阶段最重要的事件之一。触发时 readyState 变为 HAVE_METADATA(1),以下数据此时才可靠可用:
duration(总时长)audioTracks(音轨信息)videoWidth/videoHeight(视频专属)
audio.addEventListener('loadedmetadata', () => {
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);
});loadeddata — 当前帧数据就绪
readyState 变为 HAVE_CURRENT_DATA(2)。对音频而言,意味着当前播放位置的数据已经解码完毕,可以开始输出(但不保证后续数据充足)。
progress — 下载进度更新
浏览器持续下载媒体数据时周期性触发。通过 audio.buffered 可以获取当前缓冲量,用于绘制缓冲进度条:
audio.addEventListener('progress', () => {
if (audio.buffered.length > 0 && audio.duration) {
const bufferedEnd = audio.buffered.end(audio.buffered.length - 1);
const bufferedPercent = (bufferedEnd / audio.duration) * 100;
bufferBar.style.width = `%`;
}
});canplay — 可以开始播放
readyState 变为 HAVE_FUTURE_DATA(3),浏览器认为当前数据足以开始播放,但不保证能播完。适合作为"解除 loading 状态"的信号:
audio.addEventListener('canplay', () => {
hideLoadingSpinner();
playBtn.disabled = false;
});canplaythrough — 预计可以流畅播放到结束
readyState 变为 HAVE_ENOUGH_DATA(4)。浏览器估算当前网速和缓冲量,认为可以不中断地播放到结尾。注意这只是估算,不是保证:
audio.addEventListener('canplaythrough', () => {
console.log('数据充足,可以流畅播放');
// 如果设置了 autoplay,此时是触发自动播放的好时机
if (shouldAutoPlay) audio.play();
});
canplayvscanplaythrough的选择:大多数场景用canplay就够了,它触发更早,用户等待时间更短。canplaythrough更适合"确保不卡顿"的场景,如自动播放的背景音乐。
四、播放控制事件详解
play — 播放意图触发
调用 audio.play() 或设置 autoplay 后立即触发,此时音频不一定真的在发声(可能还在缓冲):
audio.addEventListener('play', () => {
// 更新 UI 为"播放中"状态
playBtn.textContent = '⏸';
playBtn.setAttribute('aria-label', '暂停');
});playing — 音频真正开始输出
音频从暂停/等待状态恢复为实际播放时触发。与 play 的区别:
| 事件 | 触发时机 | 是否在发声 |
|---|---|---|
play | 调用 play() 后立即 | 不一定 |
playing | 音频真正开始输出 | 是 |
audio.addEventListener('playing', () => {
hideBufferingIndicator(); // 缓冲结束,隐藏 loading 圈
});pause — 暂停
调用 audio.pause() 后触发。注意:ended 事件触发时,paused 会变为 true,但不会触发 pause 事件:
audio.addEventListener('pause', () => {
playBtn.textContent = '▶';
// 保存播放位置
localStorage.setItem('lastPosition', audio.currentTime);
});ended — 播放结束
音频播放到末尾时触发(loop = true 时不触发):
audio.addEventListener('ended', () => {
playBtn.textContent = '▶';
audio.currentTime = 0;
// 自动播放下一首
playNextSong();
});timeupdate — 播放时间更新
播放过程中以约 4 次/秒(250ms) 的频率触发,是更新进度条 UI 的核心事件:
audio.addEventListener('timeupdate', () => {
const percent = (audio.currentTime / audio.duration) * 100;
progressFill.style.width = `%`;
currentTimeEl.textContent = formatTime(audio.currentTime);
});性能注意事项:timeupdate 触发频率不算高,但如果在回调中执行复杂的 DOM 操作或计算,仍然可能造成性能问题。建议配合 requestAnimationFrame 做节流:
let rafId = null;
audio.addEventListener('timeupdate', () => {
if (rafId) return; // 上一帧还没渲染完,跳过
rafId = requestAnimationFrame(() => {
updateProgressBar();
rafId = null;
});
});ratechange — 播放速率变化
playbackRate 或 defaultPlaybackRate 改变时触发:
audio.addEventListener('ratechange', () => {
rateDisplay.textContent = `x`;
});volumechange — 音量变化
volume 或 muted 属性改变时触发:
audio.addEventListener('volumechange', () => {
if (audio.muted || audio.volume === 0) {
volumeIcon.textContent = '🔇';
} else if (audio.volume < 0.5) {
volumeIcon.textContent = '🔉';
} else {
volumeIcon.textContent = '🔊';
}
volumeSlider.value = audio.muted ? 0 : audio.volume;
});五、缓冲与网络事件详解
waiting — 等待缓冲
播放过程中数据不足# 第三章:音频事件系统 —— 掌握播放器的"神经网络"
如果说
HTMLMediaElement是播放器的控制台,那么事件系统就是它的神经网络。一个健壮的播放器,不是靠轮询currentTime来感知状态变化,而是靠精准监听正确的事件、在正确的时机做出响应。本章把HTMLMediaElement的全部事件彻底梳理清楚。
一、为什么事件系统如此重要?
媒体播放是一个典型的异步状态机过程。从你设置 audio.src 到音频真正发出声音,中间经历了网络请求、格式解析、解码初始化、缓冲填充等一系列异步步骤。你无法用同步代码感知这些变化,只能依赖事件。
错误地使用事件——比如在错误的时机读取 duration,或者没有监听 waiting 事件导致 loading 状态缺失——是播放器 bug 的最大来源之一。把事件系统吃透,是从"能用"到"好用"的分水岭。
二、事件全景:按生命周期分类
HTMLMediaElement 定义了超过 20 个事件。把它们按照媒体生命周期分组,远比死记硬背有效得多。
2.1 资源加载阶段
这是音频从"一个 src 字符串"变成"可播放数据"的过程。
loadstart — 浏览器开始加载媒体资源时触发。这是加载流程的起点,适合在这里显示 loading 状态:
audio.addEventListener('loadstart', () => {
showSkeleton(); // 显示骨架屏或 loading
console.log('开始加载,networkState:', audio.networkState);
});durationchange — duration 属性发生变化时触发。初始加载时会从 NaN 变为实际时长,seek 到不同位置有时也会触发:
audio.addEventListener('durationchange', () => {
if (isFinite(audio.duration)) {
durationEl.textContent = formatTime(audio.duration);
} else if (audio.duration === Infinity) {
durationEl.textContent = '直播'; // 直播流
}
});loadedmetadata — 元数据加载完成时触发。此时 duration、readyState(变为 1)已可用,是初始化播放器 UI 的最佳时机:
audio.addEventListener('loadedmetadata', () => {
// 此时 duration 已经可靠
initProgressBar(audio.duration);
// 如果需要从特定位置开始播放
if (startTime) audio.currentTime = startTime;
});loadeddata — 当前播放位置的数据加载完毕时触发(readyState 变为 2)。注意这不代表可以流畅播放,只代表"当前帧有数据":
audio.addEventListener('loadeddata', () => {
console.log('当前帧数据就绪,readyState:', audio.readyState); // 2
});progress — 浏览器正在下载媒体数据时周期性触发。用于更新缓冲进度条:
audio.addEventListener('progress', () => {
updateBufferBar(audio.buffered, audio.duration);
});canplay — 浏览器认为可以开始播放了(readyState 变为 3),但不保证能播放到结尾而不卡顿:
audio.addEventListener('canplay', () => {
playBtn.disabled = false; // 解锁播放按钮
hideLoading();
});canplaythrough — 浏览器估计以当前速率播放,数据足够支撑播放到结尾而不需要暂停缓冲(readyState 变为 4)。是"可以放心播放"的信号:
audio.addEventListener('canplaythrough', () => {
console.log('数据充足,可流畅播放到结尾');
// 如果设置了 autoplay 且用户已交互,可在此触发播放
});
canplayvscanplaythrough的选择:如果你想尽早开始播放(优先低延迟),监听canplay;如果你想确保流畅体验(优先不卡顿),监听canplaythrough。对于大多数播客/音乐场景,canplay已经足够。
2.2 播放控制阶段
play — 调用 audio.play() 后立即触发(在实际音频输出之前)。注意它不代表声音已经响起,只代表"播放意图"被接受:
audio.addEventListener('play', () => {
playBtn.textContent = '⏸';
playBtn.setAttribute('aria-label', '暂停');
});playing — 音频真正开始输出声音时触发。包括首次播放、从暂停/缓冲中恢复播放。这是"声音已经响起"的可靠信号:
audio.addEventListener('playing', () => {
hideBufferingSpinner(); // 隐藏缓冲中的 loading
console.log('音频正在输出');
});pause — 调用 audio.pause() 后触发,或者音频因缓冲不足自动暂停时也会触发:
audio.addEventListener('pause', () => {
playBtn.textContent = '▶';
playBtn.setAttribute('aria-label', '播放');
});ended — 音频播放到结尾时触发(loop 为 true 时不触发):
audio.addEventListener('ended', () => {
if (playlist.hasNext()) {
playNext();
} else {
resetPlayer();
}
});timeupdate — 播放过程中周期性触发,约每 250ms 一次(浏览器实现有差异)。用于更新进度条和当前时间显示:
audio.addEventListener('timeupdate', () => {
const progress = audio.currentTime / audio.duration;
progressFill.style.width = `%`;
currentTimeEl.textContent = formatTime(audio.currentTime);
});性能注意:
timeupdate触发非常频繁,回调里不要做重计算或 DOM 批量操作。如果需要高频更新(如音频可视化),应使用requestAnimationFrame而非依赖timeupdate。
ratechange — playbackRate 或 defaultPlaybackRate 发生变化时触发:
audio.addEventListener('ratechange', () => {
rateDisplay.textContent = `x`;
});volumechange — volume 或 muted 发生变化时触发:
audio.addEventListener('volumechange', () => {
if (audio.muted || audio.volume === 0) {
volumeIcon.textContent = '🔇';
} else if (audio.volume < 0.5) {
volumeIcon.textContent = '🔉';
} else {
volumeIcon.textContent = '🔊';
}
});2.3 缓冲与网络阶段
这组事件是实现"缓冲中..."提示的核心,也是最容易被忽略的一组。
waiting — 因数据不足而暂停播放时触发(网络慢、卡顿)。这是显示"缓冲中"转圈的正确时机:
audio.addEventListener('waiting', () => {
showBufferingSpinner();
console.log('数据不足,等待缓冲...');
});stalled — 浏览器尝试获取数据但数据停止到来时触发。比 waiting 更严重,通常意味着网络问题:
audio.addEventListener('stalled', () => {
console.warn('数据获取停滞,可能是网络问题');
showNetworkWarning();
});suspend — 浏览器主动暂停数据加载时触发(比如已缓冲足够多的数据,暂时不需要继续下载)。这是正常行为,不是错误:
audio.addEventListener('suspend', () => {
console.log('浏览器暂停加载,已缓冲足够数据');
});abort — 媒体加载被中止(不是因为错误,而是因为主动中断,如切换 src):
audio.addEventListener('abort', () => {
console.log('加载被中止');
});emptied — 媒体元素被清空时触发。调用 load() 方法、或者将 src 设为空字符串时触发:
audio.addEventListener('emptied', () => {
resetUI(); // 重置播放器 UI
console.log('媒体元素已清空,networkState:', audio.networkState); // 0
});2.4 跳转(Seek)阶段
seeking — 开始执行跳转时触发(用户拖动进度条时):
audio.addEventListener('seeking', () => {
showSeekingIndicator();
});seeked — 跳转完成,新位置的数据已就绪时触发:
audio.addEventListener('seeked', () => {
hideSeekingIndicator();
console.log('已跳转到:', audio.currentTime);
});2.5 错误阶段
error — 发生错误时触发。通过 audio.error 属性获取 MediaError 对象:
audio.addEventListener('error', (event) => {
const err = audio.error;
const messages = {
1: '用户中止了加载',
2: '网络错误,请检查网络连接',
3: '音频解码失败,文件可能已损坏',
4: '不支持的音频格式或无效的 src',
};
const msg = messages[err?.code] || '未知错误';
showErrorMessage(msg);
console.error('MediaError:', err?.code, err?.message);
});三、事件触发顺序:完整时序图
理解事件的触发顺序,是排查"事件没触发"或"触发时机不对"问题的基础。
正常加载播放的完整时序
设置 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 展示完整状态转换
四、play vs playing、pause vs waiting 的本质区别
这是最容易混淆的两组事件,必须专门说清楚。
play 与 playing
| 事件 | 触发时机 | 代表含义 |
|---|---|---|
play | 调用 play() 后立即 | "我打算播放" |
playing | 音频真正输出声音时 | "声音已经响起" |
play 触发时,音频可能还在缓冲,还没有实际发声。playing 才是"声音已经出来了"的信号。如果你想隐藏 loading 动画,应该监听 playing 而不是 play。
pause 与 waiting
| 事件 | 触发原因 | 用户感知 |
|---|---|---|
pause | 主动调用 pause() | 用户点了暂停 |
waiting | 缓冲不足自动停止 | 播放卡顿转圈 |
两者都会导致音频停止输出,但原因完全不同。waiting 之后浏览器会自动恢复(触发 playing),而 pause 需要用户主动点击播放才能恢复。
五、实战:基于事件的健壮播放器状态管理
用一个状态机来管理播放器的各种状态,是工程化的最佳实践:
class PlayerStateMachine {
constructor(audioEl) {
this.audio = audioEl;
// 定义所有可能的状态
this.states = {
IDLE: 'idle', // 空闲,未加载
LOADING: 'loading', // 加载中
READY: 'ready', // 就绪,可播放
PLAYING: 'playing', // 播放中
PAUSED: 'paused', // 已暂停
BUFFERING: 'buffering', // 缓冲中
ENDED: 'ended', // 播放结束
ERROR: 'error', // 错误
};
this.current = this.states.IDLE;
this._bindEvents();
}
_transition(newState) {
const prev = this.current;
this.current = newState;
console.log(`状态变更: [${prev}] → [${newState}]`);
this._onStateChange(newState, prev);
}
_bindEvents() {
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', () => {
// 区分主动暂停和缓冲暂停
if (!this.audio.ended) {
this._transition(this.states.PAUSED);
}
});
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));
}
_onStateChange(state, prev) {
const ui = {
[this.states.IDLE]: () => resetUI(),
[this.states.LOADING]: () => showLoading(),
[this.states.READY]: () => { hideLoading(); enablePlayBtn(); },
[this.states.PLAYING]: () => { hideLoading(); setPlayIcon('pause'); },
[this.states.PAUSED]: () => setPlayIcon('play'),
[this.states.BUFFERING]: () => showBufferingSpinner(),
[this.states.ENDED]: () => { setPlayIcon('replay'); onEnded(); },
[this.states.ERROR]: () => showError(this.audio.error),
};
ui[state]?.();
}
get isPlaying() { return this.current === this.states.PLAYING; }
get isBuffering() { return this.current === this.states.BUFFERING; }
get hasError() { return this.current === this.states.ERROR; }
}
// 使用
const audio = document.getElementById('myAudio');
const player = new PlayerStateMachine(audio);六、事件监听的性能优化
timeupdate 的节流
timeupdate 每秒触发约 4 次,如果回调里有复杂操作,需要节流:
let lastUpdate = 0;
audio.addEventListener('timeupdate', () => {
const now = Date.now();
if (now - lastUpdate < 100) return; // 最多每 100ms 更新一次
lastUpdate = now;
updateProgressBar();
});用 requestAnimationFrame 替代 timeupdate 做可视化
对于音频可视化等高频更新场景,timeupdate 的频率远远不够,应该用 requestAnimationFrame:
function renderLoop() {
if (!audio.paused) {
drawWaveform(); // 每帧绘制波形
requestAnimationFrame(renderLoop);
}
}
audio.addEventListener('playing', () => requestAnimationFrame(renderLoop));使用 { once: true } 避免重复绑定
对于只需要触发一次的事件,使用 once 选项自动移除监听器:
audio.addEventListener('loadedmetadata', () => {
initUI(audio.duration);
}, { once: true }); // 触发一次后自动移除统一清理事件监听器
播放器组件销毁时,必须移除所有事件监听,防止内存泄漏:
class AudioPlayer {
constructor(el) {
this.audio = el;
this._handlers = {};
this._on('timeupdate', this._onTimeUpdate.bind(this));
this._on('ended', this._onEnded.bind(this));
}
_on(event, handler) {
this._handlers[event] = handler;
this.audio.addEventListener(event, handler);
}
destroy() {
Object.entries(this._handlers).forEach(([event, handler]) => {
this.audio.removeEventListener(event, handler);
});
this._handlers = {};
}
}七、本章知识图谱
小结
音频事件系统是播放器开发中最需要"心中有图"的部分。理解每个事件的触发时机、区分容易混淆的事件对(play vs playing、pause vs waiting)、掌握完整的时序流程,能让你的播放器在各种网络条件和用户操作下都表现得稳定可靠。
下一章我们将深入音频格式与编解码——为什么同一首歌有 MP3、AAC、OGG 这么多格式?它们的压缩原理是什么?在 Web 上如何做出最优的格式选择策略?这些问题的答案,将直接影响你的产品在音质、体积和兼容性之间的取舍。