Vue 3 卡片滑动组件实战:从手势原理到性能优化

Vue 3 卡片滑动组件实战:从手势原理到性能优化 最近在做一个社交类项目的时候产品提了个需求首页要有一组用户卡片可以像现在主流交友软件那样左右滑动喜欢就右滑不喜欢就左滑滑出去的卡片要有抛飞效果下面的卡片要能跟着顶上来。需求本身不复杂但真正动手写的时候发现坑不少——手势怎么跟、旋转角度怎么算、抛飞动画怎么跟 Vue 的响应式系统配合、卡片栈怎么管理才不会越滑越卡。这篇文章把我从零实现 Vue 3 交友卡片滑动组件的完整过程梳理出来包含手势处理的数学原理、组件拆分的边界设计、完整的可运行代码以及我在实际项目中踩过的几个典型坑。适合正在用 Vue 3 做类似交互效果的开发者参考也适合想搞懂拖拽 惯性 联动这类复杂手势组件怎么组织代码的朋友。1. 先拆需求这个组件的核心难点到底在哪1.1 一张卡片引发的交互思考交友卡片滑动这个交互最早被大家熟知是国外那款社交软件带火的后来国内不少产品也跟进了这个玩法。表面上看起来就是手指按住卡片往左一扔、往右一扔但落到工程实现上它其实是一连串问题的组合手指在卡片上移动时卡片要实时跟着手指走而且是平移 旋转两个动作叠加。手指松开后系统要判断用户是想滑走还是只是想看看前者要抛飞出去后者要弹回原位。最上层卡片飞走之后下面那张要平滑地补位同时整个卡片堆要有层次感通常是后面几张逐层缩小、压低。滑动过程要给用户明确的视觉反馈右滑的角度、透明度变化、甚至文案提示让用户感知到再滑一点就会触发。如果只做手指拖哪就移到哪那太简单了随便绑个事件就能做。真正的门槛在于如何用一套干净的状态模型把这几个状态拖拽中、弹回、抛飞、补位串起来并且保证动画流畅、状态不串。这也是我决定从零写而不是直接引第三方库的原因——这类交互组件业务逻辑变化非常快有的要加超级喜欢、有的要加撤销、有的要做卡片详情折叠自己掌握核心逻辑后面改起来才不被动。1.2 为什么选 Vue 3 而不是 Vue 2 或 React这个项目本身技术栈是 Vue 3所以选型上没有太多悬念。但从组件实现角度Vue 3 确实有几个特性让这种手势组件写起来更舒服Composition API 把拖拽状态和业务逻辑收敛在一起代码比 Options API 的 data/computed/methods 三段式更紧凑。手势组件的状态关联性很强offsetX、offsetY、旋转角、是否可拖拽是互相依赖的用 setup 函数写起来心智负担小很多。script setup语法糖省掉了一堆 return组件代码量肉眼可见地减少。响应式系统的 Proxy 实现在处理高频更新的拖拽场景时性能表现比 Vue 2 的 defineProperty 更好。拖拽过程中每次 move 都要更新视图底层响应式代理的效率直接影响帧率。Transition 组件和 CSS 动画的配合在 Vue 3 里更稳定处理卡片抛飞后移除的过渡时机更可控。有一说一这种组件用 React 也能写但如果你团队就是 Vue 技术栈没必要为一个小交互去引一套新框架。我用 Vue 3 还有一个私心Composition API 写出来的逻辑天然方便提取成组合式函数后面如果另一个页面也要用滑动卡片直接把useSwipeCard这个 hook 拿过去就行。1.3 组件边界拆成单个卡片还是整个卡片堆动手写之前我先纠结了一个问题这个组件是拆成SwipeCard单张卡片和CardStack卡片堆两层还是一个组件一把梭最后我拆了两层。理由是单张卡片负责自己的手势响应、拖拽位移、旋转计算、抛飞/弹回动画它只需要关注我这张卡片怎么样不需要知道外面还有几张卡。卡片堆负责数据管理现在展示的是第几张、当前卡片滑出去了应该触发什么回调、下一张卡片的进场动画怎么组织。它不关心单张卡片内部的拖拽细节。这样拆完之后改动单张卡片的视觉效果不会影响卡片堆的逻辑加新功能比如卡片上叠一层按钮按钮也能触发左右滑也只需要往 SwipeCard 暴露方法。后面我把这个思路展开讲代码就是按这个边界写的。2. 手势交互核心原理拖拽状态机与计算公式2.1 拖拽状态的三元组起点、偏移、松手结果所有拖拽组件的第一性问题都是状态存什么。我见过不少新手把临时坐标也塞进 data 里结果一拖就卡。实际上一次完整的拖拽交互只需要三个核心状态startX / startY手指按下时记录的起点坐标。注意只记录一次move 的时候绝对不能再更新起点否则计算偏移量就全乱了。offsetX / offsetY当前坐标减去起点坐标这就是卡片相对原位的位移。这个值在 move 里高频更新是卡片跟手的关键。direction / result松手后根据偏移量和其他条件判断出的结果——「左滑」「右滑」「回弹」。这个状态决定后续走哪条动画路径。这里有个细节值得强调起点坐标必须在touchstart/mousedown里记录偏移量必须在touchmove/mousemove里计算判断结果必须放在touchend/mouseup里做。三个阶段职责清晰不要混。混的后果就是典型的拖起来飘——卡片要么跟手延迟半拍要么一碰就飙出去。用组合式函数来组织这段逻辑整个拖拽核心大概长这样// useSwipeGesture.js import { ref } from vue export function useSwipeGesture(options {}) { const { threshold 100, // 位移阈值超过则认为滑动有效 onSwipeLeft, // 左滑回调 onSwipeRight, // 右滑回调 onReset // 回弹回调 } options const startX ref(0) const startY ref(0) const offsetX ref(0) const offsetY ref(0) const isDragging ref(false) function onStart(e) { const clientX e.touches ? e.touches[0].clientX : e.clientX const clientY e.touches ? e.touches[0].clientY : e.clientY startX.value clientX startY.value clientY isDragging.value true } function onMove(e) { if (!isDragging.value) return const clientX e.touches ? e.touches[0].clientX : e.clientX const clientY e.touches ? e.touches[0].clientY : e.clientY offsetX.value clientX - startX.value offsetY.value clientY - startY.value } function onEnd() { if (!isDragging.value) return isDragging.value false if (offsetX.value threshold) { onSwipeRight onSwipeRight(offsetX.value) } else if (offsetX.value -threshold) { onSwipeLeft onSwipeLeft(offsetX.value) } else { offsetX.value 0 offsetY.value 0 onReset onReset() } } return { startX, startY, offsetX, offsetY, isDragging, onStart, onMove, onEnd } }2.2 旋转角度怎么算才自然卡片跟手只做平移是不够的看起来像贴了张纸在屏幕上挪动完全没有拿在手里翻看的感觉。加旋转之后立刻就不一样了——这就是 Tinder 这类产品手感的关键。旋转角度的算法不复杂让旋转角和水平偏移量成线性关系。我常用的公式是rotation (offsetX / cardWidth) * MAX_ROTATIONMAX_ROTATION我一般取 15 度。卡片的宽度除以offsetX得到一个当前位移占卡片宽度的比例再乘以最大角度。这样手指把卡片拖到和卡片一样宽的位置时卡片正好转了 15 度视觉上非常自然。为什么是 15 度而不是 30 度 45 度我试过 45 度滑动的确更夸张但问题在于角度一大卡片上的文字、图片都会严重变形用户反而看不清内容而且返回到弹回动画时回旋转动路径很长显得拖泥带水。15 度是个有存在感但不过度的数值。如果卡片上的内容以图片为主可以放到 18~20 度如果以文字为主建议 10~12 度就够。实现上旋转角不需要单独设一个 ref直接用计算属性从offsetX推出来少一个状态就少一份同步出错的可能import { computed } from vue const MAX_ROTATION 15 const rotation computed(() { const ratio offsetX.value / cardWidth.value return ratio * MAX_ROTATION }) const cardStyle computed(() ({ transform: translate(${offsetX.value}px, ${offsetY.value}px) rotate(${rotation.value}deg) }))cardStyle直接返回一个transform字符串模板里:stylecardStyle绑定上去就行。这里要注意translate和rotate必须写在同一个 transform 里顺序是先位移后旋转这样旋转中心始终是卡片中心视觉最稳。2.3 判定阈值多少算滑走松手后判断是滑走还是弹回需要一个阈值。这个阈值我一开始写死了 80 像素后来在不同的手机屏幕上看效果发现差距很大——小屏手机上 80px 几乎要滑到屏幕边缘了大屏平板上 80px 又显得太轻。更好的方案是阈值和卡片宽度联动用百分比 最小值双重保障const SWIPE_THRESHOLD_RATIO 0.35 const MIN_SWIPE_DISTANCE 80 function shouldSwipeAway(offsetX) { const threshold Math.max(cardWidth.value * SWIPE_THRESHOLD_RATIO, MIN_SWIPE_DISTANCE) return Math.abs(offsetX.value) threshold }0.35 的意思是手指至少把卡片拖出去卡片宽度 35% 的位置松手才算滑走。低于这个比例松手就回弹。这个值我调过好几轮35% 是用户意图最清晰的分界点——拖到 30% 左右松手后大多数人其实是想再想想而超过 40% 之后绝大多数情况用户已经下定决心要滑掉这张卡了。还有一个容易忽略的点垂直方向的偏移量要不要参与判定我的答案是不参与。交友卡片滑动的判定只看水平方向。垂直方向如果拖太远要么让它自然弹回要么后续做强化的上滑超级喜欢玩法时再单独做一套判定不要和水平滑动搅在一起。否则用户斜着滑一下你不知道他到底想左滑还是上滑交互就糊了。3. 实操手写一个可用的 SwipeCard 单卡组件3.1 准备工作模板结构和样式基础开始写代码之前我先搭了组件的基础结构。一张卡片最外层是一个绝对定位的容器内部是业务方传入的插槽内容可能是头像加名字也可能是复杂的图文混排。为了演示方便我这里直接用一张带渐变背景和名字的卡片做示例。组件文件结构我建议这样组织src/components/swipe/ ├── SwipeCard.vue # 单张卡片负责手势和动画 └── CardStack.vue # 卡片堆负责数据管理和补位模板部分的关键是外层容器必须设置touch-action: none否则移动端浏览器会拦截触摸手势做滚动你拖卡片的时候页面会跟着滚交互直接崩掉。这个属性是我最早踩的坑之一后面会细说。3.2 SwipeCard 完整代码与逐段讲解下面是 SwipeCard 的完整代码我把它拆成几段来讲。先看模板部分template div classswipe-card :stylecardStyle touchstart.passivefalse touchstartonStart touchmove.preventonMove touchendonEnd touchcancelonEnd mousedownonStart mousemoveonMove mouseuponEnd mouseleaveonEnd div classswipe-card__badge swipe-card__badge--like :stylebadgeLikeStyle 喜欢 /div div classswipe-card__badge swipe-card__badge--nope :stylebadgeNopeStyle 拒绝 /div slot/slot /div /template这里有几个细节需要解释touchstart.passivefalseVue 3 中事件修饰符写在一起时.passive会默认让监听器变成被动模式但这里我们必须阻止默认行为否则触摸会导致页面滚动所以要显式声明.passivefalse同时配合touchmove.prevent。实际验证下来touchmove.prevent是必须的但.passive在不同浏览器上行为不一致最稳妥的做法是 touchmove 上直接上.prevent。touchcancel也要绑onEnd。手机上偶尔来电、弹系统通知都会触发touchcancel如果不处理卡片会卡在拖了一半的位置状态一直停不下来。鼠标事件是为了桌面端调试。只做移动端的项目可以省掉这组事件但我在开发时习惯保留因为浏览器 DevTools 的设备模拟跟真实触摸还是有点差别用鼠标直接测更方便。接下来是 script 部分script setup import { ref, computed } from vue const props defineProps({ cardData: { type: Object, required: true }, canDrag: { type: Boolean, default: true } }) const emit defineEmits([swipe-left, swipe-right, settle]) const CARD_WIDTH 320 const MAX_ROTATION 15 const SWIPE_THRESHOLD_RATIO 0.35 const MIN_SWIPE_DISTANCE 80 const startX ref(0) const startY ref(0) const offsetX ref(0) const offsetY ref(0) const isDragging ref(false) const isLeaving ref() // | left | right const isResetting ref(false) const threshold computed(() Math.max(CARD_WIDTH * SWIPE_THRESHOLD_RATIO, MIN_SWIPE_DISTANCE) ) const rotation computed(() { if (isLeaving.value) return 0 const ratio offsetX.value / CARD_WIDTH return ratio * MAX_ROTATION }) const cardStyle computed(() { if (isLeaving.value left) { return { transform: translate(${-window.innerWidth}px, ${offsetY.value}px) rotate(-${MAX_ROTATION}deg), transition: transform 0.4s ease-in, opacity: 0 } } if (isLeaving.value right) { return { transform: translate(${window.innerWidth}px, ${offsetY.value}px) rotate(${MAX_ROTATION}deg), transition: transform 0.4s ease-in, opacity: 0 } } return { transform: translate(${offsetX.value}px, ${offsetY.value}px) rotate(${rotation.value}deg), transition: isResetting.value ? transform 0.3s ease-out : none, opacity: Math.max(0, 1 - Math.abs(offsetX.value) / CARD_WIDTH) } }) const badgeLikeStyle computed(() ({ opacity: Math.min(1, offsetX.value / 60), transform: rotate(-12deg) })) const badgeNopeStyle computed(() ({ opacity: Math.min(1, -offsetX.value / 60), transform: rotate(12deg) })) function onStart(e) { if (!props.canDrag || isLeaving.value) return const clientX e.touches ? e.touches[0].clientX : e.clientX const clientY e.touches ? e.touches[0].clientY : e.clientY startX.value clientX startY.value clientY isDragging.value true isResetting.value false } function onMove(e) { if (!isDragging.value || !props.canDrag) return const clientX e.touches ? e.touches[0].clientX : e.clientX const clientY e.touches ? e.touches[0].clientY : e.clientY offsetX.value clientX - startX.value offsetY.value clientY - startY.value } function onEnd() { if (!isDragging.value) return isDragging.value false if (Math.abs(offsetX.value) threshold.value) { leave(offsetX.value 0 ? right : left) } else { reset() } } function leave(direction) { isLeaving.value direction emit(direction left ? swipe-left : swipe-right, props.cardData) const cleanup () { emit(settle, { direction, cardData: props.cardData }) isLeaving.value offsetX.value 0 offsetY.value 0 } const animationDuration 400 setTimeout(cleanup, animationDuration) } function reset() { isResetting.value true offsetX.value 0 offsetY.value 0 setTimeout(() { isResetting.value false emit(settle, { direction: reset, cardData: props.cardData }) }, 300) } /script代码里有几个容易忽视的地方我单独拎出来说明为什么卡片透明度要跟着位移变化。很多实现里卡片滑出去是整个硬飞出虽然能用但没有那种卡片逐渐淡出、让底下的卡浮上来的层次感。我在cardStyle里加了一句opacity: Math.max(0, 1 - Math.abs(offsetX.value) / CARD_WIDTH)位移到卡片宽度时透明度归零。这样配合旋转手指拖拽的时候卡片的消失感是渐进的观感好很多。为什么离开动画用setTimeout而不是transitionend。我在最初版本里监听transitionend来触发清理逻辑结果发现两个问题一是卡片上可能有子元素也会触发transitionend得额外判断e.target二是如果多个 CSS 属性同时过渡transform 和 opacitytransitionend会触发两次处理起来很麻烦。所以后来干脆用setTimeout时间跟 CSS 的 transition 时长保持一致400ms简单可靠。为什么window.innerWidth直接写在计算属性里。离开动画的终点是飞到屏幕之外理论上用translateX(100vw)也行但实测某些安卓浏览器上vw在 transform 里表现不稳定所以我直接用window.innerWidth拿像素值。这个值在运行时读取离开动画只有一帧性能没压力。为什么移动的时候 transition 是none。拖拽过程中绝对不能有 transition否则卡片会黏手——你手指动了卡片要延迟一小段时间才跟上手感全毁了。只有回弹reset和离开leave时才需要 transition。这就是isResetting这个状态的作用拖拽时动态样式里 transition 为 none松手回弹时切成0.3s ease-out。然后加上基础的 CSS.swipe-card { position: absolute; left: 50%; top: 50%; width: 300px; height: 420px; margin-left: -150px; margin-top: -210px; border-radius: 20px; background: linear-gradient(135deg, #6a11cb, #2575fc); box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18); touch-action: none; user-select: none; -webkit-user-select: none; cursor: grab; will-change: transform; overflow: hidden; } .swipe-card:active { cursor: grabbing; } .swipe-card__badge { position: absolute; top: 28px; padding: 8px 18px; border: 3px solid #fff; border-radius: 8px; font-size: 22px; font-weight: 700; color: #fff; opacity: 0; z-index: 2; } .swipe-card__badge--like { left: 24px; } .swipe-card__badge--nope { right: 24px; }这里left: 50%; top: 50%; margin-left/-top是让卡片居中。也可以用 flex 布局让父容器居中但既然卡片内部要绝对定位叠放、动画要 transform我习惯用这种绝对定位 负margin的写法position 定位和 transform 动画互不干扰。3.3 卡片数据模型与图片渲染实际业务里卡片上几乎肯定有图片。头像图片的加载有个坑图片没加载完成时卡片的高度会是 0布局会闪一下。我给卡片设计了固定高度图片用object-fit: cover填充这样无论图片原始比例是什么都不会把卡片撑变形template SwipeCard :card-datacard swipe-lefthandleSwipeLeft swipe-righthandleSwipeRight div classuser-card img classuser-card__photo :srccard.photo :altcard.name / div classuser-card__info span classuser-card__name{{ card.name }}/span span classuser-card__age{{ card.age }}/span /div /div /SwipeCard /template style scoped .user-card__photo { width: 100%; height: 100%; object-fit: cover; } .user-card__info { position: absolute; left: 0; right: 0; bottom: 0; padding: 20px; background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); color: #fff; } .user-card__name { font-size: 24px; font-weight: 600; margin-right: 8px; } .user-card__age { font-size: 18px; opacity: 0.85; } /style图片懒加载这块如果卡片数据量大建议给 img 加loadinglazy但注意最上层那张卡片的图片不能懒加载否则用户滑到最顶层才发现图片还是白的。我自己通常是把前 3 张卡片的图片设成loadingeager后面再出现的用懒加载。4. 卡片堆的管理补位、联动与数据出栈4.1 CardStack 组件的职责拆解单张卡片能拖了接下来的核心问题是卡片堆怎么管理。堆的逻辑其实不复杂但容易写乱我见过最乱的写法是在单卡组件里直接操作卡片数组滑完一张splice一下数组一变视图全乱了。我的做法是单卡只负责我滑出去了/我回弹了这个信号的发射卡片堆组件统一接收信号、更新数据。CardStack 的核心状态只有两个卡片数组cards和当前索引currentIndex。script setup import { ref, computed } from vue import SwipeCard from ./SwipeCard.vue const props defineProps({ cards: { type: Array, required: true }, renderCount: { type: Number, default: 3 } }) const emit defineEmits([like, nope, stack-empty]) const currentIndex ref(0) const visibleCards computed(() { return props.cards.slice(currentIndex.value, currentIndex.value props.renderCount) }) function handleSwipeLeft(cardData) { emit(nope, cardData) currentIndex.value 1 if (currentIndex.value props.cards.length) { emit(stack-empty) } } function handleSwipeRight(cardData) { emit(like, cardData) currentIndex.value 1 if (currentIndex.value props.cards.length) { emit(stack-empty) } } function handleSettle() { // 可选的复位回调比如清空按钮的 loading 状态 } /script template div classcard-stack SwipeCard v-for(card, index) in visibleCards :keycard.id :card-datacard :can-dragindex 0 :stylegetStackStyle(index) swipe-lefthandleSwipeLeft swipe-righthandleSwipeRight settlehandleSettle slot namecard :cardcard/slot /SwipeCard /div /templaterenderCount是 3 的意思是DOM 里最多同时存在 3 张卡片。最顶层那张index 0可以拖拽后面两张只是作为底牌展示用户看到下一张长什么样。这个数量不是越多越好3 张是性能和视觉的平衡点——再多的话后面几层基本被盖住白白消耗渲染成本。4.2 堆叠层次感缩放和偏移怎么配为了让卡片堆有立体感后面几张卡要逐层缩小并往下沉一点。这个逻辑我用一个函数getStackStyle(index)返回动态样式script setup function getStackStyle(index) { if (index 0) { return { zIndex: 30 - index, transform: scale(1) translateY(0) } } const scale 1 - index * 0.05 const translateY index * 14 return { zIndex: 30 - index, transform: scale(${scale}) translateY(${translateY}px), transformOrigin: center bottom } } /script这里的思路是第 0 张最上面不缩放第 1 张缩到 95%第 2 张缩到 90%同时每张往下移动 14px。zIndex从 30 开始递减保证上面的卡永远覆盖下面的卡。transformOrigin: center bottom这行值得注意。默认的 transform-origin 是中心点如果按中心点缩放下面那张卡的底部会往上抬视觉上像从中间缩进去。改成底部中心后缩放是从底部往上收正好贴合卡片堆叠时下一张被压在下面只露出顶部一截的视觉效果。我第一次写的时候没注意这个属性卡片堆怎么看怎么别扭缩放的几何中心完全不对。4.3 为什么用 currentIndex 而不是直接改数组很多人会问卡片滑出去直接把cards数组的第一项shift()掉不就行了答案是这样会破坏 Vue 的复用机制。注意模板里v-for的:keycard.idVue 靠 key 来判断哪些元素需要销毁重建、哪些可以复用。如果用shift()数组第一项删除后原第二项变成新的第一项但它在 DOM 里是一个已经存在的元素。Vue 会尝试复用它可这个元素的状态比如正在做位置过渡跟它新的身份变成顶层卡不匹配就会出现闪动、位置错乱的问题。用currentIndex的好处是每次只增加索引底层卡片不会触发销毁或重建Vue 只做最小的 DOM 更新动画的连续性最好。代价是数组会越长越占内存所有历史卡片数据都还在cards里但对一个会话里最多滑几百张卡片的场景来说完全够用。真要做无限加载等到stack-empty事件触发时往cards后面 push 新数据就行currentIndex不用动。5. 性能优化与兼容性把帧率卡到 60fps5.1 硬件加速这个 transform 到底在优化什么拖拽组件最容易犯的性能错误是用left、top来做位移。这两个属性的变化会触发浏览器的布局Layout和绘制Paint阶段每一帧都要重新计算帧率直接掉到 40fps 以下尤其在低端安卓机上卡片会明显掉帧。正确的做法就是只操作transform和opacity。这两个属性在绝大多数现代浏览器上走的是合成器Compositor线程不触发 Layout 也不触发 PaintGPU 直接合成新画面省掉最耗时的两个阶段。为了让浏览器提前做好准备我给卡片加了will-change: transform。它的作用是提示浏览器这个元素可能要频繁做 transform 变化提前给我分配一个独立的合成层。注意这个属性不能滥用一个页面几十个元素都加will-change反而会因为合成层太多把内存吃爆。像我们这种最多同时 3 张卡片的场景加上完全没问题。还有一个小细节拖拽过程中我给每张卡片的 transform 里同时包含translate和scale这两个都写在同一个 transform 中。浏览器对单次 transform 变化的处理效率是最高的尽量避免把位移放在外层容器、缩放放在内层容器那样会变成两个合成层同步变化反而增加开销。5.2 移动端事件兼容性touch 和 mouse 的坑移动端触摸事件和桌面端鼠标事件同时绑定的前提是搞清楚它们之间的相互干扰。我建议端口明确的原则真机移动端只响应touchstart/touchmove/touchend。鼠标事件在手机上也会派发模拟器但真机上触摸后多半会触发对应的兼容鼠标事件如果两个都绑一次拖动会触发两次逻辑。桌面端开发只响应mouse事件。桌面 Chrome DevTools 里模拟触摸时鼠标事件依然会触发容易混。那组件里为什么两个都绑因为我在onStart/onMove/onEnd里做了统一处理——通过e.touches是否存在来区分事件来源const clientX e.touches ? e.touches[0].clientX : e.clientX这样触摸事件走 touches鼠标事件走 clientX不会重复。但有一个场景必须处理用户在触摸移动过程中浏览器会派发一个兼容性的mousemove导致onMove被调两次偏移计算重复叠加。我的规避方案是onMove开头判断e.touches时同时判断事件类型距离——如果触摸事件之后紧接着又来了鼠标事件直接忽略。实际项目中我用一个简单的标志位处理let lastEventType function onMove(e) { const type e.touches ? touch : mouse if (lastEventType lastEventType ! type Date.now() - lastEventTime 500) return lastEventType type lastEventTime Date.now() // 后续计算 }这段代码的意思是500ms 内如果事件来源从 touch 切到 mouse或反过来直接忽略后面那个。实际测试中这个判断能挡住绝大多数兼容性事件。touch-action: none这个 CSS 属性我在前面提过这里再强调一遍。它在移动端的作用是告诉浏览器这个元素上的手势操作交给 JS 处理不要自己滚动页面。不加这个属性在手机上拖卡片时页面会跟着滚动、或者触发浏览器的回弹效果卡片根本拖不动。桌面端不受这个属性影响所以如果只测桌面端很容易漏掉这个坑。5.3 防止选中的细节user-select 与长按卡片能不能拖动和文本是否被选中是一对天然冲突。手指按住卡片拖拽时浏览器会把按住 移动识别成文本选择操作卡片上如果有名字、年龄等文字松开后可能会看到文字被蓝底选中非常难看。最简单粗暴的解法是给卡片加user-select: none。但如果卡片内部有需要复制的文本比如简介详情就要把那个具体元素重新设回user-select: text。这块我建议在单卡组件里统一处理.swipe-card { user-select: none; -webkit-user-select: none; } .swipe-card__copyable-text { user-select: text; -webkit-user-select: text; }另外长按卡片时iOS 会有放大镜或者链接预览的交互也会干扰拖拽。针对这个问题可以在 CSS 里再补一条.swipe-card { -webkit-touch-callout: none; }这句是 iOS Safari 专用的用来禁用长按后的弹窗菜单复制、分享等。安卓上没有对应的问题不需要处理。6. 常见问题与排查技巧实录6.1 我踩过的 5 个高频坑动手写这个组件前后我在测试中踩了不少坑挑几个最典型的记录下来大家遇到类似现象可以直接对照排查。现象原因解决方案手机上拖卡片页面跟着滚动外层容器没有禁掉浏览器手势给卡片加touch-action: none同时touchmove.prevent拖动时卡片不跟手有延迟transform 之外还用了 left/top 做位移全部改为 transform并加will-change: transform松手后回弹动画生硬拖拽中卡片没有 transition回弹时突然加上首帧跳变回弹时先等一帧让 transition 生效再改 offsetX或者用requestAnimationFrame包一层卡片滑出去后底下那张突然闪出来卡片移除时机太早离开动画没走完就切 currentIndex在 SwipeCard 的settle事件setTimeout 400ms之后再更新卡片堆索引桌面上拖完一次之后点一下卡片自己飞了鼠标 move/up 事件没有在 up 之后解绑或者 touchend 后没把isDragging置 false统一在onEnd里清空拖拽状态同时监听mouseleave做兜底第二行的粘手延迟问题值得多说两句。如果卡片位移用的是 transform理论上不会卡但如果卡片本身很大而且上面有大量高清图片合成层在每帧移动时还是要重绘就会感觉到延迟。优化手段是对图片做压缩、限制最大分辨率以及给卡片 z-index 排好序保证只有最上层卡片的合成层是smooth级别下层卡片可以用content-visibility: auto做渲染优化。第四行的闪出问题其实就是 4.3 节说的currentIndex更新时机问题。注意我代码里leave()的清理动作是 400ms 后触发的settleCardStack 收到settle事件后再改变currentIndex这样底层的visibleCards才会更新——此时动画刚好播完不会有任何跳动。6.2 回弹动画的保证requestAnimationFrame 是个好东西回弹动画有时候会出现首帧跳变的 bug原因是拖拽时 transform 里没有 transition松手后你把isResetting设为 true同时把offsetX清零这时新的 transform 带上了transition: 0.3s ease-out理论上应该平滑过渡但在某些浏览器上由于样式在同一帧内发生了transition 从无到有 transform 从 A 到 B两个变化浏览器会忽略这个过渡直接跳到终点。解决方法是把两个状态的变化拆到两帧让浏览器先拿到有 transition 但 offsetX 还是 A的样式下一帧再改 offsetX 到 0function reset() { isResetting.value true requestAnimationFrame(() { offsetX.value 0 offsetY.value 0 }) setTimeout(() { isResetting.value false }, 300) }这样一个 rAF 之后浏览器的 transition 机制就能正常工作了。这类样式先后依赖的问题在复杂交互组件里经常出现我的经验是优先想到用requestAnimationFrame把状态更新拆帧而不是硬调 CSS 动画时长。6.3 数据流与组件的再扩展这个组件做完之后如果要加撤销上一步功能数据流上怎么改其实只要保证 CardStack 里保存一份历史记录即可const history [] function handleSwipeRight(cardData) { history.push({ type: like, cardData }) emit(like, cardData) currentIndex.value 1 } function undo() { const last history.pop() if (!last) return currentIndex.value - 1 }currentIndex减一之后visibleCards里会重新出现那张卡片然后交给用户重新滑。这个功能在交友应用里属于高频刚需手滑误操作太常见了有了这套数据结构实现成本几乎为零。如果再往上扩展做右滑时显示心形图标、左滑时显示叉号图标这种业务层需求就往 SwipeCard 里的badge插槽传递状态就行。我目前的实现里已经预留了badgeLikeStyle和badgeNopeStyle透明度随位移变化用户滑到一半就能看到图标慢慢浮现提示继续滑就会触发——这个细节对交互的引导感很重要很多人会忽略。7. 实战调优从能用到好用我做了三件事组件写完之后我把体验调到顺手级别做了三件不大但感知明显的事。第一件事是调整回弹阻尼。直接线性回弹会让卡片啪一下弹回去缺少那种被弹簧拉回的柔和感。我在回弹 transition 上把 easing 从ease-out换成了cubic-bezier(0.18, 0.89, 0.32, 1.28)这个曲线上来会稍微超过一点再收回来手感更像弹性材质。注意不要用过强的 overshoot否则卡片会弹到反方向又回弹一次观感很差。第二件事是给离开动画加了减速感。抛飞的时候transition: transform 0.4s ease-in前快后慢模拟物体被扔出去的物理效果。如果是匀速或者加速飞出去就很假。这里单独的 translate 用内联 transition 写在cardStyle里和拖拽中的transition: none区分开不会互相干扰。第三件事是空状态的处理。stack-empty事件触发后业务层要显示暂无更多用户的占位图。但要注意把空状态渲染放在 CardStack 内部还是外面。我建议放在 CardStack 内部用v-ifcurrentIndex cards.length控制这样从最后一张卡飞走到显示空状态之间可以写一个淡入动画体验更连贯template div classcard-stack SwipeCard v-for... / div v-ifisStackEmpty classcard-stack__empty slot nameempty/slot /div /div /template script setup const isStackEmpty computed(() currentIndex.value props.cards.length) /script实际使用这个组件的时候我个人最深的体会是手势组件的代码量并不多真正花时间的全在细节调优——同一个组件拖拽阻尼差一点、回弹曲率差一点、阈值差一点手感就是天壤之别。所以我建议各位拿到代码之后不要直接上生产先准备三五台不同屏幕尺寸的设备真机滑几分钟把阈值和旋转角度调到你的业务场景最舒服的状态再发布不迟。