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() 启用后,可以把同一条件下的多个类写成一个组,避免反复重复前缀,在复杂响应式组件里可读性更高。
import { defineConfig, transformerVariantGroup } from "unocss"
export default defineConfig({
transformers: [transformerVariantGroup()]
})三、交互入口拆分:startDrag 与 jumpTo
进度条交互第一步是把入口拆清楚:拖拽控制柄(startDrag)和点击轨道跳转(jumpTo),且每种行为都要兼容桌面端(mousedown / mousemove / mouseup)与移动端(touchstart / touchmove / touchend)。不要混成一个入口,否则分支会越来越乱;这一步只是交互入口拆分,真正的位置计算交给统一的更新函数。
<!-- 控制柄 -->
<div @mousedown="startDrag" @touchstart="startDrag" />
<!-- 整条轨道 -->
<div @click="jumpTo" @touchstart="jumpTo" />四、startDrag 的事件生命周期
拖拽最容易出错的地方不是计算公式,而是事件生命周期。startDrag 要做:preventDefault 避免选中文字、识别是否为 touchstart、统一取开始时的 clientX、在 document 上监听 move 与 end、在 onEnd 里移除监听。把监听挂在 document 而不是只挂在控制柄上很重要,因为拖拽时手指或鼠标很容易移出原始小圆点区域,监听范围太小会导致拖拽中断。
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 里重复实现。
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 时先写入当前宽度再做后续比例运算。
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 |