{T}

Howler.js 进度拖拽、点击跳转与响应式位置同步

概述

音频播放器的进度条交互,本质是“点击跳转 + 拖拽延续 + 双端兼容 + 尺寸变化同步”的组合问题。本文先修正一个头部布局小 bug,再引入 UnoCSS 分组变体,然后把进度条交互拆成 startDrag 与 jumpTo 两套入口,统一到 updatePosition 做边界裁剪,最后用 useResizeObserver 解决容器宽度变化后手柄错位的响应式问题。

学习目标

  • 修正头部布局冲突:不要把 ElRow 和自定义 flex 混用,直接换回普通 div
  • 了解 UnoCSS 分组变体(transformerVariantGroup)对响应式类名可读性的提升
  • 把进度条交互拆成 startDrag 与 jumpTo 两套入口,并兼容鼠标与触摸
  • 掌握 startDrag 的事件生命周期管理(document 级监听 + 清理)
  • 用 updatePosition + Math.max/min 做边界裁剪,并用 useResizeObserver 按比例重算位置
  • 认识触摸与鼠标双端事件统一处理(getClientX 抽象)对移动端兼容的意义

一、先修布局小 bug

正式写逻辑前,先修一个头部布局问题:之前顶部用了 ElRow,它带有栅格与换行行为,即使补了 flex-nowrap 也没彻底盖掉。正确做法是直接换回普通 div 并手写 flex 布局。这一步看似只是“改个标签”,但后续大量逻辑都依赖元素尺寸、位置与行内布局是否稳定,结构不稳会直接导致响应式计算和交互区域判断偏差。

二、UnoCSS 分组变体

后续播放器样式会大量用到组合式响应式类,例如 lt-sm:(...)hover:(...)。UnoCSS 提供 Variant group transformer,通过 transformerVariantGroup() 启用后,可以把同一条件下的多个类写成一个组,避免反复重复前缀,在复杂响应式组件里可读性更高。

ts
import { defineConfig, transformerVariantGroup } from "unocss"

export default defineConfig({
  transformers: [transformerVariantGroup()]
})

三、交互入口拆分:startDrag 与 jumpTo

进度条交互第一步是把入口拆清楚:拖拽控制柄(startDrag)和点击轨道跳转(jumpTo),且每种行为都要兼容桌面端(mousedown / mousemove / mouseup)与移动端(touchstart / touchmove / touchend)。不要混成一个入口,否则分支会越来越乱;这一步只是交互入口拆分,真正的位置计算交给统一的更新函数。

Vue SFC
<!-- 控制柄 -->
<div @mousedown="startDrag" @touchstart="startDrag" />
<!-- 整条轨道 -->
<div @click="jumpTo" @touchstart="jumpTo" />

四、startDrag 的事件生命周期

拖拽最容易出错的地方不是计算公式,而是事件生命周期。startDrag 要做:preventDefault 避免选中文字、识别是否为 touchstart、统一取开始时的 clientX、在 document 上监听 move 与 end、在 onEnd 里移除监听。把监听挂在 document 而不是只挂在控制柄上很重要,因为拖拽时手指或鼠标很容易移出原始小圆点区域,监听范围太小会导致拖拽中断。

ts
function startDrag(e: MouseEvent | TouchEvent) {
  e.preventDefault()
  const isTouch = e.type === "touchstart"
  const startX = isTouch ? e.touches[0].clientX : (e as MouseEvent).clientX
  const onMove = (moveEvent: MouseEvent | TouchEvent) => {
    const clientX = isTouch
      ? (moveEvent as TouchEvent).touches[0].clientX
      : (moveEvent as MouseEvent).clientX
    updatePosition(clientX, startX, originLeft)
  }
  const onEnd = () => {
    document.removeEventListener(isTouch ? "touchmove" : "mousemove", onMove)
    document.removeEventListener(isTouch ? "touchend" : "mouseup", onEnd)
  }
  document.addEventListener(isTouch ? "touchmove" : "mousemove", onMove)
  document.addEventListener(isTouch ? "touchend" : "mouseup", onEnd)
}

五、统一的位置更新与边界裁剪

核心函数是 updatePosition:先拿轨道宽度、当前 clientX、拖拽起点 startX、当前起始 originLeft,再算出新的 left,并用 Math.max 与 Math.min 做边界裁剪,保证小圆点不跑出轨道。这个函数同时驱动“控制柄位置”和“已播放进度宽度”,不要散落在 startDrag / jumpTo 里重复实现。

ts
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
}

六、点击跳转与拖拽延续的参数差异

二者虽都调用 updatePosition,但传参不同:点击跳转只需算“点击点相对于轨道左边的偏移”,即 startX = rect.left, originLeft = 0;拖拽延续要保留“当前控制柄已在什么位置”,即 originLeft = startX - rect.left。关键是把“当前位置”在拖拽开始那一刻冻结成常量,而不是在 move 中反复重取 left.value 累加,否则越拖越偏。

七、响应式位置同步

如果进度条只存像素值 left,容器宽度变化后手柄就会错位。正确做法是额外维护 progressWidth,用旧的 left / progressWidth 算出比例 rate,再用 rate * newWidth 重新计算新的 left。用 VueUse 的 useResizeObserver 监听元素尺寸变化,初始宽度为 0 时先写入当前宽度再做后续比例运算。

ts
useResizeObserver(progressRef, (entries) => {
  const width = entries[0].contentRect.width
  if (!progressWidth.value) {
    progressWidth.value = width
    return
  }
  const rate = left.value / progressWidth.value
  left.value = rate * width
  progressWidth.value = width
})

常见问题

问题原因解决方案
加了 flex-nowrap 头部仍换行上层用了带预设布局行为的容器组件直接换回普通 div 自己接管 flex
只写鼠标拖拽,移动端无法操作事件模型没兼容 touch 事件用统一 getClientX 和双端事件绑定处理
拖拽时手柄跑出进度条位置计算没做边界裁剪用 Math.max(0, ...) 与 Math.min(maxWidth, ...) 限制
点击跳转正常,再次拖拽像从 0 开始没保存拖拽开始时的初始偏移拖拽开始时计算 originLeft = startX - rect.left 并固定
改用 left.value 反复累加越拖越偏每次 move 都在叠加变化中的值把“初始偏移”冻结成常量再与移动差值相加
改变窗口宽度后手柄错位只存像素值没按比例重算维护 progressWidth,用 rate = left / width 重新计算
拖拽中页面跟着滚动拖拽没阻止默认行为startDrag 里 preventDefault,触摸事件必要时 passive:false

延伸阅读