VueUse useSwipe 组合式函数指南:基于 TouchEvent 的响应式滑动手势检测
VueUse useSwipe 组合式函数指南:基于 TouchEvent 的响应式滑动手势检测
导读
useSwipe 是 VueUse 核心包(@vueuse/core)中位于 Sensors(传感器) 类别下的一个组合式函数,它基于浏览器原生 TouchEvent、核心实现、官方示例 与 单元测试,完整讲解 useSwipe 的 API、方向判定算法、阈值机制、事件生命周期回调,以及如何在 Vue 3 组件中用它构建"滑动删除"等真实交互场景。
一、useSwipe 是什么
useSwipe 提供基于 TouchEvent 的响应式滑动检测。在移动端(触摸屏)交互中,滑动(Swipe)是最常见的手势之一:列表项滑动删除、图片轮播切换、卡片滑动关闭、抽屉面板手势收起等场景都依赖它。
它天然支持 Vue 3 的响应式体系,返回值中 isSwiping、direction、lengthX、lengthY 均为可响应状态,滑动过程中 UI 可以实时响应手势变化。在 VueUse 仓库中,它被归类为 packages/core/useSwipe,同类别(Sensors)下还有 usePointerSwipe(基于 Pointer Events 的滑动检测)等,但 useSwipe 是专为触屏 TouchEvent 设计的。
二、安装与引入
useSwipe 从 @vueuse/core 包导出:
import { useSwipe } from '@vueuse/core'
三、基本用法
3.1 最小示例
<script setup lang="ts">
import { useSwipe } from '@vueuse/core'
import { useTemplateRef } from 'vue'
const el = useTemplateRef('el')
const { isSwiping, direction } = useSwipe(el)
</script>
<template>
<div ref="el">
Swipe here
</div>
</template>
这是 skills/vueuse-functions/references/useSwipe.md 中的标准用法:
- 通过 Vue 3.5+ 的
useTemplateRef拿到模板 ref; - 将其作为
target传入useSwipe; - 返回解构出
isSwiping(是否正在滑动)与direction(滑动方向)。
3.2 完整示例:滑动删除卡片
官方 demo.vue 展示了一个接近真实产品的"左滑删除"场景,可完整复制运行:
<script setup lang="ts">
import type { UseSwipeDirection } from '@vueuse/core'
import { useSwipe } from '@vueuse/core'
import { computed, shallowRef, useTemplateRef } from 'vue'
const target = useTemplateRef('target')
const container = useTemplateRef('container')
const containerWidth = computed(() => container.value?.offsetWidth)
const left = shallowRef('0')
const opacity = shallowRef(1)
function reset() {
left.value = '0'
opacity.value = 1
}
const { direction, isSwiping, lengthX, lengthY } = useSwipe(
target,
{
passive: false,
onSwipe(e: TouchEvent) {
if (containerWidth.value) {
if (lengthX.value < 0) {
const length = Math.abs(lengthX.value)
left.value = `${length}px`
opacity.value = 1.1 - length / containerWidth.value
}
else {
left.value = '0'
opacity.value = 1
}
}
},
onSwipeEnd(e: TouchEvent, direction: UseSwipeDirection) {
if (lengthX.value < 0 && containerWidth.value && (Math.abs(lengthX.value) / containerWidth.value) >= 0.5) {
left.value = '100%'
opacity.value = 0
}
else {
left.value = '0'
opacity.value = 1
}
},
},
)
</script>
<template>
<div>
<div ref="container" class="container select-none">
<button @click="reset">
Reset
</button>
<div ref="target" class="overlay" :class="{ animated: !isSwiping }" :style="{ left, opacity }">
<p>Swipe right</p>
</div>
</div>
<p class="status">
Direction: {{ direction ? direction : '-' }} <br>
lengthX: {{ lengthX }} | lengthY: {{ lengthY }}
</p>
</div>
</template>
<style scoped>
.container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed #ccc;
overflow: hidden;
}
.overlay {
top: 0;
left: 0;
width: 100%;
height: 100%;
position: absolute;
background: #3fb983;
}
.overlay.animated {
transition: all 0.2s ease-in-out;
}
.overlay > p {
color: #fff;
font-weight: bold;
text-align: center;
overflow: hidden;
white-space: nowrap;
}
.status {
text-align: center;
}
</style>
该示例的核心思路:
- 跟随手势:
onSwipe回调中通过lengthX(响应式计算的位移量)实时设置元素left偏移,并随滑出距离等比降低opacity,让元素"粘在手指上"; - 判定删除:
onSwipeEnd中当lengthX < 0(左滑)且滑出距离达到容器宽度 50% 时,将元素完全滑出并隐藏(opacity: 0),否则回弹复位; isSwiping驱动动画:滑动过程中通过:class="{ animated: !isSwiping }"关闭 CSS 过渡(避免手势跟手延迟),滑动结束后开启transition: all 0.2s ease-in-out实现平滑回弹/滑出动画。
注意:示例将
passive设为false,这是为了在touchmove中调用preventDefault()阻止浏览器默认滚动行为(详见下文"passive 选项"与源码分析)。
四、完整 API 与类型声明
useSwipe 的完整类型签名如下(来自 skills/vueuse-functions/references/useSwipe.md,与 packages/core/useSwipe/index.ts 的实现一一对应):
export type UseSwipeDirection = "up" | "down" | "left" | "right" | "none"
export interface UseSwipeOptions extends ConfigurableWindow {
/**
* Register events as passive
*
* @default true
*/
passive?: boolean
/**
* @default 50
*/
threshold?: number
/**
* Callback on swipe start
*/
onSwipeStart?: (e: TouchEvent) => void
/**
* Callback on swipe moves
*/
onSwipe?: (e: TouchEvent) => void
/**
* Callback on swipe ends
*/
onSwipeEnd?: (e: TouchEvent, direction: UseSwipeDirection) => void
}
export interface UseSwipeReturn {
isSwiping: ShallowRef<boolean>
direction: ComputedRef<UseSwipeDirection>
coordsStart: Readonly<Position>
coordsEnd: Readonly<Position>
lengthX: ComputedRef<number>
lengthY: ComputedRef<number>
stop: () => void
}
export declare function useSwipe(
target: MaybeRefOrGetter<EventTarget | null | undefined>,
options?: UseSwipeOptions,
): UseSwipeReturn
4.1 函数签名
function useSwipe(
target: MaybeRefOrGetter<EventTarget | null | undefined>,
options?: UseSwipeOptions,
): UseSwipeReturn
target:MaybeRefOrGetter<EventTarget | null | undefined>,即可以传入 DOM 元素、模板 ref、ref对象或 getter 函数。源码中通过useEventListener内部对目标进行响应式解析(包括unrefElement处理),因此即使元素在异步渲染后才挂载也能正确绑定。options:可选配置,详见下表。
4.2 选项参数表
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
passive |
boolean |
true |
是否以 passive 模式注册 touch 事件监听。若为 false,则改为 capture(捕获)模式注册,且允许在 touchmove 中调用 preventDefault() 阻止滚动 |
threshold |
number |
50 |
位移阈值(像素)。手指从起点移动超过该阈值才认定为滑动 |
onSwipeStart |
(e: TouchEvent) => void |
— | 手指按下(touchstart)时的回调 |
onSwipe |
(e: TouchEvent) => void |
— | 手指移动(touchmove)且已进入滑动状态时的回调 |
onSwipeEnd |
(e: TouchEvent, direction: UseSwipeDirection) => void |
— | 手指抬起/取消(touchend / touchcancel)时的回调,第二参数为该次滑动的最终方向 |
UseSwipeOptions还继承了ConfigurableWindow(packages/core/_configurable.ts),可通过window选项传入自定义 window 实例(如 iframe 或测试环境),该接口同样被 VueUse 大量组合式函数复用。
4.3 返回值
| 返回值 | 类型 | 说明 |
|---|---|---|
isSwiping |
ShallowRef<boolean> |
是否正在滑动(位移已超过阈值且手指未离开) |
direction |
ComputedRef<UseSwipeDirection> |
当前/最终滑动方向:`'up' |
coordsStart |
Readonly<Position> |
手势起点坐标(x、y,取自 touchstart 的 clientX/clientY) |
coordsEnd |
Readonly<Position> |
手势当前/终点坐标(每次 touchmove 更新) |
lengthX |
ComputedRef<number> |
横向位移:coordsStart.x - coordsEnd.x |
lengthY |
ComputedRef<number> |
纵向位移:coordsStart.y - coordsEnd.y |
stop |
() => void |
手动解绑全部三个事件监听器(touchstart / touchmove / touchend+touchcancel) |
其中 Position 类型定义于 packages/core/types.ts:
export interface Position {
x: number
y: number
}
coordsStart、coordsEnd 是 reactive 对象,lengthX/lengthY/direction 都是 computed,因此滑动过程中的每一帧位移变化都会驱动 UI 响应式更新。
五、源码级原理解析
5.1 状态初始化与默认值
实现源码 开头解构选项并建立核心状态:
const {
threshold = 50,
onSwipe,
onSwipeEnd,
onSwipeStart,
passive = true,
} = options
const coordsStart = reactive<Position>({ x: 0, y: 0 })
const coordsEnd = reactive<Position>({ x: 0, y: 0 })
const diffX = computed(() => coordsStart.x - coordsEnd.x)
const diffY = computed(() => coordsStart.y - coordsEnd.y)
const { max, abs } = Math
const isThresholdExceeded = computed(() => max(abs(diffX.value), abs(diffY.value)) >= threshold)
const isSwiping = shallowRef(false)
注意两点:
- 位移方向约定:
diffX = coordsStart.x - coordsEnd.x。手指从左向右滑(coordsEnd.x增大)时diffX为负;向右滑返回direction: 'right'、lengthX为负。这与 demo 中"lengthX < 0视为左滑"的用法一致(测试reactivity用例中lengthX为-threshold时 direction 为'right',见 index.test.ts)。 - 阈值比较使用横、纵位移绝对值的最大值:
max(abs(diffX), abs(diffY)) >= threshold,保证对角线滑动同样能被触发,且方向判定会优先采用位移较大的轴。
5.2 方向判定算法
方向计算 逻辑如下:
const direction = computed((): UseSwipeDirection => {
if (!isThresholdExceeded.value)
return 'none'
if (abs(diffX.value) > abs(diffY.value)) {
return diffX.value > 0
? 'left'
: 'right'
}
else {
return diffY.value > 0
? 'up'
: 'down'
}
})
判定规则可以概括为:
- 未超过阈值时方向恒为
'none'; - 超过阈值后,比较横纵位移绝对值:
|diffX| > |diffY|判为水平方向,再根据diffX正负得出left(手指左滑)/right(手指右滑);- 否则判为垂直方向,根据
diffY正负得出up/down。
该算法由测试用例完整覆盖,index.test.ts 中 it.each 分别验证了四个方向的坐标序列与最终方向、onSwipeEnd 第二参数的一致性。
5.3 事件生命周期与监听器
整个组合式函数围绕三个事件构建(源码 L106-L141):
const listenerOptions = { passive, capture: !passive }
const onTouchEnd = (e: TouchEvent) => {
if (isSwiping.value)
onSwipeEnd?.(e, direction.value)
isSwiping.value = false
}
const stops = [
useEventListener(target, 'touchstart', (e: TouchEvent) => {
if (e.touches.length !== 1)
return
const [x, y] = getTouchEventCoords(e)
updateCoordsStart(x, y)
updateCoordsEnd(x, y)
onSwipeStart?.(e)
}, listenerOptions),
useEventListener(target, 'touchmove', (e: TouchEvent) => {
if (e.touches.length !== 1)
return
const [x, y] = getTouchEventCoords(e)
updateCoordsEnd(x, y)
if (listenerOptions.capture && !listenerOptions.passive && Math.abs(diffX.value) > Math.abs(diffY.value))
e.preventDefault()
if (!isSwiping.value && isThresholdExceeded.value)
isSwiping.value = true
if (isSwiping.value)
onSwipe?.(e)
}, listenerOptions),
useEventListener(target, ['touchend', 'touchcancel'], onTouchEnd, listenerOptions),
]
const stop = () => stops.forEach(s => s())
要点解析:
- 单指约束:
touchstart/touchmove都会先检查e.touches.length !== 1,多指触控直接忽略,避免多点触控干扰手势判定。 - 坐标来源:
getTouchEventCoords读取e.touches<a href="https://link.gitcode.com/i/c9140ac9b33201ecfbcc5323ba114168" target="_blank">0].clientX/clientY([源码 L94)。touchstart同时更新起点与终点,之后每次touchmove只更新终点,从而得到实时位移。 isSwiping的触发时机:touchmove中当位移首次超过阈值(isThresholdExceeded)时才将isSwiping置为true,此后每次移动都会调用onSwipe回调。touchend与touchcancel统一处理:touchend(正常抬起)与touchcancel(系统中断,如来电)都会触发onTouchEnd,仅当正处于滑动状态时回调onSwipeEnd(e, direction.value),随后重置isSwiping = false。- 生命周期自动管理:三个监听器均通过
useEventListener注册,它内部使用watchImmediate在目标解析后绑定、组件卸载时自动解绑(packages/core/useEventListener/index.ts),因此无需手动清理,除非调用stop()。
5.4 passive 选项的深层影响
监听器配置为 { passive, capture: !passive }:
passive: true(默认):以 passive 模式注册监听器,touchmove中无法调用preventDefault(),但浏览器滚动/缩放行为不被阻塞,性能最优。适合"只检测方向、不拦截滚动"的轻量场景。passive: false:改为 capture(捕获)阶段 注册,此时源码中的这段逻辑生效:
if (listenerOptions.capture && !listenerOptions.passive && Math.abs(diffX.value) > Math.abs(diffY.value))
e.preventDefault()
即在横向位移占优(|diffX| > |diffY|)时主动 preventDefault() 阻止页面垂直滚动,避免手势跟手时页面滚动冲突。这正是 demo 中"左滑删除卡片"必须设置 passive: false 的原因。
六、测试驱动的行为验证
packages/core/useSwipe/index.test.ts 使用 Vitest + jsdom 模拟 TouchEvent 分发,从测试用例中可以反推得到一组精确的行为契约:
| 测试场景 | 行为验证 |
|---|---|
threshold not exceeded(位移未达阈值) |
onSwipe 与 onSwipeEnd 均不触发,方向保持 none |
threshold exceeded(位移超阈值) |
onSwipe 与 onSwipeEnd 各触发一次 |
threshold exceeded in between(中途回移低于阈值) |
onSwipe 触发 2 次,onSwipeEnd 的 direction 为 none——即抬起时位移不足阈值则方向归零 |
reactivity |
isSwiping、direction、lengthX、lengthY 随 touchstart/touchmove 实时响应(如右滑 30px 后 lengthX 为 -30、direction 为 'right') |
swipe up/down/left/right(四方向参数化用例) |
每个方向的位移序列都正确产出对应方向,且 onSwipeEnd 第二参数与之一致 |
对 direction 的完整行为契约可以总结为:
- 未达阈值(
max(|diffX|, |diffY|) < threshold):'none'; - 达到阈值且
|diffX| > |diffY|:diffX > 0为'left',否则'right'; - 达到阈值且
|diffX| <= |diffY|:diffY > 0为'up',否则'down'。
七、在 Vue 3 组件中的集成要点
7.1 target 的三种传法
- 模板 ref:
const el = useTemplateRef('el'); useSwipe(el)(Vue 3.5+ 推荐写法); - 普通 ref:
const el = ref<HTMLElement | null>(null); useSwipe(el); - getter:
useSwipe(() => document.querySelector('.swipe-area'))。
三者都满足 MaybeRefOrGetter<EventTarget | null | undefined> 签名,元素在异步渲染完成后挂载也能被 useEventListener 响应式捕获。
7.2 与指令/组件体系的对比
useSwipe 属于组合式 API 形态,需要搭配模板 ref 使用。仓库中 onClickOutside、onKeyStroke 等同时提供指令形态(directive.ts),但 useSwipe 目前仅提供组合式函数形态,更适合在 <script setup> 中声明式组合。
7.3 适用边界
- 仅 TouchEvent:
useSwipe基于 Touch API,桌面端鼠标拖拽无法触发。若需兼容鼠标与触屏(Pointer Events),可参考同仓库的usePointerSwipe(packages/core/usePointerSwipe/index.ts); - 浏览器能力:需要支持 TouchEvent 的运行环境(移动端浏览器、触屏笔记本),桌面 Chrome DevTools 的设备模拟模式也可调试;
- SSR 注意:TouchEvent 属于浏览器 API,组合式函数应在
onMounted之后的客户端交互中体现效果;VueUse 的 SSR 安全模式(isClient判断,见 packages/core/_configurable.ts)会避免服务端注册监听器。
八、从文档到源码的快速导航
若想深入研读,本仓库内可依次查看:
| 资源 | 路径 | 说明 |
|---|---|---|
| API 参考文档 | skills/vueuse-functions/references/useSwipe.md | 含完整类型声明 |
| 组合式函数文档 | packages/core/useSwipe/index.md | 官方基础用法 |
| 核心实现 | packages/core/useSwipe/index.ts | 方向算法、事件绑定、默认值 |
| 官方演示 | packages/core/useSwipe/demo.vue | 可运行的滑动删除示例 |
| 单元测试 | packages/core/useSwipe/index.test.ts | 阈值、方向、响应性行为契约 |
| 事件监听基础 | packages/core/useEventListener/index.ts | 监听器自动绑定/解绑原理 |
| 通用类型 | packages/core/types.ts | Position 定义 |
九、小结
useSwipe 用极简的 API(一个目标 + 三个回调 + 四个响应式状态)封装了完整的触摸滑动检测链路:
- 以
touchstart记录起点、touchmove追踪终点并驱动响应式位移; - 以
max(|diffX|, |diffY|) >= threshold作为滑动判定门槛,默认 50px; - 以位移绝对值比较 + 正负号完成四方向判定;
- 以
touchend/touchcancel统一结束手势并回传最终方向; - 通过
passive选项控制是否在捕获阶段preventDefault()以协调页面滚动。
无论是需要"滑动跟手 + 滑出删除"的高交互场景,还是仅需轻量方向检测的场景,都可以直接用 useSwipe 一行接入,并在 onSwipe/onSwipeEnd 回调中完成业务逻辑。