尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Vue3 + Element Plus 音频组件封装:波形与 HLS 适配

Vue3 + Element Plus 音频组件封装:波形与 HLS 适配 Element Plus 的组件清单里图片有 el-image卡片有 el-card上传有 el-upload唯独音频这一块是空白。前一阵接了个工单系统的活详情页要内嵌一段客服通话录音功能听起来朴素得不能再朴素播放、暂停、拖进度、切倍速最好再带个简单的波形。我第一反应是去官方文档里翻确认没有现成的音频组件之后就明白这事得自己封一个——而且要封成能复用的那种因为同一个项目里后面还有三处要用。这篇就把我从零到一封装 Vue Element 音频组件的完整过程摊开讲包括 HTMLAudioElement 的真实能力边界、进度条拖拽冲突怎么解、波形为什么接上 Web Audio 之后没声音、m3u8 流在 Chrome 里为什么直接播不了以及上线前被测试提了一堆单之后才补上的那些兜底逻辑。适合正在用 Vue 3 和 Element Plus 做中后台、又恰好躲不开音频需求的同学纯新手也能跟着把代码跑起来。1. Element Plus 的组件边界以及音频组件为什么得自己动手1.1 从一份只要播放和进度条的需求说起先把需求拆干净。工单详情页里的录音用户在意的其实只有四件事这段录音多长、现在放到哪了、能不能跳到某一句、听完能不能自动播下一段。至于均衡器、歌词滚动、3D 音效跟后台系统一点关系都没有。所以组件的第一版范围就划得很死——播放控制、进度条、时间显示、倍速、音量、播放列表波形作为可选项挂在后面。问题在于Element Plus 的 el-slider、el-button、el-dropdown 全都可以直接拿来当骨架唯独那个真正干活的audio标签需要自己管。它不是一个渲染出来就完事的组件而是一个有内部状态、有异步加载、有事件流、还带各种浏览器差异的状态机。很多同学第一次写音频组件的做法是audio src/a.mp3 controls/audio行得通但很快就会撞墙。原生 controls 的样式各家浏览器自己定Chrome 是一套、Safari 是另一套放进 Element Plus 的卡片里像贴了张补丁更要命的是它没法跟播放列表联动也没法在切歌的时候自动接管状态。所以真实项目里绝大多数情况都得走自研这条路。1.2 三条实现路线的横向对比我把当时评估的三条路线整理成了一张表供你快速判断自己该走哪条维度原生 audio controls第三方播放器库aplayer / plyr / howler自研组件起步成本几乎为零中等需要读文档对齐 API偏高但一次投入长期复用样式可控性差各浏览器独立渲染中属于库自己的一套视觉体系完全可控跟 Element 主题一脉相承播放列表需要自己在外面写大多自带自己实现逻辑透明波形/频谱不支持部分支持按需接入 Web Audio流式音频各浏览器差异大依赖库的封装自己控 hls.js降级路径清晰与 Element 主题一致不可能难得大幅覆写天然一致aplayer 是一款很成熟的音乐播放器视觉上偏音乐 App圆角、渐变、封面旋转搬进后台管理系统会显得很跳plyr 是通用媒体播放器视频音频通吃但它也自带一套皮肤你要把它压成 Element 的样子覆写的 CSS 比自研还多。howler.js 是个好选择它把 AudioContext、精灵图音频、空间音效这些都封装好了但它只解决播的问题不管长什么样。所以我最后的结论是UI 层用 Element Plus播放内核自己写遇到特别复杂的音效需求再引 howler 做底层补充。1.3 先把对外契约定下来再动手写这是我在这个项目里最大的一个心得先写 props 和 expose别急着敲 template。因为组件一旦被三个页面复用接口设计的随意性会变成灾难。我最终定下来的 props 是这几个const props defineProps({ src: { type: String, default: }, // 单曲模式下的音频地址 playlist: { type: Array, default: () [] },// 播放列表元素形如 { name, artist, src } autoplay: { type: Boolean, default: false }, preload: { type: String, default: metadata }, mode: { type: String, default: order }, // order | list-loop | single | random rate: { type: Number, default: 1 }, mini: { type: Boolean, default: false }, // 迷你模式用于列表内嵌 showWave: { type: Boolean, default: false } })preload默认给metadata而不是auto是个刻意的选择。一个工单列表页可能有几十条录音如果每条都auto浏览器会真的去拉音频数据几十个并发请求下去带宽和内存都不好看。metadata只把时长、采样率这些头部信息读回来进度条就能正常显示总时长体验和开销之间这个点最平衡。对外暴露的方法则统一成命令式风格这样父组件可以拿着 ref 直接调用defineExpose({ play, pause, toggle, seek, playAt, setVolume })提示不要把暴露的方法设计成每个功能一个开关 prop。比如playing、currentTime这类持续变化的值如果做成 props父组件就得双向绑定等于把播放器的内部状态摊给外部管理两个组件会互相打架。状态放内部动作走 expose这条线要划清楚。2. HTMLAudioElement 的真实能力边界2.1 new Audio() 还是模板里写 audio 标签两种写法都能播声音。new Audio(url)创建出来的是一个游离于 DOM 之外的 HTMLAudioElement代码看起来更纯 JS。但我在这个项目里选了模板里写audio refaudioRef hidden原因有三个。第一个是 iOS 的脾气。游离元素的播放行为在某些系统版本上不稳定尤其是从后台切回前台之后重新play()偶尔会失败而挂在 DOM 上的元素表现更可预期。第二个是 Web Audio 的接入。用createMediaElementSource接管音频输出的时候元素的生命周期最好跟组件生命周期对齐否则组件卸载了元素还被 AudioContext 引用着容易出内存问题。第三个是可读性——调试的时候打开 Elements 面板能直接看到src、currentTime这些属性比在控制台里console.log一个游离对象直观得多。audio refaudioRef :preloadpreload crossoriginanonymous loadedmetadataonLoadedMetadata durationchangeonDurationChange endedonEnded erroronError waitingonWaiting canplayonCanPlay playonPlay pauseonPause /注意这里我没有绑:src。原因在第七节会展开讲——只要涉及 m3u8src 就不能交给模板得在 JS 里动态决定是直接赋值还是交给 hls.js 处理。2.2 七个必须监听的事件以及它们的触发时机事件是音频组件的心脏。用错了事件会出现进度条卡住按钮状态和实际播放不一致切歌之后声音还在响这类玄学问题。我整理了一份实际用到的清单事件触发时机用它做什么loadedmetadata元数据时长、尺寸加载完成读取duration初始化进度条上限durationchange时长发生变化含直播流更新显示的时长timeupdate播放位置变化约每 250ms 一次兜底更新进度但不是首选canplay当前数据够开始播放关掉 loading 态waiting播放中数据不足开始缓冲打开 loading 态ended播放到结尾触发切歌逻辑error加载或解码出错提示用户跳到下一首或停下timeupdate的频率是浏览器自己定的规范建议是 250ms 左右但实际跑起来在不同机器上能差出一倍。像素级的进度条如果只靠它驱动会明显发涩视觉上像一格一格跳。所以我的做法是timeupdate只作为兜底真正的进度刷新用一个requestAnimationFrame循环来做。waiting这个事件特别容易被忽略。它在你拖动进度条跳到一段还没缓存的区间时会触发此时如果界面上没有反馈用户会以为卡死了然后疯狂点播放键。加一个 200ms 的延迟防抖再显示一个小的加载指示器体感立刻不一样。2.3 duration 是 NaN 和 Infinity 的那几种情况audio.duration在元数据加载完成前是NaN在直播流场景下可能是Infinity。这两种值如果不处理直接喂给 el-slider 的max组件要么报错要么行为诡异。处理方式我写成了一个计算属性const safeDuration computed(() { const d duration.value if (!d || !Number.isFinite(d)) return 0 return d })直播流的情况下进度条本身就没有意义我直接把滑块换成直播中的标记。还有一个小坑设置currentTime必须等元数据就绪也就是readyState 1否则这次赋值会被浏览器静默忽略代码不报错但进度条跳不过去。所以seek方法里得加一道判断function seek(seconds) { const el audioRef.value if (!el || el.readyState 1) return el.currentTime Math.min(seconds, safeDuration.value || seconds) }3. 组件内部状态怎么划props、ref 与 expose3.1 哪些东西该进 props哪些坚决不能进一句话原则只有使用者希望从外部控制的东西才进 props反映播放器自己发生了什么的一律不进。src进 props因为父组件决定了播什么。autoplay进 props因为是否自动播放是使用场景决定的。但currentTime、isPlaying、duration这些绝对不能进。想象一下父组件用v-model:currentTime绑定播放器每 16ms 往父组件推一次值父组件再往回传形成回路稍有不慎就是无限更新调试起来极其痛苦。父组件需要知道播放进度怎么办走emit。我对外发这几个事件const emit defineEmits([update:index, play, pause, ended, timeupdate, error])timeupdate我做了一层节流每 500ms 往外发一次够用来做播放进度上报听满 30 秒算完成这类业务逻辑又不至于把父组件刷爆。3.2 播放状态用 ref 而不是 reactive内部状态我拆成了几组const isPlaying ref(false) const isLoading ref(false) const currentTime ref(0) const duration ref(0) const bufferedEnd ref(0) const volume ref(loadVolume()) const muted ref(false) const currentIndex ref(0) const isDragging ref(false) const dragValue ref(0)用ref而不是塞进一个reactive大对象理由很实际这个组件里同一时刻变化的东西是局部的。播放的时候只有currentTime和bufferedEnd在动volume、currentIndex都是静止的。用一堆独立refVue 的响应式追踪范围小重渲染成本低而一个大reactive对象任何一个字段变化都可能让整个组件重新跑一遍。在低频场景下这点差异看不出来但currentTime是每帧都在变的这时候差距就很明显了。另外isDragging和dragValue是专门为进度条准备的下一节详细说。3.3 expose 出去的接口要可组合我把暴露的方法控制在五个以内每个都保证幂等、可重复调用async function play() { const el audioRef.value if (!el) return try { await el.play() } catch (err) { if (err.name NotAllowedError) { emit(error, new Error(需要用户交互后才能播放)) } else { emit(error, err) } } } function pause() { audioRef.value?.pause() } function toggle() { isPlaying.value ? pause() : play() }play()返回的是 Promise这一点很多人会忘。浏览器在没有用户手势的情况下调用play()会返回一个被拒绝的 Promise同时控制台打印一个警告。如果你不catch代码不报错但什么都不发生排查起来非常费劲。所以这里必须包try/catch并把需要用户手势这个信息翻译成能看懂的中文提示。setVolume我加了一个平滑过渡直接赋值会让音量瞬间跳变听感上有啪的一下。做一个 100ms 内从当前值线性过渡到目标值的小动画体验会好很多——尤其是点击静音键的时候。4. 播放列表与切歌最容易写乱的那一段4.1 列表数据结构与当前索引的维护播放列表的数据结构我定成这样// [{ id, name, artist, src, cover, type }]type字段用来标记这首是不是 m3u8 流切歌的时候根据它决定走哪条加载路径。真正容易写乱的是当前索引的维护。我的做法是只允许一个地方修改currentIndex也就是一个统一的switchTo(index)函数。所有切歌入口——点击列表项、播放结束、点上一首、点下一首、随机模式——全部调它。function switchTo(index, autoplay true) { if (!props.playlist.length) return const total props.playlist.length const next ((index % total) total) % total // 负数也能正确回绕 if (next currentIndex.value isPlaying.value) return releaseCurrent() currentIndex.value next loadSource(props.playlist[next]) if (autoplay) play() emit(update:index, next) }那个((index % total) total) % total是处理上一首的经典写法。JavaScript 里-1 % 5等于-1不是4直接用会取到playlist[-1]报undefined。这个坑我踩过当时现象是点上一首直接没声音了控制台还不报错找了半小时。4.2 四种播放模式的实现差异模式分四种实现上的差异集中在ended事件里function onEnded() { emit(ended) switch (props.mode) { case single: seek(0) play() break case random: switchTo(pickRandomIndex()) break case list-loop: switchTo(currentIndex.value 1) break default: // order if (currentIndex.value props.playlist.length - 1) { switchTo(currentIndex.value 1) } else { isPlaying.value false } } }随机模式这里有个细节值得说直接用Math.floor(Math.random() * total)会有连续两次随机到同一首的体感问题用户会觉得随机坏了。我做的是先排除当前索引再随机function pickRandomIndex() { const total props.playlist.length if (total 1) return 0 let idx currentIndex.value while (idx currentIndex.value) { idx Math.floor(Math.random() * total) } return idx }再讲究一点可以维护一个已播随机序列一轮播完再重新洗牌这样同一首歌不会短时间内重复出现。后台系统的录音回放场景下这个优化意义不大我就没做。4.3 切歌时的资源释放动作这是我认为整个组件里最容易被忽略、也最容易出事的地方。切歌的时候如果不把旧的资源放掉会出现这些现象旧音频还在后台继续加载、内存持续上涨、某些浏览器里新旧两段声音叠在一起。正确的释放顺序是function releaseCurrent() { const el audioRef.value if (!el) return el.pause() el.removeAttribute(src) el.load() // 触发资源中止这一步不能省 if (hls) { hls.destroy() hls null } }removeAttribute(src)加load()是一对组合拳。只把src设成空字符串在某些浏览器里会触发一次error事件日志里一片红而直接赋新src覆盖旧值旧的那条请求不会立刻中止。先摘掉再load()等于明确告诉浏览器这个元素现在没源了把在跑的活都停掉。注意load()会重置currentTime和duration所以调用顺序必须是释放旧资源 → 设置新源 → 更新 UI不能反过来。我有一次把 UI 更新写在前面结果进度条先被重置到 0又被旧数据刷了一下视觉上闪了两下测试直接提了单。5. 进度条与音量条el-slider 二次封装的两个关键点5.1 拖动冲突timeupdate 和用户拖拽在抢同一个值el-slider 的v-model是个双向绑定问题就出在这。用户正在拖拽的时候currentTime每帧还在更新两者抢着写同一个值结果就是你手一松滑块啪地弹回播放位置或者拖动过程中滑块跟你的手指打架。解法是引入一个拖拽中的状态位让数据源在拖拽期间切换el-slider v-modelsliderValue :maxsafeDuration || 1 :step0.1 :show-tooltipfalse :disabled!safeDuration inputonSliderInput changeonSliderChange /const dragValue ref(0) const sliderValue computed({ get() { return isDragging.value ? dragValue.value : currentTime.value }, set(v) { // 拖拽期间只更新本地值不回写 audio if (isDragging.value) dragValue.value v } }) function onSliderInput(v) { isDragging.value true dragValue.value v } function onSliderChange(v) { isDragging.value false seek(v) }这里有个关键理解el-slider 的input在拖拽过程中持续触发change只在松手时触发一次。所以input用来标记我在拖change才真正做 seek。用computed的 getter 切换数据源比手动同步两套变量的代码干净得多。5.2 用 rAF 替代 timeupdate 做平滑进度前面提过timeupdate的频率不够。我的做法是只在播放期间开一个requestAnimationFrame循环let rafId 0 function startTick() { cancelAnimationFrame(rafId) const step () { const el audioRef.value if (el !isDragging.value) { currentTime.value el.currentTime if (el.buffered.length) { bufferedEnd.value el.buffered.end(el.buffered.length - 1) } } rafId requestAnimationFrame(step) } rafId requestAnimationFrame(step) } function stopTick() { cancelAnimationFrame(rafId) rafId 0 }onPlay里startTick()onPause和onEnded里stopTick()。暂停时循环一定要停否则页面切到后台还在空转纯属浪费电。el.buffered是个TimeRanges对象不是数组不能直接forEach。取最后一段的终点用el.buffered.end(el.buffered.length - 1)这就是网上常见的二次缓冲条效果的数据来源。用一层半透明的 div 盖在 el-slider 的轨道上宽度按bufferedEnd / duration算就行。5.3 音量记忆与被 iOS 吃掉的那行代码音量值我做了本地持久化用户调过之后下次进页面还是这个音量const VOLUME_KEY audio_player_volume function loadVolume() { const v Number(localStorage.getItem(VOLUME_KEY)) return Number.isFinite(v) v 0 v 1 ? v : 0.8 } watch(volume, (v) { const el audioRef.value if (el) el.volume v localStorage.setItem(VOLUME_KEY, String(v)) })这里必须提醒一句iOS Safari 上audio.volume是只读的你赋值不报错但完全没效果。这是系统层面的限制不是 bug。所以在移动端想做音量控制只能走 Web Audio 的 GainNode或者在 UI 上干脆把音量条隐藏掉——毕竟 iOS 的音量本来就是物理按键管的用户也不会期待页面里有音量条。这个坑当时让我们白排查了一下午一直以为是代码写错了。顺带说一句倍速。playbackRate在大多数浏览器上会同时变调变调听起来像快进的录音很滑稽。要还原音调得设el.preservesPitch true el.webkitPreservesPitch true // 兼容老版本工单录音用 1.5 倍速听的时候这个设置能救命不然人声全变成花栗鼠。6. 把音频接进 Web Audio API 做波形和频谱6.1 MediaElementSource 的接线顺序波形和频谱的原理不复杂把音频元素接进AudioContext挂一个AnalyserNode然后定时去读频率数据用 canvas 画出来。let audioCtx null let analyser null let sourceNode null function initAnalyser(el) { if (audioCtx) return audioCtx new (window.AudioContext || window.webkitAudioContext)() sourceNode audioCtx.createMediaElementSource(el) analyser audioCtx.createAnalyser() analyser.fftSize 256 analyser.smoothingTimeConstant 0.8 sourceNode.connect(analyser) analyser.connect(audioCtx.destination) // 这一行漏了就没声音 }analyser.connect(audioCtx.destination)这行是新手最容易漏的。createMediaElementSource一旦调用音频元素的输出就被接管了不再直接送到扬声器必须由你手动接回 destination。忘了接现象就是进度条在走、isPlaying是 true、但一点声音都没有。我第一次遇到时以为是音频文件坏了。另外同一个audio元素上createMediaElementSource只能调用一次。第二次调用会抛InvalidStateError。所以必须用一个标志位或者缓存住audioCtx组件重复挂载的时候别重复创建。更好的做法是把AudioContext做成模块级单例全局共享一个因为浏览器对同时存在的 AudioContext 数量是有限制的Chrome 大概是 6 个开太多会直接创建失败。还有一点AudioContext默认是suspended状态得在用户点击播放的那一刻调audioCtx.resume()才能出声。这个和自动播放策略是同一个根源。6.2 canvas 绘制频谱时的帧率控制绘制本身没什么难度getByteFrequencyData拿到一个 0-255 的数组按柱状图铺开就行。真正要注意的是帧率和性能的取舍。function draw() { if (!analyser) return const data new Uint8Array(analyser.frequencyBinCount) analyser.getByteFrequencyData(data) const w canvas.width const h canvas.height ctx.clearRect(0, 0, w, h) const barW w / data.length for (let i 0; i data.length; i) { const barH (data[i] / 255) * h ctx.fillStyle #409eff ctx.fillRect(i * barW, h - barH, barW - 1, barH) } drawRaf requestAnimationFrame(draw) }两个优化点。第一fftSize不要开太大。256 对应 128 个频点画出来已经足够好看开到 2048 是 1024 个频点画 1024 根柱子人眼根本分辨不出来但 CPU 占用会翻好几倍。第二Uint8Array要在循环外创建一次复用每次new会带来持续的 GC 压力。如果只做静态波形图比如把整首歌的振幅轮廓画出来当封面那就不需要 Analyser而是用decodeAudioData把整个文件解成 PCM 数据按块取峰值。这种方式需要先完整下载文件适合短录音不适合长音频。6.3 跨域导致频谱全是零的排查这是我踩过最隐蔽的一个坑。本地开发时波形正常一上线波形就变成一条直线但声音照常播放。排查过程是这样的第一步确认analyser不为空getByteFrequencyData返回的数组确实是全 0。第二步怀疑是不是resume()没调用加了日志确认 AudioContext 是 running 状态。第三步把音频文件换成本地静态资源波形正常了——问题锁定在跨域上。原因在于音频是从 CDN 域名加载的没有 CORS 头浏览器就把它标记为污染tainted的媒体资源。这种资源可以播放但 Web Audio 拿不到数据为了安全只能返回全零。解决方法是两件事同时做audio标签上必须加crossoriginanonymous而且必须在设置src之前就加上。属性加晚了不算数。服务端必须返回Access-Control-Allow-Origin头而且不能是通配符加凭证的组合。audio refaudioRef crossoriginanonymous /顺序这件事特别关键。我一开始是先赋src再补属性测了半天没效果后来才发现时机不对。提示如果音频源不在你的控制范围内、加不了 CORS 头那就只能放弃实时频谱退化成伪波形——用一个随机生成的静态柱状图加播放时的透明度动画视觉上够用用户根本看不出来。7. 流式音频的适配m3u8 在 audio 标签上的现实情况7.1 为什么 Safari 能播、Chrome 不能m3u8 本质是一个 HLS 的播放列表文件里面是一堆分片地址。浏览器要播它得先把列表解析出来再按顺序拉分片、拼成连续的媒体流。Safari 从很早的版本就原生支持 HLS因为 HLS 是苹果自己推的协议。所以在 Safari 里audio srcxxx.m3u8直接就能播。但 Chrome、Firefox、Edge 这些基于 Blink/Gecko 的浏览器不支持它们拿到 m3u8 文件只会当成一个文本文件loadedmetadata永远不触发duration一直是 NaN。判断方式很简单function canPlayNativeHls(el) { return !!el.canPlayType(application/vnd.apple.mpegurl) }Safari 返回maybeChrome 返回空字符串。7.2 hls.js 接入与降级判断Chrome 阵营要播 HLS就得引 hls.js。它通过 MediaSource Extensions 把分片拼成一条连续流再喂给媒体元素对audio同样适用。import Hls from hls.js let hls null function loadSource(item) { const el audioRef.value if (!el) return const url item.src const isStream /\.m3u8(\?|$)/i.test(url) releaseCurrent() if (isStream) { if (canPlayNativeHls(el)) { el.src url } else if (Hls.isSupported()) { hls new Hls({ enableWorker: true, lowLatencyMode: false, maxBufferLength: 30 }) hls.loadSource(url) hls.attachMedia(el) hls.on(Hls.Events.ERROR, onHlsError) } else { emit(error, new Error(当前浏览器不支持流式音频播放)) } } else { el.src url } el.load() }Hls.Events.ERROR的处理有讲究。hls.js 的错误分两类fatal和non-fatal。网络抖动导致的non-fatal错误它会自己重试你只要记录日志就行fatal才需要处理而且不同类型处理方式不同function onHlsError(_, data) { if (!data.fatal) return switch (data.type) { case Hls.ErrorTypes.NETWORK_ERROR: hls.startLoad() // 网络错误重启加载 break case Hls.ErrorTypes.MEDIA_ERROR: hls.recoverMediaError() // 媒体错误尝试恢复 break default: hls.destroy() hls null emit(error, new Error(音频流播放失败)) } }不加区分的做法是一有错误就destroy结果是网络稍微抖一下播放就断了用户体验很差。7.3 直播流和点播流在进度条上的不同处理点播的 m3u8 有确定的时长duration正常进度条该显示什么显示什么。直播流不一样它的时长是Infinity而且会不断有新分片追加进来。对直播流我的处理是隐藏进度条和时间显示换成一个红色的直播中标记。播放位置始终贴近缓冲末尾不做回退。用一个定时器检查如果落后超过阈值就seek到最新位置。不提供倍速和上一首下一首。const isLive computed(() !Number.isFinite(duration.value)) function syncLiveEdge() { const el audioRef.value if (!el || !isLive.value || !el.seekable.length) return const edge el.seekable.end(el.seekable.length - 1) if (edge - el.currentTime 5) { el.currentTime edge - 1 } }el.seekable和el.buffered是两个容易混的概念。buffered是已经下载到本地的区间seekable是允许跳转的区间。直播流里可跳转范围往往比已缓冲范围大所以要调整播放位置应该看seekable。8. 上线前踩过的坑与兜底方案8.1 自动播放策略与用户手势现代浏览器全都限制了自动播放autoplay属性只有在同时满足静音或者用户已经和页面交互过的情况下才生效。后台系统里用户点进详情页想看录音立刻播放这个诉求基本落不了地。我的做法是不硬碰改成两段式进页面时只preloadmetadata拿到时长界面上显示一个明显的播放按钮用户点一次之后在同一个会话里记住已授权之后切歌就可以自动播了。let userGestureUnlocked false function unlockOnce() { userGestureUnlocked true document.removeEventListener(click, unlockOnce) } document.addEventListener(click, unlockOnce, { once: true })另外audioCtx.resume()也必须在这个手势里调否则 Web Audio 那边一直是 suspended。8.2 多实例互斥工单列表页里可能同时存在十几个迷你播放器。如果不做互斥用户点了 A 又点 B两段录音会一起响非常尴尬。我的做法是在模块作用域维护一个当前正在播放的元素引用let currentPlayingEl null export function claimPlayback(el) { if (currentPlayingEl currentPlayingEl ! el) { currentPlayingEl.pause() } currentPlayingEl el }每个实例在onPlay里调一次claimPlayback(audioRef.value)。用模块级变量而不是全局 window 挂载是为了避免污染全局命名空间同时也方便将来改成多标签页共享的BroadcastChannel方案。这个抄起来很简单但漏了的后果挺严重——测试同学点两下列表就能提个单。8.3 卸载时的清理清单组件销毁时的清理我列了一份清单建议大家照抄清理项代码不清理的后果rAF 循环cancelAnimationFrame(rafId)页面切走后仍在空转音频播放el.pause()路由跳走了声音还在响媒体源removeAttribute(src)load()后台继续下载事件监听removeEventListener内存泄漏回调打到已销毁的实例上hls 实例hls.destroy()定时器和 worker 残留AudioContext视情况close()超过浏览器上限后无法再创建onBeforeUnmount(() { stopTick() const el audioRef.value if (el) { el.pause() el.removeAttribute(src) el.load() } if (hls) { hls.destroy() hls null } document.removeEventListener(click, unlockOnce) if (currentPlayingEl el) currentPlayingEl null })AudioContext 我建议只在组件确实用了频谱功能、且它是页面上唯一的使用者时才close()。如果做了模块级单例就千万别关——关了之后其他组件还在用会直接报错。这个判断得根据你的架构来别照抄。把上面这些串起来一个能扛住中后台实际使用的 Vue Element 音频组件基本就成型了。真正花时间的从来不是把声音放出来而是那些边角状态拖拽和更新打架、切歌时新旧资源交叠、iOS 上的只读音量、跨域导致的频谱归零、浏览器对自动播放的限制。这些细节没有一处能在文档里一次性查到基本都是一次次被测试单推着补出来的。我的建议是先用最小可跑版本把播放链路打通再把进度条和数据源的冲突处理干净最后才去接波形和流式播放——顺序反了调试成本会翻好几倍。
返回列表