{T}

Howler.js 实例方法透传、回调兼容与外层列表联动前置

概述

前几节主要在补播放器内部交互,这一节出现明显转折:播放器开始考虑和外层列表、父组件怎么通信。它要做三件事——把卸载逻辑从全局级 Howler.unload() 收窄为实例级 unload();在初始化时兼容用户自己传入的 onload / onplay 回调;通过 defineExpose() 把实例常用方法受控透传出去并导出类型。

这一节本质上是给「外层列表联动」提前铺桥:播放器不再是封闭盒子,而是具备对外暴露能力、兼容自定义事件、保留清晰方法类型的可复用组件。完整歌单逻辑不在本节实现。

学习目标

  • 把组件卸载从全局级 Howler.unload() 改为实例级 unload(),避免误伤其他音频组件
  • 在初始化时先缓存用户传入的 onload / onplay 再继续执行,避免吞掉用户逻辑
  • defineExpose() 把实例核心方法以受控方式透传出去
  • 用方法名白名单批量生成代理函数,减少样板代码
  • 导出 AudioPlayerMethods 类型,让父组件通过 ref 获得方法补全
  • 理解这一节完成的「能力透传」是后续歌单联动的前置基础

一、组件封装后期,重点是明确边界而非多加按钮

这一节表面是「继续完成 audio player」,但真正的变化是视角从内部转向外部。课程举的两个典型例子:点击上一首/下一首时外层歌单怎么驱动当前播放器切歌;播放模式切换后外层逻辑怎么决定下一首是谁。

这意味着播放器要开始具备:对外暴露实例能力、对外兼容用户自定义事件、对外保留清晰的方法类型。组件封装做到后期,最重要的往往不是再加一个按钮,而是明确边界——如果播放器不能和外层列表通信,就很难真正落地到业务页面。本节仍是「前置能力建设」,不是完整歌单逻辑实现。

二、卸载改用实例级 unload

课程首先回头修正一个设计问题:之前卸载阶段调用的是 Howler.unload()。它会销毁所有 Howler 实例,如果页面上同时存在多个音频组件,其中一个卸载就可能把别的组件也停掉。

这一节把卸载逻辑收窄为:先判断当前实例是否存在,存在时只调用当前实例的 unload()。这个调整标志着组件开始真正遵守「只管理自己实例」的边界。

ts
onBeforeUnmount(() => {
  if (audioInstance.value) audioInstance.value.unload()
})

Howler.unload() 是全局级清理,不适合直接放在组件卸载逻辑里;组件内部优先使用实例级 unload()。这是典型的「课程能跑」和「工程上稳妥」的差异点。

三、回调兼容:先执行用户逻辑再补内部逻辑

第二个关键修正是回调兼容。之前初始化 Howl 时组件内部直接写了 onload / onplay,如果用户自己在 props.options 里也传了同名回调,就会被覆盖掉——封装组件不应吞掉用户原始回调。

改造思路很标准:先从 props.options 取出默认回调,再在组件内部回调里先执行用户逻辑,然后继续执行组件自己的状态同步逻辑。

ts
function initAudio() {
  const defaultOnLoad = props.options?.onload
  const defaultOnPlay = props.options?.onplay

  audioInstance.value = new Howl({
    ...options,
    onload() {
      defaultOnLoad?.()
      state.duration = audioInstance.value?.duration() ?? 0
    },
    onplay(id) {
      defaultOnPlay?.(id)
      step()
    },
  })
}

包装回调时不要把原始参数丢掉,组件逻辑和用户逻辑应该共存而不是互相覆盖。这类回调兼容是组件封装成熟度的重要标志。

四、用 defineExpose 受控透传实例方法

后续外层列表需要驱动播放器(让它播放、暂停、获取状态、触发切歌前后控制)。如果播放器什么都不暴露,父组件只能靠 props 和事件做间接控制,很多场景很麻烦。defineExpose() 的意义不是把内部无脑敞开,而是只暴露真正有用的方法,保留组件内部状态管理权。

ts
defineExpose({
  play: (...args: unknown[]) => audioInstance.value?.play(...args as []),
  pause: (...args: unknown[]) => audioInstance.value?.pause(...args as []),
  stop: (...args: unknown[]) => audioInstance.value?.stop(...args as []),
  seek: (...args: unknown[]) => audioInstance.value?.seek(...args as []),
})

暴露方法是为了给父组件可控能力,不是让父组件接管一切;defineExpose() 更适合暴露方法,不适合把整个内部响应式状态原样抛出。

五、用方法名白名单批量代理

Howl 实例上有大量方法(playpausestopseekratevolumemuteunload 等)。如果一个个手写,不仅重复而且后续维护痛苦。更好的做法是先整理方法名列表,遍历它为每个方法生成代理函数,函数内部转发到 audioInstance.value

ts
const exposeMethods = ["play", "pause", "stop", "seek", "rate", "volume", "mute", "unload"] as const

function createExposeMethods(instance: Ref<Howl | undefined>) {
  return Object.fromEntries(
    exposeMethods.map((method) => [
      method,
      (...args: unknown[]) => instance.value?.[method]?.(...args as never[]),
    ]),
  )
}

defineExpose(createExposeMethods(audioInstance))

批量代理是为了减少样板代码,不是炫技巧;方法透传列表最好是显式的,不要把实例上所有属性都暴露出去。后续增删暴露能力只需改方法列表。

六、导出方法类型,让父组件获得补全

做完 defineExpose() 后很快会发现一个问题:父组件能拿到 ref、能调方法,但 TypeScript 并不知道这些方法是什么——调用时没有补全、参数没有约束、维护时不清楚哪些方法真正允许。

把暴露方法从一个「值对象」整理成一个「类型」再导出,例如 AudioPlayerMethods。父组件写 const audioRef = ref<AudioPlayerMethods>() 时就能直接拿到完整方法提示。

ts
export function createExposeMethods(instance: Ref<Howl | undefined>) {
  return {
    play: (...args: Parameters<Howl["play"]>) => instance.value?.play(...args),
    pause: (...args: Parameters<Howl["pause"]>) => instance.value?.pause(...args),
    seek: (...args: Parameters<Howl["seek"]>) => instance.value?.seek(...args),
  }
}

export type AudioPlayerMethods = ReturnType<typeof createExposeMethods>

类型导出不是锦上添花,而是让方法透传真正可用;父组件一旦直接调实例方法,没有类型提示会非常痛苦。

七、父组件通过 ref 调用成功,验证 API 闭环

课程最后做了代表性测试:外层页面给 AudioPlayerref,点击按钮直接调用 audioRef.value.play()。这一步验证的不只是「能播出来」,而是整套设计已闭环——内部有实例、实例方法受控暴露、暴露方法有明确类型、父组件能拿到提示并成功调用。

ts
const audioRef = ref<AudioPlayerMethods>()

function handleClick() {
  audioRef.value?.play()
}

这意味着播放器已经具备和外层歌单、播放面板、全局控制条协同的基础能力。下一节做真正的外层列表交互时,底层能力已经足够。ref 里应尽量写明确的方法类型,而不是 any


常见问题

问题原因解决方案
为何不能继续用 Howler.unload()它会清空页面上所有 Howler 实例组件内部改用 audioInstance.value?.unload()
用户传的 onload / onplay 为何没生效初始化时被组件内部回调覆盖先缓存默认回调,再在包装回调里继续执行
只用 defineExpose() 不导出类型够不够父组件能调但无类型提示和参数约束额外导出 AudioPlayerMethods 供父组件 ref 使用
为何不直接暴露整个 audioInstance会把内部边界打穿,不够受控只暴露经过筛选的常用方法
外层 ref 调到方法报错可能传错实例引用或代理里取错 .value保证传给 createExposeMethods() 的是响应式实例引用本身
方法列表为何单独维护数组手写几十个方法太重复通过方法名白名单批量生成代理函数
这一节是否已完成歌单切换还没有,只打通父层调用能力下一节再处理外层列表联动与播放模式策略

延伸阅读