{T}

Howler.js 播放模式调试收尾、状态修复与控制栏显隐设计

概述

功能写完不代表播放器行为正确。这一节对前面完成的播放模式做系统调试:单曲循环模式下切歌按钮应失效;自然播放结束应通过 onend 驱动外层切歌;把 onload / onplay / onend 的默认回调兼容收敛成 runDefault();随机模式进入时初始化历史栈、离开时清空;切歌重建实例时要保留当前音量和静音状态,并修正 volume = 0|| 1 误判的边界 bug。

控制栏显隐的完整配置化是下一节的增强方向,本节只作为延伸设计思路保留。

学习目标

  • 把播放模式逐一对齐预期:单曲循环、列表循环、随机、切歌状态继承
  • 在切歌入口用 loop === 2 屏蔽单曲循环下的上一曲/下一曲
  • 把自然结束后的自动切歌挂到 onend,并继续 emit("next") 交外层决定
  • 用统一的 runDefault() 收口默认回调兼容,兼容用户自有 onend
  • 随机模式进入时压入当前索引、离开时清空历史栈
  • 重建实例时把 state.volume 回写新配置,并用 typeof undefined 而非 || 1 保留合法 0 音量

一、功能完成不等于行为正确

这一节主题不是继续写大量新功能,而是对已完成播放模式做系统调试。调试顺序很典型:单曲循环是否符合预期、列表循环能否正确回绕、随机切歌后历史栈是否正确、切歌后音量和静音状态是否保留。

媒体组件和普通表单组件不同,很多问题只有在「真实播放 + 切模式 + 切歌 + 拖进度 + 调音量」的组合场景里才暴露。这节的价值在于把前面零散能力做整体验证,把不符合播放器直觉的行为收敛掉。

code
功能开发完成 != 播放器行为正确

还需要:
  单曲循环调试
  列表循环调试
  随机模式调试
  切歌状态继承调试

媒体组件的 bug 往往出现在多状态组合之后,不会只在单一按钮点击时暴露;这类调试章节记录了「代码能跑」和「行为正确」之间的差异。

二、单曲循环下屏蔽切歌按钮

单曲循环模式下预期很明确:当前这一首播放到末尾时自动重新开始,上一曲/下一曲按钮不应把它切走。但之前实现里切歌按钮仍会继续触发切歌逻辑,打断单曲循环语义。修复时在切歌入口最前面加一层判断:如果当前 loop 模式索引等于 2,直接不做任何切歌操作。这是把单曲循环提升成高优先级约束条件。

ts
function handleChange(name: "preview" | "next") {
  if (loop.value === 2) return

  if (name === "preview") emit("preview")
  else emit("next")

  state.progress = 0
}

单曲循环不只是「结束后自动重播」,还意味着外部切歌按钮应失效;这类模式判断应放在切歌逻辑入口最前面,避免后续状态已被修改。

三、自然结束的自动切歌挂在 onend

之前只有手动上一曲/下一曲按钮,真正的播放器还要处理「当前歌曲自然播放结束,下一步自动做什么」。课程把这条链路挂到 onend——对媒体组件来说,「播放结束」本来就是实例回调而非 UI 事件,这是完全正确的入口。

这里没有把所有模式策略写死在播放器内部,而是先更简洁地透传:在 onend 里直接 emit("next"),实际播哪一首仍交给外层歌单页根据当前模式决定。

ts
function initAudio(options?: AudioPlayerOptions) {
  audioInstance.value = new Howl({
    ...options,
    onend() {
      runDefault("onend", props.options)
      emit("next")
    },
  })
}

自然结束后的切歌逻辑应放在实例回调层,不要放在 UI 按钮层;这里仍要兼容用户自己传的 onend 回调,不能直接覆盖。

四、用 runDefault 收口默认回调兼容

onloadonplay 的默认回调兼容逻辑已经开始重复,当 onend 也要加入同样包装时,继续复制粘贴不划算。课程把「从 props.options 取默认回调并执行」抽成 runDefault(),每个回调内部只需关心先执行用户逻辑、再执行组件自己的状态处理或事件透传。

ts
function runDefault(
  name: "onload" | "onplay" | "onend",
  options?: AudioPlayerOptions,
  ...args: unknown[]
) {
  const fn = options?.[name]
  if (typeof fn === "function") fn(...args)
}

最重要的是保留参数透传能力,比如 onplay(id) 里的 id;一旦回调包装开始重复就应及时收口。这种工具函数说明播放器封装已进入「整理重复模式」阶段。

五、随机模式历史栈与模式绑定

调试随机模式时发现更细的问题:仅仅在随机下一曲时 push 历史不够,当用户刚切到随机模式时若发生自然结束或切歌,历史栈可能没记录「当前这一首」。最终处理是:模式切到随机时先把当前歌曲索引推入 oldIndex,切到其他模式时直接清空 oldIndex。这让历史栈从「局部功能附属物」变成「模式状态的一部分」。

ts
function handleMode(index: number) {
  mode.value = index

  if (mode.value === 3) oldIndex.value.push(current.value)
  else oldIndex.value = []

  if (index === 2) {
    oldLoop.value = options.value.loop ?? false
    audioRef.value?.loop(true)
  } else {
    audioRef.value?.loop(oldLoop.value)
  }
}

随机模式的历史栈应跟模式本身绑定,而不只是跟某个按钮动作绑定;离开随机模式时清空栈是为了避免旧随机路径污染其他模式。这类「模式切换时顺带同步一批派生状态」是播放器状态管理很典型的场景。

六、重建实例时必须回写音量

用户把音量调小或静音,切歌后音量又恢复默认值。根本原因不是 volume() 方法有问题,而是切歌触发实例重建,新实例初始化用的是新 options,而 options 里没把当前 state.volume 写回去,新实例当然按默认值启动。这揭示实例重建型组件的重要原则:所有需要跨实例继承的状态都必须显式写回新配置。

ts
watch(() => props.options, (newOptions) => {
  const options = Object.assign({}, newOptions, {
    volume: state.volume,
  })

  audioInstance.value?.unload()
  initAudio(options)
}, { deep: true })

只靠 state.volume 存在于响应式对象里不会自动影响新实例;任何实例重建都会重新读取初始化配置,跨实例状态必须显式回写。

七、用 typeof undefined 保住 0 音量

最后修了一个典型 JavaScript 边界 bug:之前写法大致是 options.volume || 1,会把合法的 0 也判成「没有值」,最终静音状态切歌后又恢复成 1。正确写法不是判断真假,而是判断 typeof options.volume === "undefined"——只有真的没传时才回退到 1,用户当前音量就是 0 则应被保留。

ts
state.volume = typeof options.volume === "undefined" ? 1 : Number(options.volume)

0、空字符串、false 这类合法 falsy 值都不能直接用 || 判断兜底;对播放器来说,0 音量是明确且合法的状态。修完后静音切歌、恢复音量、再次切歌整条链路才真正稳定。


常见问题

问题原因解决方案
单曲循环下点切换还会切歌切歌入口没先做模式保护handleChange() 最前面判断 loop.value === 2 直接返回
播放结束不自动进下一首只处理手动切歌没处理自然结束onend()emit("next"),交外层列表策略
新增 onend 后用户结束回调不执行组件内部又覆盖用户回调runDefault() 先执行用户回调再跑组件逻辑
随机切换后上一曲历史不对历史栈没在进入随机模式时初始化切到随机时先压入当前索引,离开时清空栈
切歌后音量恢复默认值重建时新 options 没带当前 state.volume重建前用 Object.assign()volume 回写新配置
静音切歌后又自动恢复成 1用了 `options.volume
单曲循环改 options.loop 导致整首重置触发实例重建改为直接调用实例方法 audioRef.value?.loop(true)

延伸阅读