零依赖滑卡交互组件:Pointer Events、rAF 与 transform 实战

简介:Tinder-1.2.2 源码包面向具备一定 Java 基础、希望研读开源工具实现原理的开发者,围绕数据匹配、推荐或算法优化等通用场景,提供一套可本地解压研究的工程材料。压缩包共 53 个文件,约 127KB,以 50 个 Java 源文件为主体,另含 XML 构建配置、HTML 说明页面和 serialized 序列化数据,便于从代码组织、构建入口到运行数据几个层面理解项目结构。已有 1636 人学习下载,说明该版本在源码阅读与工具实践方面具有一定参考价值。读者可结合主源码、测试目录与构建配置,梳理模块划分、接口设计与异常处理方式,并从中体会高效数据处理和性能优化的实现思路,适合用作课程设计、项目借鉴或二次开发前的研读样本。

1. 一个滑卡交互,从零写到能用要多久

产品丢过来一张动图:左右滑卡片,滑走一张补一张,松手要有惯性。看上去十分钟的活,真动手就是另一回事——拖拽要跟手,松手要判方向,飞出要有轨迹,卡片栈要回收,按钮还得和手势走同一条路,最要命的是移动端上手指一滑,整个页面跟着弹。这个叫 tinder 的源码包把这两三天的事提前做完了:一个零依赖的滑卡交互组件,用 Pointer Events 同时接管鼠标、触摸和触控笔,rAF 驱动惯性与回弹,DOM 只保留可见的几张卡循环复用。它适合两类人:想给工具站或 H5 demo 加一段“刷卡片”交互的前端,以及想找一份能直接读、能改的手势加动画范例的开发者。整个包不大,读一遍比查十篇手势教程快。

2. 技术选型与目录结构:为什么这个组件不引任何框架

2.1 选型:Pointer Events + rAF + transform 三件套

第一个决定是“要不要依赖框架”。挂到 React 或 Vue 上确实省事,但组件就走不出那个生态了,一个纯静态页面想用还得先装一套构建链。第二个决定是“要不要引手势库”。手势库能力全,代价是体积和一层抽象——你要调 20px 的回弹阻尼,得先翻它的源码看参数落在哪。

最后的组合是:Pointer Events 做输入统一,requestAnimationFrame 自己做补间,CSS transform 做渲染输出。三者各管一段,边界清楚。

关于渲染,有一条踩过坑的硬规则:拖拽过程中绝不用 CSS transition,也不要动 left/top。transition 有启动延迟,手指已经走 30px 了卡片才跟上,跟手感立刻崩;left/top 每帧触发重排,安卓中端机上肉眼可见掉帧。只写transform: translate3d()加rotate(),交给合成层。

/* src/styles.css —— 卡片层绕不开的三条规则 */ .deck { position: relative; touch-action: none; /* 这块区域的触摸手势归 JS,浏览器别拿去滚页面 */ user-select: none; /* 长按不选中文字,否则拖动时会拖出一个蓝色选框 */ -webkit-tap-highlight-color: transparent; } .deck .card { position: absolute; inset: 0; transform: translate3d(0, 0, 0); will-change: transform; /* 提合成层,但只在拖动期间开,见 3.1 */ backface-visibility: hidden; /* 部分机型不加这行,旋转时有 1px 抖动 */ }

touch-action: none是移动端不抖的前提,缺了它,手指往下带一点角度,页面就开始橡皮筋回弹,卡片跟着一起飘。will-change写在这里只是兜底,实际运行时由 JS 在 pointerdown 时加上、pointerup 后摘掉——常驻的合成层会吃显存,卡片一多就顶不住。backface-visibility属于玄学项,但确实有效,加了之后旋转边角不再发虚。

2.2 目录结构:七个源文件各管一段

整个源码包按“一层只干一件事”切开,任何一个 bug 基本能靠“它属于哪一层”快速定位。

路径职责什么时候会改它
src/index.js对外入口,收参数、建实例、暴露 API加新配置项时
src/deck.js数据指针、DOM 栈、上锁与撤销栈改卡片生命周期时
src/gesture.js指针事件收集,输出位移与速度调手势灵敏度时
src/physics.js阈值判定、飞出时长、缓动曲线调手感时
src/animator.jsrAF 补间与取消基本不动
src/renderer.js建卡、写 transform、卡片内容渲染换成业务卡片时
src/styles.css布局与合成层规则换皮肤时
demo/index.html最小可跑示例学习时

分层约定只有一句:手势层不碰样式,动画层不碰数据,数据层不碰 DOM。违反它的后果很具体——比如在手势层直接改node.style.transform,那么动画层启动时拿不到“当前真实位置”,卡片会先跳一下再飞出去。

// src/index.js —— 入口只做三件事:合并参数、建实例、把手势灌进去 import { Deck } from './deck.js'; import { bindGesture } from './gesture.js'; import './styles.css'; export class SwipeDeck { constructor(el, options = {}) { this.opts = { data: [], visible: 3, // 同时存在的 DOM 卡片数 threshold: 0.25, // 位移超过容器宽度的 25% 就判为滑出 velocity: 0.5, // 速度阈值 px/ms,甩一下也能滑出 rotateRatio: 0.08, // 每 1px 位移对应多少度旋转 ...options, }; this.deck = new Deck(el, this.opts); // deck.gestureHandlers 是一个 { onStart, onMove, onEnd } 的对象 this.unbind = bindGesture(el, this.deck.gestureHandlers); } swipe(dir) { return this.deck.programmaticSwipe(dir); } // 按钮走这条 undo() { return this.deck.undo(); } destroy() { this.unbind(); this.deck.destroy(); } } export default SwipeDeck;

visible决定性能上限:3 张够用,5 张在低端机上开始吃力。threshold和velocity是手感的两个开关,前者管“拖多远算数”,后者管“甩多快算数”,两个条件是或的关系。rotateRatio影响的是观感不是逻辑,0.08 意味着卡片横移 300px 时转 24 度,再多就转过头的。

2.3 一次滑动在架构里走了哪几步

把链路摊平看只有六步,每一步的输入输出都是纯数据,这是整个包好调试的根本原因。

第一步,pointerdown落在卡片上,gesture 记录起点坐标、时间戳,给卡片加will-change。第二步,pointermove每帧算出dx / dy,同时用最近 100ms 的采样点算横向速度,回调给 deck。第三步,deck 不做判定,把dx直接转成 transform 写给 renderer,卡片跟手走。第四步,pointerup把最终的dx和vx交给 physics,由它返回'left' / 'right' / null。第五步,返回方向就启动飞出补间,返回 null 就回弹到原位。第六步,补间结束触发onSwipe回调,deck 把这张卡从栈里摘掉,从data尾部补一张新的进栈底。

这条链上唯一有状态的是 deck,gesture、physics、animator 全是无状态的纯函数或纯 DOM 操作。所以只要“卡片位置不对”这类问题出现,八成是 deck 的 cursor 算错了,而不是动画有问题。

3. 核心模块拆解:从按下到卡片飞出的完整链路

3.1 手势层:setPointerCapture 与 4px 启动阈值

手势层最容易出问题的地方不是算位移,而是“手指滑出卡片范围后事件断了”。桌面端用鼠标拖快了会甩出卡片边界,移动端更常见——手指一偏就出界,卡片当场粘连在屏幕上不跟手,用户以为卡死。

解法是setPointerCapture,把指针捕获到这个元素上,出了边界事件依然派发到它。

// src/gesture.js —— 只负责把指针动作翻译成位移和速度,不碰任何样式 const START_SLOP = 4; // px,小于这个数不算拖动,避免点击被误判成滑卡 const SAMPLE_WINDOW = 100; // ms,只取最近 100ms 的速度,甩动判定才准 export function bindGesture(el, { onStart, onMove, onEnd }) { let pointerId = null; let startX = 0, startY = 0; let lastX = 0, lastT = 0; let samples = []; // 最近若干个 (x, t) 采样点 let dragging = false; function down(e) { if (pointerId !== null) return; // 多指按下只认第一根 pointerId = e.pointerId; el.setPointerCapture(pointerId); // 关键:出界后事件仍回到 el startX = lastX = e.clientX; startY = e.clientY; lastT = performance.now(); samples = [{ x: e.clientX, t: lastT }]; dragging = false; onStart?.({ x: e.clientX, y: e.clientY }); } function move(e) { if (e.pointerId !== pointerId) return; const dx = e.clientX - startX; const dy = e.clientY - startY; // 竖直方向的抖动直接忽略,滑卡只认横向 if (!dragging) { if (Math.hypot(dx, dy) < START_SLOP) return; if (Math.abs(dy) > Math.abs(dx)) return; // 用户其实在上下滚,放行 dragging = true; } const now = performance.now(); samples.push({ x: e.clientX, t: now }); while (samples.length > 2 && now - samples[0].t > SAMPLE_WINDOW) samples.shift(); onMove?.({ dx, dy, progress: dx / el.clientWidth }); } function up(e) { if (e.pointerId !== pointerId) return; el.releasePointerCapture(pointerId); pointerId = null; if (!dragging) { onEnd?.({ dx: 0, dy: 0, vx: 0, moved: false }); return; } const first = samples[0]; const last = samples[samples.length - 1]; const dt = last.t - first.t; const vx = dt > 0 ? (last.x - first.x) / dt : 0; // px/ms,向右为正 dragging = false; onEnd?.({ dx: e.clientX - startX, dy: e.clientY - startY, vx, moved: true }); } el.addEventListener('pointerdown', down); el.addEventListener('pointermove', move); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); // 来电、系统手势打断也要收尾 return () => { el.removeEventListener('pointerdown', down); el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); }; }

几个参数值得单独说。START_SLOP = 4是为了区分点击和拖动:手指按下去的瞬间一定会抖两三个像素,没有这个阈值,所有点击都会被当成拖动,卡片会轻微位移再回弹,看着像接触不良。

速度采样只取最近 100ms 是血泪经验。如果拿“总位移 ÷ 总时长”算平均速度,一个“先慢慢拖 800ms 再猛地一甩”的动作会被平均成很慢,卡片不飞出去,用户会觉得甩了没用。取最近 100ms 的两三个点算瞬时速度,甩动才判得出来。

pointercancel必须挂,而且要和pointerup走同一段收尾逻辑。不加的话,滑卡时来一通电话、或者系统从边缘划出返回手势,指针会被浏览器强制收回,卡片永远停在半路。

3.2 判定层:位移阈值与速度阈值是或的关系

判定逻辑本身只有十几行,但“什么时候算滑出”这个决定手感,值得单独成模块。

// src/physics.js —— 纯函数,输入位移和速度,输出方向或者 null export const DISTANCE_RATIO = 0.25; // 位移超过容器宽度的 1/4 算滑出 export const VELOCITY_GATE = 0.5; // px/ms,约等于 500px/s export function decide(dx, vx, deckWidth) { // 甩动和拖动方向相反时不算数,防止“往右拖一半然后往左甩”这种糊操作 if (vx !== 0 && Math.sign(vx) !== Math.sign(dx)) return null; const farEnough = Math.abs(dx) > deckWidth * DISTANCE_RATIO; const fastEnough = Math.abs(vx) > VELOCITY_GATE; if (farEnough || fastEnough) return dx > 0 ? 'right' : 'left'; return null; } // 飞出时长按速度反推:甩得越快,动画越短,但夹在 180~420ms 之间 export function flyDuration(dx, vx, deckWidth) { const remaining = Math.max(deckWidth, Math.abs(dx)) - Math.abs(dx); const speed = Math.max(Math.abs(vx), 0.3); // 兜底 0.3px/ms,防止除零后动画爆长 return Math.min(420, Math.max(180, remaining / speed)); } export const EASE_OUT = (p) => 1 - Math.pow(1 - p, 3); // 飞出用 export const EASE_BACK = (p) => { // 回弹用 const c = 1.70158; return 1 + (c + 1) * Math.pow(p - 1, 3) + c * Math.pow(p - 1, 2); };

farEnough || fastEnough这个“或”是关键。改成“与”的后果是:快速轻甩永远不生效,用户必须拖够距离,手感立刻变得很笨。反过来只留速度判定,慢慢拖到底也不会滑出,同样反直觉。

EASE_BACK带回弹过冲,用在取消拖动时,卡片会稍微冲过原位再回来,视觉上像被橡皮筋拽住。EASE_OUT用在飞出,加速度递减,卡片是“甩出去”而不是“匀速平移出去”。这两个曲线换掉任意一个,整个组件的手感就变了,调之前先想清楚要哪一种。

3.3 动画层:一个能取消的 rAF 补间

动画层只有 30 行,但必须能取消。没有取消能力的补间,是快速连滑时卡片错位的直接原因。

// src/animator.js —— 极简补间,返回一个 cancel 函数 export function tween({ from, to, duration, easing, onUpdate, onDone }) { const t0 = performance.now(); let raf = 0; let cancelled = false; function frame(now) { if (cancelled) return; const p = Math.min(1, (now - t0) / duration); onUpdate(from + (to - from) * easing(p)); if (p < 1) { raf = requestAnimationFrame(frame); } else { onDone?.(); } } raf = requestAnimationFrame(frame); return function cancel() { cancelled = true; // 双保险:即使有一帧已经排进队列也不会继续写 cancelAnimationFrame(raf); }; }

cancelled这个标志位不是冗余。cancelAnimationFrame只能取消“还没执行”的那一帧,如果取消动作恰好发生在某帧回调内部(比如在onUpdate里触发了下一次滑动),那一帧仍在跑,会用一个过期的进度值覆盖新动画。加个布尔判断,一笔带过的事。

duration由 3.2 的flyDuration算出来,easing按场景选。onUpdate拿到的是插值后的数值(比如 x 位移),由调用方决定写成什么 transform。动画层不认识卡片,也不认识数据,所以它不需要知道这些东西。

3.4 卡片栈:只留三张 DOM,滑一张补一张

如果数据有 200 条就渲染 200 张卡片,首屏就要几秒,内存也顶不住。这里的做法是栈管理:屏幕上永远只有visible张,滑走一张从底部补一张。

// src/deck.js —— 数据指针 + DOM 栈,全部状态集中在实例上 import { tween } from './animator.js'; import { decide, flyDuration, EASE_OUT, EASE_BACK } from './physics.js'; import { createCard, writeTransform, resetTransform } from './renderer.js'; const REBOUND_MS = 220; // 回弹固定时长,太快像抽搐,太慢像卡住 export class Deck { constructor(el, opts) { this.el = el; this.opts = opts; this.data = opts.data; this.cursor = 0; // 已消费的条数 this.cards = []; // 从顶到底,cards[0] 是最上面那张 this.locked = false; // 动画期间上锁,防止连滑打穿栈 this.history = []; // 撤销用的后悔药 this.fill(); } fill() { while (this.cards.length < this.opts.visible) { const index = this.cursor + this.cards.length; const item = this.data[index]; if (!item) break; const depth = this.cards.length; const node = createCard(this.el, item, this.opts.render); // 越往下的卡片缩放越小小一点、下移一点,做出层叠感 writeTransform(node, 0, depth * 8, 1 - depth * 0.04); node.style.zIndex = String(100 - depth); this.cards.push({ node, item }); } } get gestureHandlers() { return { onStart: () => { const top = this.cards[0]; if (!top || this.locked) return; top.node.style.willChange = 'transform'; // 拖动期间才提层 }, onMove: ({ dx, dy }) => { const top = this.cards[0]; if (!top || this.locked) return; const rot = dx * this.opts.rotateRatio; writeTransform(top.node, dx, dy * 0.3, 1, rot); // 纵向只跟 30%,压手感 }, onEnd: (payload) => { const top = this.cards[0]; if (!top || this.locked) return; top.node.style.willChange = ''; if (!payload.moved) return; this.settle(top, payload); }, }; } settle(top, { dx, dy, vx }) { const dir = decide(dx, vx, this.el.clientWidth); if (!dir) { // 没到阈值,回弹到原位 this.locked = true; tween({ from: { x: dx, y: dy * 0.3, rot: dx * this.opts.rotateRatio }, to: { x: 0, y: 0, rot: 0 }, duration: REBOUND_MS, easing: EASE_BACK, onUpdate: ({ x, y, rot }) => writeTransform(top.node, x, y, 1, rot), onDone: () => { this.locked = false; }, }); return; } // 到了阈值,飞出去 this.locked = true; const width = this.el.clientWidth; const targetX = dir === 'right' ? width * 1.6 : -width * 1.6; const duration = flyDuration(dx, vx, width); tween({ from: { x: dx, rot: dx * this.opts.rotateRatio }, to: { x: targetX, rot: (dir === 'right' ? 1 : -1) * 30 }, duration, easing: EASE_OUT, onUpdate: ({ x, rot }) => writeTransform(top.node, x, 0, 1, rot), onDone: () => this.commit(top, dir), }); } commit(top, dir) { top.node.remove(); // DOM 立刻销毁,不留垃圾 this.cards.shift(); this.cursor += 1; this.history.push({ item: top.item, dir }); this.opts.onSwipe?.(dir, top.item); this.locked = false; this.fill(); // 从底部补一张进来 if (this.cards.length === 0) this.opts.onEmpty?.(); } programmaticSwipe(dir) { const top = this.cards[0]; if (!top || this.locked) return false; this.locked = true; const width = this.el.clientWidth; const targetX = dir === 'right' ? width * 1.6 : -width * 1.6; tween({ from: { x: 0, rot: 0 }, to: { x: targetX, rot: (dir === 'right' ? 1 : -1) * 30 }, duration: 260, easing: EASE_OUT, onUpdate: ({ x, rot }) => writeTransform(top.node, x, 0, 1, rot), onDone: () => this.commit(top, dir), }); return true; } }

locked这个标志位是防连滑的核心。没有它,用户左右手同时快速划两下,第二个动画会在第一个还没 commit 时启动,this.cards[0]指向同一张卡,两个 tween 同时往它身上写 transform,最后卡片飞到一半停住,栈里多出一张永远滑不掉的僵尸卡。

onMove里dy * 0.3是刻意衰减。纵向完全跟手会让卡片像块泥巴,跟 30% 既有反馈又不影响横向判定。

programmaticSwipe和手势走的是同一个commit,这样按钮点击和手指滑动产生的状态变更完全一致,onSwipe回调里不需要区分来源。很多人图省事在按钮里直接改数据不跑动画,结果是计数和 DOM 对不上。

commit里先remove()再shift(),顺序不能反。反了的话fill()里算depth时会因为数组长度提前变化,新补进来的卡片层级错位,视觉上就是卡片忽前忽后。

4. 跑起来:启动、最小示例与参数表

4.1 环境与两条启动命令

源码包不需要 Node 之外的东西,Node 16 以上就能跑。demo 用的是最朴素的静态服务,没有 HMR,改完刷新页面——这是刻意的,组件本身零依赖,不想为了 demo 引一整套构建工具。

# 装依赖(只有 rollup 和 http-server 两个开发依赖) npm install # 起本地服务,默认 5173 端口,打开 demo/index.html 就能滑 npm run dev # 打包,产出 ESM 和 UMD 两份 npm run build

npm run build会输出dist/tinder.esm.js和dist/tinder.umd.js。前者给现代打包器用,后者给<script>直接引。如果只想验证功能,跳过打包直接跑 demo 更快。

跑起来之后如果卡片完全不动,先看控制台有没有 404——styles.css没加载上时,卡片会全部叠在文档流里,看起来像一坨。这是最常见的“跑不起来”,不是代码问题。

4.2 三十行跑通一个最小页面

不用打包器,直接引 ESM 版本,一个 HTML 文件就够。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8" /> <!-- 移动端必须,否则 1px 边框和手势一起糊 --> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <title>滑卡示例</title> <style> #deck { width: min(92vw, 380px); height: 520px; margin: 40px auto; position: relative; } #deck .card { border-radius: 16px; overflow: hidden; background: #fff; box-shadow: 0 8px 24px rgba(0,0,0,.12); } </style> </head> <body> <div id="deck"></div> <script type="module"> import { SwipeDeck } from './dist/tinder.esm.js'; // 200 条假数据,验证一下滑到底会不会卡 const data = Array.from({ length: 200 }, (_, i) => ({ id: i, title: `卡片 ${i + 1}`, desc: `这是第 ${i + 1} 条内容`, })); const deck = new SwipeDeck(document.querySelector('#deck'), { data, visible: 3, render: (item) => `<div class="card"> <h3>${item.title}</h3> <p>${item.desc}</p> </div>`, onSwipe: (dir, item) => console.log('滑走了', dir, item.id), onEmpty: () => console.log('滑完了,该去接口拉下一批了'), }); // 键盘也能滑,顺手把按钮联动也接上 document.addEventListener('keydown', (e) => { if (e.key === 'ArrowLeft') deck.swipe('left'); if (e.key === 'ArrowRight') deck.swipe('right'); }); </script> </body> </html>

render返回的是字符串,组件内部用innerHTML塞进卡片容器。如果返回的是已经建好的 DOM 节点,createCard会走appendChild分支,两种都支持——数据量大的时候用节点更省,避免每条都解析一次 HTML 字符串。

onEmpty只在栈彻底空了才触发,滑走一张但底部还有卡时不会触发。接分页接口就在这个回调里加数据,然后重新调一次fill()。

4.3 参数表:哪些能改、改了会怎样

参数类型默认值改了会怎样
dataArray[]数据源,支持运行中整体替换,替换后需要重新fill()
renderFunction内置简单模板返回 HTML 字符串或 DOM 节点,决定卡片长什么样
visibleNumber3改成 5 视觉更厚,但低端机上合成层压力明显变大
thresholdNumber0.25调小更容易滑出,调到 0.1 会误触,调到 0.5 会拖不动
velocityNumber0.5调低更容易甩飞,调到 0.2 时轻点一下都可能误判
rotateRatioNumber0.08只影响观感,0.15 以上会显得夸张
onSwipeFunctionnull参数(direction, item),方向是'left'或'right'
onEmptyFunctionnull栈空时触发,接下一页数据的入口

threshold和velocity是一对,改一个的时候顺手看一眼另一个。只把threshold调到 0.1、velocity还是 0.5,会出现“慢慢拖一点就滑走,但快速轻甩反而不动”的割裂感。

5. 避坑与排查:滑卡交互里最容易翻车的五件事

5.1 手指滑出卡片范围,卡片就不跟手了

现象:桌面端鼠标拖快了甩出卡片边缘,或者移动端手指一偏出界,卡片当场停在半路,之后怎么动都没反应,直到松手才恢复。

原因:指针事件默认只在按下时命中的那个元素上派发,指针移出元素边界后,pointermove就不再落到卡片上了。如果这时还在用pointermove累计位移,值会停住。

解决:pointerdown里立刻调setPointerCapture(e.pointerId),把指针捕获到卡片元素,出界后事件依然回来。同时pointerup和pointercancel里都要releasePointerCapture,漏了会让后续的点击穿透到下层元素——表现是点按钮没反应,点卡片反而触发了别的东西。

5.2 快速连滑两下,卡片错位或者多出一张滑不掉的

现象:连着快速划两次,第二张卡飞到一半停住,或者栈里出现一张点不动的“僵尸卡”,后面所有卡片层级全乱。

原因:第一次滑动的飞出动画还没结束(commit没执行),第二次滑动的动画就启动了,两个 tween 同时往同一个 DOM 节点写 transform,后一帧覆盖前一帧;同时this.cards[0]还是同一张卡,commit被调了两次,cursor多加了一次。

解决:locked标志位在动画启动前置 true、commit或回弹结束后置 false,所有入口(手势onEnd、programmaticSwipe)统一检查。不要试图靠debounce去防——用户甩得快时 debounce 会吞掉合法操作,感觉像卡了。

5.3 移动端上滑卡,整个页面跟着上下弹

现象:手机上按住卡片往斜下方拖,卡片动的同时页面也在滚动,松手后页面回弹,卡片位置全错。

原因:两个问题叠加。一是容器没写touch-action: none,浏览器把这段手势当成页面滚动的候选;二是用了touchmove却没传{ passive: false },preventDefault()被浏览器忽略且在控制台报警告。

解决:优先写touch-action: none,这是声明式的,比preventDefault可靠;如果必须用touchmove,一定addEventListener('touchmove', fn, { passive: false })。另外纵向位移在 transform 里衰减到 30%(见 3.4),既给反馈又不至于让人以为页面在跟着动。

5.4 飞出动画掉帧,中端安卓机上像幻灯片

现象:桌面端丝滑,一到手机上卡片飞出就一顿一顿,动画时间明显变长。

原因:九成是在改left/top/margin,每帧触发一次重排;剩下的一成是在动画回调里读offsetWidth、getBoundingClientRect()这类会强制刷新布局的属性——读一次就强制同步一次,帧率直接砍半。

解决:只写transform: translate3d(),宽高在布局阶段一次算好存在实例上(this.el.clientWidth缓存起来,别每帧读)。用 DevTools 的 Performance 面板录三秒,看有没有大片紫色 Layout 区块,有就说明还在重排。

5.5 松手后卡片粘在原地,不回弹也不飞

现象:拖到一半松手,卡片就那么停着,什么都不发生。

原因:tween没有正确启动或者被误取消了。常见两种:一是在onEnd里先调了cancel()再调tween(),取消的是刚新建的那个;二是duration算成了 0 或NaN,(now - t0) / duration得到Infinity,Math.min(1, Infinity)其实是 1,理论上会直接跳到终点——但如果 duration 是NaN,整个比较就失效,动画永远停在第一帧。

解决:flyDuration里对速度做下限兜底(Math.max(Math.abs(vx), 0.3)),杜绝除零;tween返回的 cancel 函数只在真正需要中断时调用,别在启动新动画前顺手把手上这个取消掉。调试时在frame里打一行console.log(p),p不变就是 duration 有问题。

6. 二次开发:把它改成你业务里的卡片

换业务几乎只需要动render。商品卡就返回图片加价格,简历卡就返回姓名加技能标签,组件完全不关心内容是什么:

const deck = new SwipeDeck(el, { data: products, render: (p) => `<div class="card"> <img src="${p.cover}" alt="" loading="lazy" /> <div class="meta"><b>${p.price}</b><span>${p.title}</span></div> </div>`, onSwipe: async (dir, p) => { // 动画已经结束,这儿只做数据上报,别再碰 DOM await fetch('/api/feedback', { method: 'POST', body: JSON.stringify({ id: p.id, action: dir }), }); }, });

onSwipe触发时那张卡已经从 DOM 摘掉了,回调用途就是记数据、发请求、改外部状态。别在这个回调里再去querySelector找卡片,找不到的。

想加“上滑收藏”这种新方向,改动集中在两处:physics.js里给decide增加纵向分支(用dy和vy走同一套阈值逻辑),deck.js的settle里按方向给targetY而不是targetX。别在onMove里就提前判定方向,那会让卡片在拖动过程中突然改行为,手感很怪。

撤销功能history已经留好了,undo()要做的就是把{ item, dir }弹出来、cursor -= 1、把卡片节点重新插回栈顶并跑一段进场动画。这里有个坑:撤销后fill()不要再补卡,否则栈里会多出一张。

改完怎么验证——我一般跑三遍:第一遍把data拉到 500 条,从头滑到尾,看有没有层级错乱和内存涨;第二遍用 DevTools 的 Performance 录 5 秒连滑,帧率掉到 50 以下就去查是不是又读到布局属性了;第三遍把visible调到 8,在旧一点的安卓机上跑,扛不住就说明当前值已经是上限。

那次线上出问题,是因为我在commit里先更新了cursor再remove(),本地 200 条数据怎么测都没事,用户的收藏夹只有 3 条,滑到底直接闪退。从那以后我每次改动画回调里的顺序,都强制走一遍“只有 1 条数据”的极端用例,宁可多花五分钟。希望帮到你。

本文还有配套的精品资源,点击获取