音频播放器的进度条交互,本质是“点击跳转 + 拖拽延续 + 双端兼容 + 尺寸变化同步”的组合问题。本文先修正一个头部布局小 bug,再引入 UnoCSS 分组变体,然后把进度条交互拆成 startDrag 与 jumpTo 两套入口,统一到 updatePosition 做边界裁剪,最后用 useResizeObserver 解决容器宽度变化后手柄错位的响应式问题。
正式写逻辑前,先修一个头部布局问题:之前顶部用了 ElRow,它带有栅格与换行行为,即使补了 flex-nowrap 也没彻底盖掉。正确做法是直接换回普通 div 并手写 flex 布局。这一步看似只是“改个标签”,但后续大量逻辑都依赖元素尺寸、位置与行内布局是否稳定,结构不稳会直接导致响应式计算和交互区域判断偏差。
后续播放器样式会大量用到组合式响应式类,例如 lt-sm:(...)、hover:(...)。UnoCSS 提供 Variant group transformer,通过 transformerVariantGroup() 启用后,可以把同一条件下的多个类写成一个组,避免反复重复前缀,在复杂响应式组件里可读性更高。
import { defineConfig, transformerVariantGroup } from "unocss"
export default defineConfig({
transformers: [transformerVariantGroup()]
})进度条交互第一步是把入口拆清楚:拖拽控制柄(startDrag)和点击轨道跳转(jumpTo),且每种行为都要兼容桌面端(mousedown / mousemove / mouseup)与移动端(touchstart / touchmove / touchend)。不要混成一个入口,否则分支会越来越乱;这一步只是交互入口拆分,真正的位置计算交给统一的更新函数。
<!-- 控制柄 -->
<div @mousedown="startDrag" @touchstart="startDrag" />
<!-- 整条轨道 -->
<div @click="jumpTo" @touchstart="jumpTo" />拖拽最容易出错的地方不是计算公式,而是事件生命周期。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 |