多点触控技术实现原理与前端开发实战经验分享
又踩坑了,多点触控手势识别乱成一锅粥
上周在搞一个画板功能,用户要在手机上用两个手指缩放、旋转画布。听起来挺简单的对吧?结果 touchstart、touchmove、touchend 一套下来,手指一多就各种鬼畜:缩放方向反了、旋转角度跳变、甚至单指拖动也会触发双指逻辑。折腾了整整两天,头发都快薅秃了。
一开始以为是数学问题,其实是事件处理顺序搞错了
最开始我天真地以为只要拿到两个 touch 点的坐标,算个距离变化就是缩放比例,算个向量夹角就是旋转角度。代码写得还挺“优雅”:
let startDistance = 0;
let startAngle = 0;
function handleTouchStart(e) {
if (e.touches.length === 2) {
const [t1, t2] = e.touches;
startDistance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
startAngle = Math.atan2(t2.clientY - t1.clientY, t2.clientX - t1.clientX);
}
}
function handleTouchMove(e) {
if (e.touches.length === 2) {
const [t1, t2] = e.touches;
const currentDistance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
const currentAngle = Math.atan2(t2.clientY - t1.clientY, t2.clientX - t1.clientX);
const scale = currentDistance / startDistance;
const rotate = (currentAngle - startAngle) * 180 / Math.PI;
// 应用 transform...
}
}
结果一上手就翻车:手指稍微抖一下,或者第三个手指不小心碰到屏幕(比如握持姿势),e.touches.length 就变成 3 了,整个逻辑直接崩掉。更恶心的是,iOS 上有时候 touchmove 事件里 touches 数量会突然从 2 跳到 1 再跳回 2,导致计算出来的 scale 和 rotate 像抽风一样。
关键教训:别只看 touches.length,要看 identifier!
后来翻 MDN 文档才发现,每个 touch 对象有个 identifier 属性,从 touchstart 开始一直到 touchend 都不会变。也就是说,真正靠谱的做法不是数手指数量,而是记录我们关心的那两个手指的 identifier,后续只认这两个 ID。
这里我踩了个大坑:一开始我还以为 identifier 是递增的整数,结果发现不同浏览器行为不一致,有的从 0 开始,有的随机数。所以绝对不能假设 identifier 的值,只能存储并匹配。
核心代码就这几行
重构后的思路很清晰:
- 在
touchstart时,如果检测到两个 touch,就把它们的 identifier 存起来 - 后续
touchmove只处理包含这两个 identifier 的事件 - 任意一个手指离开(
touchend或touchcancel),就重置状态
完整实现如下(已亲测在 iOS Safari 和 Android Chrome 正常工作):
let trackedTouches = null; // 存储两个手指的 identifier
let initialTransform = { scale: 1, rotate: 0, dx: 0, dy: 0 };
function getTouchByIdentifier(touches, id) {
for (let i = 0; i < touches.length; i++) {
if (touches[i].identifier === id) return touches[i];
}
return null;
}
function calculateGesture(touches) {
if (!trackedTouches) return null;
const t1 = getTouchByIdentifier(touches, trackedTouches.id1);
const t2 = getTouchByIdentifier(touches, trackedTouches.id2);
if (!t1 || !t2) return null; // 其中一个手指已经离开了
const x1 = t1.clientX, y1 = t1.clientY;
const x2 = t2.clientX, y2 = t2.clientY;
const distance = Math.hypot(x2 - x1, y2 - y1);
const angle = Math.atan2(y2 - y1, x2 - x1);
return { distance, angle };
}
function handleTouchStart(e) {
if (e.touches.length === 2 && !trackedTouches) {
// 开始追踪这两个手指
trackedTouches = {
id1: e.touches[0].identifier,
id2: e.touches[1].identifier
};
const gesture = calculateGesture(e.touches);
if (gesture) {
initialTransform.scale = 1;
initialTransform.distance = gesture.distance;
initialTransform.angle = gesture.angle;
}
e.preventDefault(); // 阻止默认缩放
}
}
function handleTouchMove(e) {
if (!trackedTouches) return;
const gesture = calculateGesture(e.touches);
if (!gesture) return;
const scale = gesture.distance / initialTransform.distance;
const rotate = (gesture.angle - initialTransform.angle) * 180 / Math.PI;
// 这里应用你的 transform 逻辑
applyTransform({ scale, rotate });
e.preventDefault(); // 必须阻止默认行为,否则页面会滚动或缩放
}
function handleTouchEnd(e) {
// 检查是否是我们追踪的手指离开了
if (trackedTouches) {
let shouldReset = false;
for (let i = 0; i < e.changedTouches.length; i++) {
const id = e.changedTouches[i].identifier;
if (id === trackedTouches.id1 || id === trackedTouches.id2) {
shouldReset = true;
break;
}
}
if (shouldReset) {
trackedTouches = null;
}
}
}
// 绑定事件(记得用 passive: false,否则 preventDefault 无效)
element.addEventListener('touchstart', handleTouchStart, { passive: false });
element.addEventListener('touchmove', handleTouchMove, { passive: false });
element.addEventListener('touchend', handleTouchEnd, { passive: false });
element.addEventListener('touchcancel', () => { trackedTouches = null; }, { passive: false });
踩坑提醒:这三点一定注意
第一,passive: false 是必须的。现代浏览器为了滚动性能,默认把 touch 事件设为 passive,这时候调用 preventDefault() 会报错且无效。结果就是页面跟着手指滚动,你的手势根本没法用。这点在 Chrome DevTools 里会有警告,但很多人忽略。
第二,别忘了 touchcancel。iOS 在某些情况下(比如来电、系统弹窗)会触发 touchcancel 而不是 touchend,不处理的话状态就卡住了,下次手势直接失效。
第三,单指和多指要分开处理。上面代码只处理双指,如果你还需要单指拖动,得另外写一套逻辑,并且确保两者互不干扰。我的做法是:一旦进入双指模式,就禁止单指逻辑;双指结束才重新允许单指。
改完后还有一两个小问题,但无大碍
现在基本稳了,不过在低端 Android 机上偶尔会出现轻微的“跳帧”——就是旋转时角度突然跳一下。怀疑是 touchmove 触发频率不够高,或者系统做了 touch 合并。但用户反馈说不影响使用,就没深究了。毕竟不是做专业绘图软件,能用就行。
另外,如果要做三指、四指手势,这个模式也能扩展,就是代码会更啰嗦。不过现实中真用三指操作的场景太少了,我暂时没遇到。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有现成的库能处理得更优雅?或者对 touch 事件流有更深的理解?求分享!

暂无评论