{T}

Howler.js 控制栏显隐配置、移动端适配与 useAudioPlayer 逻辑抽离

概述

主功能完成后,真正影响复用价值的是适配与可配置,而不是再加更多按钮。这一节做三件事收尾:补齐移动端控制栏(结构重排、样式重排、逻辑复用桌面端已完成的模式/倍率/快进快退);把控制栏显隐从写死模板改为 controls 数组配置驱动;把页面里越来越长的歌单与模式逻辑抽成 useAudioPlayer() 组合式函数。

这是把音频播放器从「课程示例」收束成「更接近真实组件库产物」的关键一步。

学习目标

  • 理解移动端适配本质是结构/样式重排加逻辑复用,而非另写一套事件系统
  • controls 数组让用户决定哪些控制项显示,借鉴 Pagination 的 layout 思路
  • hasName() 把模板里散落的 includes() 判断收敛成统一入口
  • some() 处理整组控制项的组合显隐,避免隐藏后留下空壳占位
  • withDefaults() 的工厂函数内联默认数组,规避 script setup 编译提升报错
  • 把歌单与模式逻辑抽成 useAudioPlayer(),让页面只保留列表数据与组件接线

一、收尾优化对应成熟组件的三个维度

这一节不是新增核心能力,而是明确三个优化方向:移动端控制栏适配、控制栏按钮显隐配置、外层歌单逻辑抽离。它们分别对应成熟组件的关键维度——能不能在不同终端正常使用、能不能按业务裁剪功能、能不能把页面逻辑沉淀成可复用能力。

code
播放器收尾优化 =
  移动端可用
  控制栏可配置
  页面逻辑可抽离

组件真正好不好用往往取决于这些收尾能力,而非主功能本身;「能跑」到「可复用」之间通常就差这一类适配与抽象工作。本节更偏工程化完善,而不是 API 堆叠。

二、移动端适配是结构重排而非另写逻辑

移动端适配重点不是重写一套逻辑,而是把桌面端已完成的模式切换、倍率切换、快进快退、时间显示搬到更紧凑的控制栏里继续可用。本质是结构重排、样式重排、逻辑复用——课程把桌面端按钮块按移动端布局复制过来,在移动端条件分支中复用同一套状态与事件方法。

这种做法虽不够「优雅抽象」,但在当前体量下务实。关键是移动端和桌面端逻辑应尽量复用,差异主要放在布局层;如果只复制 UI 结构却没复用事件方法,后续维护会很麻烦。课程最终验证的重点是移动端每个按钮都仍能驱动原有播放器逻辑。

三、用 controls 数组驱动控制栏显隐

这一节真正新增的组件能力是控制栏配置化。课程参考 Element Plus Pagination 的思路:不再写死哪些按钮一定出现,而是交给用户传入一个数组声明需要显示哪些控制项。不同业务对控制栏诉求差异很大——有的页面不需要快进快退,有的不需要音量,有的不需要倍率与模式。

课程整理出控制项枚举:preview / next / forward / rewind / volume / rate / mode,播放按钮和进度条保留为固定必显,其他按钮走配置数组判断。

ts
type ControlsType =
  | "preview"
  | "next"
  | "forward"
  | "rewind"
  | "volume"
  | "rate"
  | "mode"

interface AudioPlayerProps {
  controls?: ControlsType[]
}

播放按钮和进度条通常属于核心结构,不建议随意关闭;控制项应先有受控枚举再开放给用户传递。这一步做完,播放器就开始具备「不同业务页面复用同一组件」的能力。

四、用 hasName 收敛模板里的 includes 判断

课程没有在模板里反复写 props.controls?.includes("rate") 这类判断,而是先封装一个 hasName(name)

ts
const controls = props.controls ?? []

function hasName(name: ControlsType) {
  return controls.includes(name)
}

模板因此更像「配置驱动显示」:v-if="hasName('preview')"v-if="hasName('rate')"。这种小函数能让模板表达更清楚,后续控制栏规则变化时改 hasName() 比改一堆模板条件稳得多。对复杂组件来说,「小函数收口」是很重要的维护手段。

五、整组控制项隐藏时连带隐藏容器

接入 controls 配置后很快发现一个典型 UI 问题:某些控制项全部隐藏了,但外层 div 还在,页面留下一块空区域。这是配置化组件的常见副作用——单个元素显隐处理完,还要继续判断这一组是否还有任何可见子项。

Vue SFC
<div v-if="props.controls?.some(item => ['volume', 'rate', 'mode'].includes(item))">
  <button v-if="hasName('volume')" type="button">音量</button>
  <button v-if="hasName('rate')" type="button">倍率</button>
  <button v-if="hasName('mode')" type="button">模式</button>
</div>

配置化显示时,除了单个按钮条件,还要考虑整组容器的存在条件,否则容易出现「按钮没了但间距还在」的空壳布局。这类逻辑尤其容易在播放器、工具栏、分页条里出现。

六、withDefaults 默认数组用内联工厂函数

课程想给 controls 一组默认值,直接把上面定义的常量塞进 withDefaults() 结果报错——这类问题本质上和 script setup 编译提升有关。最终务实处理是:不再引用局部常量,直接把默认数组字面量内联进工厂函数。

ts
const props = withDefaults(defineProps<AudioPlayerProps>(), {
  controls: () => ["preview", "next", "forward", "rewind", "volume", "rate", "mode"],
})

数组和对象类型默认值建议都用工厂函数返回;script setup 下若默认值引用局部变量报错,直接内联通常最稳。这一步是组件配置化完整落地的重要补丁。

七、把歌单逻辑抽成 useAudioPlayer

页面里围绕歌单的逻辑(modeoldIndexgetRandomIndexhandleNexthandlePreviewhandleMode)越来越长,出现明显信号:逻辑长、和列表数据强耦合、又可独立复用。抽成组合式函数后,页面变得清晰——上面是 AudioPlayer,中间是 list,下面只保留接线。

ts
export function useAudioPlayer(list: AudioItem[]) {
  const current = ref(0)
  const mode = ref(0)
  const oldIndex = ref<number[]>([])

  function getRandomIndex(current: number, length: number) {
    let number = current
    do {
      number = Math.floor(Math.random() * length)
    } while (number === current)
    return number
  }

  function handleNext() {}
  function handlePreview() {}
  function handleMode(index: number) {}

  return { current, mode, handleMode, handleNext, handlePreview }
}

抽组合式函数的前提是逻辑自成体系且与页面模板解耦;useAudioPlayer() 当前接收的是列表值本身而非 ref,调用时要传 list.value,不要直接传整个响应式对象。


常见问题

问题原因解决方案
移动端样式适配了但逻辑没跟上只复制布局没复用原有控制方法移动端结构直接复用桌面端已完成模式/倍率/快进快退逻辑
某些按钮隐藏后右侧还留空白子元素隐藏但父容器还在给外层容器再加一层 some() 判断
controls 不传时页面什么都不显示没给 controls 设默认值withDefaults() 提供一组完整默认控制项
withDefaults() 里引用局部数组报错script setup 编译提升导致局部变量不可直接用作默认字面量直接把数组字面量写进默认值工厂函数
页面歌单逻辑越来越乱handleNextmode、随机策略都堆在页面抽成 useAudioPlayer() 组合式函数
useAudioPlayer() 调用后逻辑不生效传进去的是 listref 而函数按普通数组处理当前写法传 list.value,不要直接传响应式对象

延伸阅读