新闻详情

新闻详情

首页 / 资讯中心 / 详情

Vue 3 + Element Plus 音频组件:支持 m3u8 与播放列表

发布时间:2026/9/30 5:44:05来源:尧图网络
Vue 3 + Element Plus 音频组件:支持 m3u8 与播放列表
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 与事件清单照着接口写不会乱我最终的接口设计大概是这个样子你在写之前也可以先照着列一遍能省掉后面很多返工。propssrc音频地址支持普通 mp3/wav也支持 m3u8 流地址autoplay是否自动播放默认false原因后面细说preload预加载策略none/metadata/auto默认metadataloop单曲循环list播放列表数组元素包含name、url、可选artistinitialVolume初始音量0 到 1 之间默认 0.8rates倍速可选项默认[0.5, 1, 1.25, 1.5, 2]compact紧凑模式用于列表内嵌的小播放器emitsplay/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标签只作为播放引擎存在给它加classhidden-audio隐藏掉所有交互都由我们自己画的控件处理。template div classaudio-player :class{ is-compact: compact } audio refaudioRef :srccurrentSrc :preloadpreload :looploop loadedmetadataonLoadedMetadata timeupdateonTimeUpdate progressonProgress endedonEnded erroronError waitingonWaiting canplayonCanPlay playonPlay pauseonPause / div classplayer-main el-button classplay-btn circle :typestatus playing ? primary : default clicktogglePlay el-icon v-ifisBufferingLoading //el-icon el-icon v-else-ifstatus playingVideoPause //el-icon el-icon v-elseVideoPlay //el-icon /el-button div classplayer-info div classtitle-row span classtrack-name{{ currentTrack.name || 未命名音频 }}/span span classtime{{ formatTime(currentTime) }} / {{ formatTime(duration) }}/span /div div classslider-wrap div classbuffered-bar :style{ width: bufferedPercent % } / el-slider v-modelcurrentTime :min0 :maxMath.max(duration, 1) :disabled!isDurationValid :show-tooltipfalse inputonSliderInput changeonSliderChange / /div /div div classplayer-tools el-popover placementtop :width60 triggerclick template #reference el-button textel-iconHeadset //el-icon/el-button /template el-slider v-modelvolume vertical height100px :min0 :max1 :step0.01 inputonVolumeChange / /el-popover el-dropdown commandonRateChange el-button text{{ playbackRate }}x/el-button template #dropdown el-dropdown-menu el-dropdown-item v-forr in rates :keyr :commandr{{ r }}x/el-dropdown-item /el-dropdown-menu /template /el-dropdown /div /div div v-iflist list.length classplaylist el-scrollbar max-height180px div v-for(item, index) in list :keyitem.url classplaylist-item :class{ active: index currentIndex } clickswitchTrack(index) el-icon classitem-icon VideoPlay v-ifindex currentIndex status playing / Headset v-else / /el-icon span classitem-name{{ item.name }}/span span v-ifitem.artist classitem-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只更新 UIchange才 seek切歌后进度条闪回旧曲目的timeupdate写入新状态切歌前先pause重置状态再加isDragging类似的锁缓冲条来回跳读取了第一个buffered区间读最后一个区间buffered.end(length - 1)组件销毁后仍有声音未释放媒体元素pauseremoveAttribute(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 毫秒左右。记得在暂停和组件卸载时取消循环否则会一直空转消耗性能。这个改动很小但在做朗读跟读类功能时效果提升非常明显。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

doocs/leetcode 插入排序(Insertion Sort)算法详解:动态有序插入思想与多语言实现 2026/9/30 6:39:31

doocs/leetcode 插入排序(Insertion Sort)算法详解:动态有序插入思想与多语言实现

示例工程教程 【免费下载链接】leetcode 🔥LeetCode solutions in any programming language | 多种编程语言实现 LeetCode、《剑指 Offer(第 2 版)》、《程序员面试金典(第 6 版)》题解 项目地址: https:/…

阅读更多 →
怎么在 Switch 上跑 wiliwili 看B站 2026/9/30 6:39:31

怎么在 Switch 上跑 wiliwili 看B站

怎么在 Switch 上跑 wiliwili 看B站 【免费下载链接】wiliwili 第三方B站客户端,目前可以运行在PC全平台、PSVita、PS4 、Xbox 和 Nintendo Switch上 项目地址: https://gitcode.com/GitHub_Trending/wi/wiliwili 游戏通关的空档想在 Switch 上刷两集B站&…

阅读更多 →
Flipper Zero Unleashed 固件 FAP 插件安装完整指南:3 条路径覆盖 SD 卡、fbt 与 WiFi 模块 2026/9/30 6:39:30

Flipper Zero Unleashed 固件 FAP 插件安装完整指南:3 条路径覆盖 SD 卡、fbt 与 WiFi 模块

Flipper Zero Unleashed 固件 FAP 插件安装完整指南:3 条路径覆盖 SD 卡、fbt 与 WiFi 模块 【免费下载链接】unleashed-firmware Flipper Zero Unleashed Firmware 项目地址: https://gitcode.com/GitHub_Trending/un/unleashed-firmware Flipper Zero Unle…

阅读更多 →
Claude 环境下的 MCP Server 鉴权完全指南:从 CIMD/DCR 选型到 Token 存储与 SDK 落地 2026/9/30 6:39:24

Claude 环境下的 MCP Server 鉴权完全指南:从 CIMD/DCR 选型到 Token 存储与 SDK 落地

AI 插件开发工具插件系统 【免费下载链接】claude-plugins-official Official, Anthropic-managed directory of high quality Claude Code Plugins. 项目地址: https://gitcode.com/GitHub_Trending/cl/claude-plugins-official 点击查看 免费下载 在 Claude 生态…

阅读更多 →
YuE2 白盒音乐生成指南:用 ABC 乐谱编辑 AI 生成的歌曲,改和弦、换歌词、变速度全都能做到 2026/9/30 6:39:24

YuE2 白盒音乐生成指南:用 ABC 乐谱编辑 AI 生成的歌曲,改和弦、换歌词、变速度全都能做到

YuE2 白盒音乐生成指南:用 ABC 乐谱编辑 AI 生成的歌曲,改和弦、换歌词、变速度全都能做到 【免费下载链接】YuE YuE2: frontier music generation with symbolic planning, zero-shot covers, and agentic music editing. 项目地址: https://gitcode.…

阅读更多 →
Liquibase 数据库变更管理快速上手指南:5 分钟跑通第一次数据库迁移 2026/9/30 6:39:24

Liquibase 数据库变更管理快速上手指南:5 分钟跑通第一次数据库迁移

Liquibase 数据库变更管理快速上手指南:5 分钟跑通第一次数据库迁移 【免费下载链接】liquibase Main Liquibase Source 项目地址: https://gitcode.com/GitHub_Trending/li/liquibase Liquibase 是一款广泛使用的开源数据库变更管理工具。它让数据库结构改动…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉