☰
Vue 3 + Element Plus 音频组件:支持 m3u8 与播放列表
2026/9/30 5:44:04 网站建设 项目流程

1. 从原生 audio 到封装组件:这个音频组件到底要解决什么问题

做 Vue 项目做久了,你会发现一个规律:凡是和浏览器原生 API 打交道的东西,第一次用觉得简单,第二次用开始烦,第三次用就想封装成组件了。音频播放就是典型代表。原生<audio>标签给了一个默认的播放条,但你只要稍微对 UI 有点要求,比如想跟 Element 的设计语言对齐、想放到自己的卡片布局里、想在播放列表里切换曲目,那个默认控件就会立刻变得不合时宜。

我最初接手的是一个在线课程的播放页面,需求说起来不复杂:一条音频、一个进度条、一个音量控制、一个倍速选择,外加一个课程章节的播放列表。当时想的是直接用<audio controls>就完事了,结果设计稿一出来,默认控件的外观和尺寸完全对不上,移动端更是惨不忍睹——iOS 上的默认播放器会全屏接管,安卓各家浏览器渲染出来的样式都不一样。从那时候起我就确定,这东西必须自己封装。

这篇内容想聊的就是:在 Vue 生态里,怎么基于 Element 的原子组件,手搓一个真正能用在生产环境里的音频播放组件。它要能处理播放、暂停、进度拖拽、音量调节、倍速切换、播放列表切歌,还要能接 m3u8 这种流式音源。整套方案我会给出可以直接复制到项目里的代码,也会把我在实际使用中踩过的坑一条条列出来,比如为什么duration有时候是NaN,为什么自动播放会被浏览器拦掉,为什么组件都销毁了声音还在响。

适合谁看?如果你正在做 Vue 2 或 Vue 3 项目,用过 Element UI 或 Element Plus,需要自己实现音频播放功能,那这篇基本能覆盖你八成以上的场景。如果你只是想找一个现成的第三方音频库,我也会在后面对比几种方案,告诉你什么时候该用库、什么时候自己写更划算。

2. 组件接口设计:先把 props、事件和状态机定死

动手写代码之前,我习惯先把接口想清楚。音频组件最容易出问题的地方不是 UI,而是状态。播放、暂停、缓冲、结束、切歌,这几个状态如果没有明确的来源,很容易出现按钮图标显示"暂停"但实际没在播、进度条走到头但状态还是播放中这类诡异现象。

2.1 props 与事件清单,照着接口写不会乱

我最终的接口设计大概是这个样子,你在写之前也可以先照着列一遍,能省掉后面很多返工。

props:

  • src:音频地址,支持普通 mp3/wav,也支持 m3u8 流地址
  • autoplay:是否自动播放,默认false,原因后面细说
  • preload:预加载策略,none/metadata/auto,默认metadata
  • loop:单曲循环
  • list:播放列表数组,元素包含name、url、可选artist
  • initialVolume:初始音量,0 到 1 之间,默认 0.8
  • rates:倍速可选项,默认[0.5, 1, 1.25, 1.5, 2]
  • compact:紧凑模式,用于列表内嵌的小播放器

emits:

  • play/pause:播放状态变化
  • ended:当前曲目播放结束
  • timeupdate:播放进度变化,抛出{ currentTime, duration }
  • loadedmetadata:元数据加载完成,抛出{ duration }
  • error:加载或解码失败,抛出错误对象
  • change:播放列表切歌时抛出当前索引和曲目信息

为什么要把timeupdate也往外抛?因为很多时候父组件需要根据播放进度做别的事,比如高亮当前朗读的文本、上报学习进度、自动翻页。如果组件内部吃掉了这个事件,父组件就只能轮询,很别扭。

提示:autoplay我默认设成false,这不是偷懒。浏览器对带声音的自动播放有严格限制,绝大多数情况下会被静默拦截,与其让它看起来"能用但经常不灵",不如把控制权交给用户。

2.2 用一个简单状态机管住播放、暂停、缓冲、结束

状态管理这块我不用 Vuex 也不用 Pinia,一个组件内部的ref就够了,关键是状态要单一来源。我的做法是维护一个status字段,取值只有四种:idle、playing、paused、loading。所有按钮图标、禁用状态、加载动画都从这一个字段推导出来。

这里有个细节值得说一说。很多人会同时维护isPlaying和isPaused两个布尔值,结果切歌的时候忘记重置其中一个,就出现了两个都true的荒唐局面。单一状态字段从根上避免这个问题。

缓冲状态我单独用一个isBuffering布尔值表示,因为它和播放状态可以并存——正在播放但网络卡住,这时候按钮应该还是"暂停"图标,但同时要转个圈。这两个信息维度不同,不要硬塞进一个字段里。

2.3 进度条、缓冲条、音量条各自的取值逻辑

进度条我用 Element 的el-slider,它的v-model是当前值,需要配合min和max。音频的进度取值有两个坑:一是duration在元数据加载前是NaN,直接拿去做max会让滑块行为异常;二是el-slider的max为 0 时也会出问题。

我的处理方式是给max加一个兜底:Math.max(duration, 1),同时给进度条加一个disabled状态,duration无效时直接禁用滑块。视觉上你可能察觉不到,但能避免用户拖动一个值域错误的控件。

缓冲条我一开始没做,后来加了。原因是有用户反馈"点了播放半天没声音,以为坏了"。其实是在缓冲,但界面上除了一个小转圈没有任何反馈。缓冲进度的获取方式是监听audio的progress事件,读取buffered对象:

const onProgress = () => { const audio = audioRef.value if (!audio || !audio.buffered.length) return const end = audio.buffered.end(audio.buffered.length - 1) bufferedPercent.value = duration.value ? (end / duration.value) * 100 : 0 }

拿最后一个buffered区间是因为用户可能已经跳转过,前面的区间是旧数据。这个细节不处理,缓冲条会来回跳,看着很慌。

音量条我用el-slider的竖向模式,放在一个el-popover里,点击音量图标才展开。竖滑块在 Element Plus 里是支持的,加个vertical属性就行,高度大概设 100px 比较顺手。

3. 完整实现:一个可直接复用的 Vue 3 + Element Plus 音频播放器

理论说完了,直接上代码。我按 Vue 3 的<script setup>写,如果你还在 Vue 2 + Element UI,逻辑完全一样,把组合式 API 换成data和methods,把<script setup>换成export default {}即可,el-slider和el-popover的用法基本没变。

3.1 模板结构:把 audio 藏在背后,UI 交给 Element

模板层的思路是:<audio>标签只作为播放引擎存在,给它加class="hidden-audio"隐藏掉,所有交互都由我们自己画的控件处理。

<template> <div class="audio-player" :class="{ 'is-compact': compact }"> <audio ref="audioRef" :src="currentSrc" :preload="preload" :loop="loop" @loadedmetadata="onLoadedMetadata" @timeupdate="onTimeUpdate" @progress="onProgress" @ended="onEnded" @error="onError" @waiting="onWaiting" @canplay="onCanPlay" @play="onPlay" @pause="onPause" /> <div class="player-main"> <el-button class="play-btn" circle :type="status === 'playing' ? 'primary' : 'default'" @click="togglePlay" > <el-icon v-if="isBuffering"><Loading /></el-icon> <el-icon v-else-if="status === 'playing'"><VideoPause /></el-icon> <el-icon v-else><VideoPlay /></el-icon> </el-button> <div class="player-info"> <div class="title-row"> <span class="track-name">{{ currentTrack.name || '未命名音频' }}</span> <span class="time">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span> </div> <div class="slider-wrap"> <div class="buffered-bar" :style="{ width: bufferedPercent + '%' }" /> <el-slider v-model="currentTime" :min="0" :max="Math.max(duration, 1)" :disabled="!isDurationValid" :show-tooltip="false" @input="onSliderInput" @change="onSliderChange" /> </div> </div> <div class="player-tools"> <el-popover placement="top" :width="60" trigger="click"> <template #reference> <el-button text><el-icon><Headset /></el-icon></el-button> </template> <el-slider v-model="volume" vertical height="100px" :min="0" :max="1" :step="0.01" @input="onVolumeChange" /> </el-popover> <el-dropdown @command="onRateChange"> <el-button text>{{ playbackRate }}x</el-button> <template #dropdown> <el-dropdown-menu> <el-dropdown-item v-for="r in rates" :key="r" :command="r">{{ r }}x</el-dropdown-item> </el-dropdown-menu> </template> </el-dropdown> </div> </div> <div v-if="list && list.length" class="playlist"> <el-scrollbar max-height="180px"> <div v-for="(item, index) in list" :key="item.url" class="playlist-item" :class="{ active: index === currentIndex }" @click="switchTrack(index)" > <el-icon class="item-icon"> <VideoPlay v-if="index === currentIndex && status === 'playing'" /> <Headset v-else /> </el-icon> <span class="item-name">{{ item.name }}</span> <span v-if="item.artist" class="item-artist">{{ item.artist }}</span> </div> </el-scrollbar> </div> </div> </template>

图标全部来自@element-plus/icons-vue,你在入口文件里全局注册一下就行。注意el-slider的@input和@change是两个不同的时机,这个差别非常关键,下一节展开说。

3.2 逻辑层:组合式 API 的完整写法

逻辑层的核心是把audio元素的事件映射成响应式状态,同时保证反向操作(点按钮、拖滑块)能正确作用到audio元素上。

<script setup> import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue' import { ElMessage } from 'element-plus' import Hls from 'hls.js' const props = defineProps({ src: { type: String, default: '' }, autoplay: { type: Boolean, default: false }, preload: { type: String, default: 'metadata' }, loop: { type: Boolean, default: false }, list: { type: Array, default: () => [] }, initialVolume: { type: Number, default: 0.8 }, rates: { type: Array, default: () => [0.5, 1, 1.25, 1.5, 2] }, compact: { type: Boolean, default: false } }) const emit = defineEmits([ 'play', 'pause', 'ended', 'timeupdate', 'loadedmetadata', 'error', 'change' ]) const audioRef = ref(null) const status = ref('idle') const isBuffering = ref(false) const currentTime = ref(0) const duration = ref(0) const bufferedPercent = ref(0) const volume = ref(props.initialVolume) const playbackRate = ref(1) const currentIndex = ref(0) const isDragging = ref(false) let hlsInstance = null const isDurationValid = computed(() => { return Number.isFinite(duration.value) && duration.value > 0 }) const currentTrack = computed(() => { if (props.list.length) return props.list[currentIndex.value] || {} return { name: '', url: props.src } }) const currentSrc = computed(() => { if (props.list.length) return props.list[currentIndex.value]?.url || '' return props.src }) function formatTime(sec) { if (!Number.isFinite(sec) || sec < 0) return '00:00' const m = Math.floor(sec / 60) const s = Math.floor(sec % 60) return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` }

这里formatTime对NaN和负数做了兜底。别看这个判断简单,没有它的时候,我遇到过进度条上显示NaN:NaN的情况,用户截图来问是不是 bug,很尴尬。

currentSrc用computed而不是直接绑定props.src,是为了让列表模式和单曲模式共用一套渲染逻辑。

3.3 拖拽进度条的两个关键细节

最关键的部分来了。el-slider的@input在拖动过程中会持续触发,@change只在松手时触发一次。这两个事件如果用错,会出现两种典型 bug。

第一种是只在@change里设置audio.currentTime,拖动过程中音频继续播放,位置显示会跟手指打架。第二种是只在@input里设置,每移动一个像素就 seek 一次,浏览器会疯狂触发seeking事件,音质直接崩掉。

我的做法是:@input里只置一个isDragging标志,让timeupdate暂时不更新currentTime,拖完了在@change里一次性 seek。

function onSliderInput(val) { isDragging.value = true currentTime.value = val } function onSliderChange(val) { isDragging.value = false const audio = audioRef.value if (!audio) return audio.currentTime = val } function onTimeUpdate(e) { if (isDragging.value) return const audio = e.target currentTime.value = audio.currentTime emit('timeupdate', { currentTime: audio.currentTime, duration: audio.duration }) }

第二个细节是移动端的触摸体验。el-slider默认的滑块在手指下太小,拖动时容易拖飞。我一般会覆写一下样式,把滑块直径从 16px 提到 20px,同时给el-slider外面包一层padding: 8px 0,增大热区。

.slider-wrap { padding: 8px 0; position: relative; } .slider-wrap :deep(.el-slider__button) { width: 20px; height: 20px; } .buffered-bar { position: absolute; top: 50%; left: 0; height: 4px; border-radius: 2px; transform: translateY(-50%); background: rgba(64, 158, 255, 0.25); pointer-events: none; z-index: 1; }

缓冲条我用了绝对定位压在滑块轨道下方,颜色比主进度条浅,pointer-events: none保证它不拦截点击。

3.4 播放列表与切歌的无缝衔接

切歌这个动作看着简单,其实有几个必须处理的点。直接改src会触发重新加载,但如果你在watch里没有先暂停当前音频,前一首的timeupdate可能还在往新曲目上写数据,进度条会闪一下再归零。

我的处理顺序是:先pause,再重置状态,再换src,最后按需play。

watch(currentSrc, async (newSrc) => { if (!newSrc) return destroyHls() const audio = audioRef.value if (!audio) return audio.pause() status.value = 'idle' currentTime.value = 0 duration.value = 0 bufferedPercent.value = 0 if (isM3u8(newSrc)) { await nextTick() attachHls(newSrc) } }) async function switchTrack(index) { if (index === currentIndex.value) { togglePlay() return } const wasPlaying = status.value === 'playing' currentIndex.value = index emit('change', { index, track: props.list[index] }) await nextTick() if (wasPlaying) { play() } } function togglePlay() { status.value === 'playing' ? pause() : play() } async function play() { const audio = audioRef.value if (!audio) return try { await audio.play() } catch (err) { status.value = 'paused' ElMessage.warning('播放被拦截,请手动点击播放') } } function pause() { audioRef.value?.pause() } function onPlay() { status.value = 'playing' isBuffering.value = false emit('play') } function onPause() { status.value = 'paused' emit('pause') }

注意play()返回的是 Promise,一定得catch。浏览器拦截自动播放的时候,这个 Promise 会 reject,如果你不处理,控制台会飘一堆未捕获的异常,状态也会卡在"播放中"。

onEnded里要处理单曲循环和自动下一首的分支:

function onEnded() { emit('ended') if (props.loop) return if (props.list.length && currentIndex.value < props.list.length - 1) { switchTrack(currentIndex.value + 1) } else { status.value = 'idle' currentTime.value = 0 } }

4. m3u8 流式音频怎么接进来

如果你只放本地 mp3,上面这套代码已经够用了。但实际项目里,尤其是课程、广播这类场景,音源很可能是 m3u8 格式的流。这里必须单独讲一章,因为它的处理方式和普通音频完全不同。

4.1 浏览器的支持差异与判断方式

m3u8 其实是一个播放列表文件,里面记录的是一段段.ts分片。浏览器要播放它,需要 HLS 协议的支持。现状是这样的:Safari 系(包括 iOS 上的所有浏览器,因为它们底层都是 WebKit)原生支持 HLS,可以直接把.m3u8地址塞给<audio>的src;而 Chrome、Firefox、Edge 在桌面端都不支持,必须借助hls.js这类库,通过 Media Source Extensions 把分片喂给<audio>元素。

所以判断逻辑要写成分支:

function isM3u8(url) { return /\.m3u8($|\?)/i.test(url) } function canPlayNativeHls(audio) { return audio.canPlayType('application/vnd.apple.mpegurl') !== '' }

注意这里用canPlayType判断,不要用 UA 判断。UA 判断在 PC 端的 Safari 和某些定制浏览器上会失灵,而canPlayType是标准 API,稳得多。

4.2 用 hls.js 动态挂载与销毁实例

hls.js的用法不复杂,关键是生命周期要配对。创建实例、绑定媒体元素、加载源、监听错误、销毁,一步都不能少。

function attachHls(url) { const audio = audioRef.value if (!audio) return if (canPlayNativeHls(audio)) { audio.src = url return } if (Hls.isSupported()) { hlsInstance = new Hls({ enableWorker: true, lowLatencyMode: false, maxBufferLength: 30 }) hlsInstance.loadSource(url) hlsInstance.attachMedia(audio) hlsInstance.on(Hls.Events.ERROR, (event, data) => { if (data.fatal) { switch (data.type) { case Hls.ErrorTypes.NETWORK_ERROR: hlsInstance.startLoad() break case Hls.ErrorTypes.MEDIA_ERROR: hlsInstance.recoverMediaError() break default: destroyHls() emit('error', data) break } } }) } else { ElMessage.error('当前浏览器不支持播放该音频流') } } function destroyHls() { if (hlsInstance) { hlsInstance.destroy() hlsInstance = null } } onBeforeUnmount(() => { destroyHls() const audio = audioRef.value if (audio) { audio.pause() audio.removeAttribute('src') audio.load() } })

maxBufferLength设成 30 是有讲究的。默认值偏大,直播流场景下会造成明显延迟;设太小又容易卡顿。纯音频的话,30 秒是个比较舒服的平衡点。enableWorker开着能把解析工作放到 worker 线程,主线程压力小一些,播放列表滚动会更跟手。

注意:hlsInstance.attachMedia(audio)之后不能再手动给audio.src赋值,两者会打架。要么走原生 HLS,要么走 hls.js,二选一,别混着来。

onBeforeUnmount里的清理动作也是必须的。removeAttribute('src')加上load()是一个小技巧,能让浏览器立即停止对该地址的请求。不加这两句,有些情况下音频会继续在后台缓冲,白白消耗流量。

5. 常见问题与排查实录

下面这些是真实被问过、也是我自己踩过的坑。我把它们整理成问题、原因、解法三段式,方便你对照排查。

5.1 时长显示 NaN 或 Infinity

最常见的一个。原因有三个可能:一是元数据还没加载完就读取了duration,此时是NaN;二是音源是流式的,本身没有确定的时长,duration会返回Infinity;三是服务端没有正确返回Content-Length,或者不支持 Range 请求,浏览器拿不到完整信息。

解法是加一个有效值判断,同时监听loadedmetadata事件而不是在mounted里直接读:

function onLoadedMetadata(e) { const d = e.target.duration duration.value = Number.isFinite(d) ? d : 0 emit('loadedmetadata', { duration: duration.value }) }

如果是流式音源确实没有总时长,那 UI 上就应该隐藏总时长,只显示已播放时间,这是正常的,不是 bug。

5.2 自动播放没反应

浏览器策略问题。Chrome 的规则是:用户与页面产生过交互(点击、触摸、按键)之前,带声音的媒体不允许自动播放。这个规则无法绕过,也不需要绕过。

正确的做法是把autoplay当作"尽力而为"的属性,同时在play()失败时给用户一个明确的提示,比如"点击播放按钮开始收听"。我见过有的项目为了"解决"这个问题,把音频先静音播放再取消静音,这种做法在部分浏览器上已经失效,而且体验很怪,不建议用。

5.3 组件卸载了声音还在响

路由切换或者弹窗关闭后音频还在播,是因为audio元素没有被正确释放。Vue 卸载组件时只是把 DOM 从文档树里移除,媒体元素的播放状态不会自动停止。

解法在上一章的onBeforeUnmount里已经给了,核心是三件事:暂停、清空src、调用load()。如果你是列表页里有很多个小播放器,还要保证同一时间只有一个在播,那就需要用一个全局状态或者事件总线来协调,播新的之前先把旧的停掉。

5.4 常见问题速查表

现象可能原因处理方式
时长显示 NaN元数据未加载完或服务端不支持 Range监听loadedmetadata,加Number.isFinite判断
点击播放无反应自动播放策略拦截,play()返回的 Promise 被 rejectcatch 后提示用户手动点击
拖动进度条后音频卡顿在@input里频繁 seek改为@input只更新 UI,@change才 seek
切歌后进度条闪回旧曲目的timeupdate写入新状态切歌前先pause,重置状态再加isDragging类似的锁
缓冲条来回跳读取了第一个buffered区间读最后一个区间buffered.end(length - 1)
组件销毁后仍有声音未释放媒体元素pause+removeAttribute('src')+load()
m3u8 在 Chrome 播放失败浏览器不支持原生 HLS引入hls.js,先判断canPlayType

这张表我建议你直接存下来,出问题的时候按行对照,能省掉不少调试时间。

6. 移动端适配与性能上的一些细节

PC 上跑通不代表移动端没问题。音频在移动端有几个特有的行为差异,处理不好会直接影响可用性。

6.1 iOS 与安卓的行为差异

iOS 上最大的差异是音量控制。你调用audio.volume = 0.5是无效的,系统会直接忽略,音量永远由物理按键控制。所以我的做法是在移动端直接隐藏音量滑块,避免用户拖了半天没反应。

另一个差异是 iOS 对preload的处理更保守。你设成auto,它也可能只加载元数据,这是在移动网络下省流量的策略,不属于 bug。如果你需要精确的波形图或者时长,最好在服务端把元数据提前返回给前端。

安卓这边的问题是各家浏览器内核差异大,尤其是国内的一些定制浏览器。我的经验是:尽量用标准的el-slider和el-button,少用 CSS 动画和transform做播放器的核心交互,因为某些旧内核下渲染会掉帧。

6.2 长列表音频的懒加载与内存控制

如果播放列表有几十上百条,全部提前new Audio()是灾难性的。我的做法是永远只保持一个audio实例,切歌时改src,这样内存占用是恒定的。

列表本身的渲染用el-scrollbar加虚拟滚动会更好,但如果你懒得引入虚拟列表,至少要做两件事:一是列表项不要绑定复杂的事件处理,用事件委托在父容器上处理点击;二是列表项的图标用条件渲染,当前播放项才渲染带动画的图标,其余用静态图标。

还有一个容易被忽略的点是hls.js实例的复用。如果你在列表模式下每条都用原生 HLS,那没问题;但如果用hls.js,一定要在切歌时destroy掉旧实例再建新的。我见过有项目切了十几次歌之后页面直接卡死,最后发现是十几个 hls 实例同时挂着,内存直接爆了。

最后分享一个小技巧:如果你的音频需要精确到毫秒级的进度回调(比如做逐字稿高亮),timeupdate事件的触发频率大约是每秒 4 次,精度不够。这时候可以用requestAnimationFrame自己轮询audio.currentTime,精度能到 16 毫秒左右。记得在暂停和组件卸载时取消循环,否则会一直空转消耗性能。这个改动很小,但在做朗读跟读类功能时,效果提升非常明显。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询