{T}

Howler.js 进度条组件封装、音量控制与播放模式切换

概述

上一节已经把进度条的交互闭环写完整了:点击跳转、拖拽控制、鼠标与触摸兼容、尺寸变化时的位置同步。当一套交互逻辑具备自洽性且存在复用价值时,就不该继续堆在 AudioPlayer 内部,而应当及时抽成独立组件。

本节做的是典型的「局部逻辑组件化」:把进度条抽成 ProgressBar.vue,对外用 defineModel() 暴露 0 ~ 1 的百分比语义,再用同一个组件复用出音量控制条,并补齐静音、倍率、播放模式这三类控制区的状态闭环。重点不在于多加几个按钮,而在于把原本写死在播放器内部的交互重新梳理成可复用组件与可维护状态。

学习目标

  • 判断进度条逻辑何时该抽成独立组件,而非继续塞在业务组件里
  • defineModel() 对外暴露百分比语义,而不是像素偏移
  • 用「单向 watch(modelValue) + 内部显式同步」规避双向绑定死循环
  • 用纯 CSS 的 group-hover 实现 video.js 风格的音量悬浮展开
  • useToggle() 先把静音状态闭环跑通,再接真实音量逻辑
  • 用「索引循环器」统一实现倍率与播放模式的循环切换,并处理动态图标类名的工程约束

一、什么时候该把进度条抽成组件

上一节的进度条已经具备完整交互闭环:点击跳转、拖拽控制、鼠标和触摸兼容、尺寸变化时的位置同步。一旦这些能力成立,它就不再是「音频播放器内部的一段样式」,而是一个可以复用的基础交互组件——后续既能控制播放进度,也能控制音量,还可以继续给别的媒体类组件复用。

抽组件的时机,通常是「交互逻辑自洽且具备复用价值」的时候。这里的落点是 components/slide/ProgressBar.vue,属于一次典型的局部逻辑组件化。组件拆分不是为了目录好看,而是为了减少单文件复杂度和重复实现。抽离之后,AudioPlayer 的职责更聚焦在播放器状态管理上。

Vue SFC
<!-- AudioPlayer.vue -->
<ProgressBar v-model="progress" />

二、对外暴露百分比,而不是像素值

组件抽出来后,最重要的设计问题是:内部用什么状态、对外暴露什么值。课程结论是:内部仍然保留 left 这类像素值方便控制轨道宽度和控制柄位置;对外则暴露 modelValue,且这个值是 0 ~ 1 的百分比。

百分比语义有几个明显好处:和容器宽度解耦、更适合外层业务状态表达、更容易映射到真实播放进度 / 音量值 / 缓冲比例等场景。因此 defineModel() 的作用不只是少写模板代码,而是在明确组件对外契约。

ts
const left = ref(0)
const progressWidth = ref(0)
const modelValue = defineModel<number>({ default: 0 })

function syncModelFromLeft(newLeft: number) {
  if (!progressWidth.value) return
  modelValue.value = Math.min(Math.max(newLeft / progressWidth.value, 0), 1)
}

三、规避双向绑定的互 watch 死循环

最容易踩的坑是写成两个互相 watch:watch(left) 时更新 modelValuewatch(modelValue) 时再更新 left。乍看是双向绑定,实际很容易互相触发、状态循环,且两边同时变化时无法保证优先级。

加一个 flag 锁看似规避,但一旦 leftmodelValue 同时参与更新,就变成碰运气。更合理的做法是「写入口收敛」:

  • 保留 watch(modelValue),负责外部值变化时反算内部 left
  • 不再 watch(left) 去反推 modelValue
  • 在所有真正改写 left 的落点上,显式同步 modelValue
ts
watch(modelValue, (newValue) => {
  left.value = newValue * progressWidth.value
})

function updatePosition(clientX: number, startX: number, originLeft = 0) {
  if (!progressRef.value) return
  const maxWidth = progressRef.value.offsetWidth
  const newLeft = Math.min(Math.max(originLeft + clientX - startX, 0), maxWidth)

  left.value = newLeft
  if (progressWidth.value)
    modelValue.value = Math.min(Math.max(newLeft / progressWidth.value, 0), 1)
}

真正改进的是状态同步策略:说清楚「谁负责驱动谁」,而不是少写一个 watch()

四、用同一个 ProgressBar 复用音量控制

进度条抽出来后,最直接的复用场景是右侧音量控制。这里视觉交互先行、逻辑后补,目标是模仿 video.js 那种效果:平时只显示音量图标,鼠标移入时旁边滑出音量条,移出时再收回。

课程强调这是纯 CSS 效果,不依赖额外 JS。关键做法是:外层包容器加 groupProgressBar 默认 invisible + w-0group-hover 时切换为可见并设置宽度。注意隐藏态要把外边距也一起收回,否则会留下空白。

Vue SFC
<div class="group flex items-center">
  <button type="button" @click="toggleMute()">音量</button>
  <ProgressBar
    v-model="volumeValue"
    class="invisible mx-0 w-0 transition-all duration-200 group-hover:visible group-hover:mx-2 group-hover:w-[80px] sm:group-hover:w-[120px]"
  />
</div>

五、静音按钮先跑通状态闭环

音量区除了滑块还有一个静音图标。课程没有马上把它接到真实音量值,而是先做一个更小的交互闭环:点击图标 → isMute 切换 → 图标跟着切换。这是合理的渐进式写法,先把交互状态跑通,再把状态映射到真实音量逻辑,调试成本会低很多。

ts
const [isMute, toggleMute] = useToggle(false)
Vue SFC
<button type="button" @click="toggleMute()">
  <i v-if="isMute">静音图标</i>
  <i v-else>音量图标</i>
</button>

后面接 Howler 的真实 mute() 时,这个 isMute 最终要和引擎状态统一;此刻不必过早把滑块值、静音状态、真实音量三者耦在一起。

六、倍率与播放模式都用「索引循环器」

倍率控制不一定非得下拉菜单。音频播放器往往更窄,再挂弹层会有位置难看、超出边界、小屏难处理等问题。更稳的做法是固定宽度加点击循环切换:1x → 1.5x → 2x → 0.5x → 1x。同时要给倍率文本区域固定最小宽度并居中,避免从 1x 切到 1.5x 时把右边图标顶得来回跳。

ts
const rateList = [1, 1.5, 2, 0.5]
const rateCurrent = ref(0)

function handleRateChange() {
  rateCurrent.value++
  if (rateCurrent.value >= rateList.length) rateCurrent.value = 0
}

播放模式切换与之本质相同,都是「状态索引循环 + 视图映射」,只是这里因为图标不能动态拼接,改用条件渲染。循环模式通常有四种:顺序播放、单曲循环、列表循环、随机播放。

ts
const loop = ref(0)

function handleLoopChange() {
  loop.value++
  if (loop.value >= 4) loop.value = 0
}
Vue SFC
<span class="flex items-center" @click="handleLoopChange">
  <i v-if="loop === 0">顺序图标</i>
  <i v-else-if="loop === 1">单曲图标</i>
  <i v-else-if="loop === 2">列表循环图标</i>
  <i v-else>随机图标</i>
</span>

这里有个工程化约束:在 UnoCSS 这类基于静态扫描的方案里,动态拼接 icon 类名并不可靠。所以把候选图标静态写出来再用 v-if 条件切换,少一点优雅感,换来更确定的产物输出。


常见问题

问题原因解决方案
进度条抽成组件后为何不直接暴露 leftleft 是组件内部像素状态,不适合做对外契约defineModel() 暴露 0 ~ 1 百分比值
双向绑定为何不能简单写两个 watchleftmodelValue 互相 watch 很容易循环触发只保留「外部值 → 内部状态」的 watch,内部变更点显式同步 modelValue
音量条隐藏后为何还留空白隐藏时只收了宽度,没把外边距一起收回默认态同步设置 w-0 + mx-0,悬浮时再恢复宽度和间距
静音按钮为何先不直接改真实音量一开始把图标、状态、音量值耦死会提高调试成本先跑通 isMute 状态切换,再接引擎逻辑
倍率切换时右边图标为何抖动文本宽度从 1x1.5x 后撑开布局给倍率文本固定或最小宽度并居中
循环模式为何不用动态 icon 类名UnoCSS 这类扫描型方案里动态拼接类名不稳定候选图标静态写出,再用条件渲染切换
进度条刚抽出来就拿去控制音量是否太早只有真正复用过一次才能验证组件边界是否合理正好用音量条场景验证 ProgressBar 的通用性

延伸阅读