一、Pointer Events - 现代标准
Pointer Events 是移动端开发的首选方案, 可以统一处理鼠标、触摸、触控笔等多种输入设备.
核心事件类型
| Pointer Events | 对标的 Touch Events | 对标的 Mouse Events | 触发时机 | 冒泡 |
|---|---|---|---|---|
pointerdown |
touchstart |
mousedown |
指针按下时 | ✅ |
pointermove |
touchmove |
mousemove |
指针移动时 | ✅ |
pointerup |
touchend |
mouseup |
指针抬起时 | ✅ |
pointercancel |
touchcancel |
- | 指针被中断时 | ✅ |
pointerenter |
- | mouseenter |
指针进入元素时 | ❌ |
pointerleave |
- | mouseleave |
指针离开元素时 | ❌ |
pointerover |
- | mouseover |
指针移入元素时 | ✅ |
pointerout |
- | mouseout |
指针移出元素时 | ✅ |
gotpointercapture |
- | - | 捕获指针成功时 | ✅ |
lostpointercapture |
- | - | 失去指针捕获时 | ✅ |
基础用法
1. 获取指针信息
javascript
element.addEventListener("pointerdown", (e) => {
// 坐标信息
console.log(`位置: (${e.clientX}, ${e.clientY})`);
// 设备类型 (区分输入设备)
console.log(`类型: ${e.pointerType}`); // 'mouse', 'touch', 'pen'
// 唯一标识符 (用于多点触控)
console.log(`ID: ${e.pointerId}`);
// 是否为主指针 (第一个触点)
console.log(`主指针: ${e.isPrimary}`);
// 压力感应 (触控笔支持)
console.log(`压力: ${e.pressure}`); // 0.0 - 1.0
// 接触区域大小
console.log(`尺寸: ${e.width}x${e.height}`);
});
2. 完整的交互流程
javascript
element.addEventListener("pointerdown", (e) => {
console.log("按下");
});
element.addEventListener("pointermove", (e) => {
console.log("移动");
});
element.addEventListener("pointerup", (e) => {
console.log("抬起");
});
element.addEventListener("pointercancel", (e) => {
console.log("中断 (来电、弹窗等)");
});
实战应用
场景 1: 拖拽元素
核心技术: 指针捕获 (Pointer Capture)
javascript
const box = document.getElementById("draggable");
let offsetX, offsetY;
box.addEventListener("pointerdown", (e) => {
// 捕获指针 - 后续事件都发送到这个元素 (即使移出范围)
box.setPointerCapture(e.pointerId);
offsetX = e.clientX - box.offsetLeft;
offsetY = e.clientY - box.offsetTop;
box.style.cursor = "grabbing";
});
box.addEventListener("pointermove", (e) => {
if (box.hasPointerCapture(e.pointerId)) {
box.style.left = `${e.clientX - offsetX}px`;
box.style.top = `${e.clientY - offsetY}px`;
}
});
box.addEventListener("pointerup", (e) => {
box.releasePointerCapture(e.pointerId);
box.style.cursor = "grab";
});
场景 2: 多点触控缩放
核心技术: 使用 pointerId 区分不同触点
javascript
const canvas = document.getElementById("canvas");
const pointers = new Map(); // 存储活跃触点
let initialDistance = 0;
let initialScale = 1;
canvas.addEventListener("pointerdown", (e) => {
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
// 双指操作
if (pointers.size === 2) {
const points = Array.from(pointers.values());
initialDistance = getDistance(points[0], points[1]);
initialScale = parseFloat(canvas.style.transform.replace(/.*scale\((\d+\.?\d*)\).*/, "$1")) || 1;
}
});
canvas.addEventListener("pointermove", (e) => {
if (pointers.has(e.pointerId)) {
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 2) {
const points = Array.from(pointers.values());
const currentDistance = getDistance(points[0], points[1]);
const scale = initialScale * (currentDistance / initialDistance);
canvas.style.transform = `scale(${scale})`;
}
}
});
canvas.addEventListener("pointerup", (e) => {
pointers.delete(e.pointerId);
});
canvas.addEventListener("pointercancel", (e) => {
pointers.delete(e.pointerId);
});
// 计算两点距离
function getDistance(p1, p2) {
return Math.hypot(p2.x - p1.x, p2.y - p1.y);
}
场景 3: 滑动方向识别
javascript
let startX, startY;
const threshold = 50; // 最小滑动距离
document.addEventListener("pointerdown", (e) => {
startX = e.clientX;
startY = e.clientY;
});
document.addEventListener("pointerup", (e) => {
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
// 判断主要滑动方向
if (Math.abs(deltaX) > Math.abs(deltaY)) {
// 水平滑动
if (Math.abs(deltaX) > threshold) {
const direction = deltaX > 0 ? "右滑" : "左滑";
console.log(direction);
}
} else {
// 垂直滑动
if (Math.abs(deltaY) > threshold) {
const direction = deltaY > 0 ? "下滑" : "上滑";
console.log(direction);
}
}
});
场景 4: 长按检测
javascript
let pressTimer;
const longPressDelay = 500; // 500ms
element.addEventListener("pointerdown", (e) => {
pressTimer = setTimeout(() => {
console.log("长按触发");
// 执行长按操作
}, longPressDelay);
});
element.addEventListener("pointermove", (e) => {
// 移动超过阈值则取消长按
clearTimeout(pressTimer);
});
element.addEventListener("pointerup", (e) => {
clearTimeout(pressTimer);
});
element.addEventListener("pointercancel", (e) => {
clearTimeout(pressTimer);
});
二、手势库推荐
对于复杂的手势交互 (如旋转、捏合、多指操作), 推荐使用成熟的手势库.
选择建议
按项目类型选择:
- 原生 JS 项目: Interact.js (全功能) 或 ZingTouch (轻量)
- Vue 3 项目 : 优先
@vueuse/gesture(生态一致, Composition API) - React 项目 : 优先
@use-gesture/react(配合 react-spring 体验最佳) - 轮播 / 幻灯片: Swiper (跨框架, 功能完善)
使用示例
Interact.js (原生 JS)
javascript
import interact from "interactjs";
// 拖拽 + 缩放 + 旋转
// interact() 是 Interact.js 的核心函数, 接受 CSS 选择器, 返回一个 Interactable 对象
// 该对象支持链式调用, 可同时启用多种交互能力
interact("#element")
// .draggable() 启用拖拽功能
.draggable({
// onmove 是拖拽过程中的回调函数, 每次指针移动时触发
onmove: (event) => {
const target = event.target; // 被拖拽的 DOM 元素
// event.dx 和 event.dy 是本次移动的增量 (相对于上一次位置的偏移量)
// 从 data-x/data-y 属性中读取累计位移 (如果不存在则默认为 0)
const x = (parseFloat(target.getAttribute("data-x")) || 0) + event.dx;
const y = (parseFloat(target.getAttribute("data-y")) || 0) + event.dy;
// 使用 CSS transform 更新元素的视觉位置 (不改变 DOM 布局)
target.style.transform = `translate(${x}px, ${y}px)`;
// 将累计位移存储到 data-* 属性中, 用于下次计算
// 这是因为 transform 属性会被后续的 scale / rotate 覆盖, 无法直接读取位移值
target.setAttribute("data-x", x);
target.setAttribute("data-y", y);
},
})
// .resizable() 启用缩放功能
.resizable({
// edges 配置哪些边缘可以拖拽来调整大小
// 四个边都设置为 true 表示元素可从任意方向缩放
edges: { left: true, right: true, bottom: true, top: true },
})
// .gesturable() 启用手势功能 (主要用于移动端的双指缩放和旋转)
.gesturable({
// onmove 是手势操作过程中的回调函数
onmove: (event) => {
// event.scale 是双指缩放的比例 (相对于初始距离)
const scale = event.scale;
// event.angle 是双指旋转的角度 (单位: 度)
const rotation = event.angle;
// 应用缩放和旋转变换 (会覆盖之前的 translate 变换)
event.target.style.transform = `scale(${scale}) rotate(${rotation}deg)`;
},
});
@vueuse/gesture (Vue 3)
vue
<script setup>
import { ref } from "vue";
import { useDrag } from "@vueuse/gesture";
const el = ref(null);
const position = ref({ x: 0, y: 0 });
// 使用 useDrag 组合式函数实现拖拽
useDrag(
({ offset: [x, y] }) => {
// offset 是累计偏移量
position.value = { x, y };
},
{
domTarget: el, // 目标元素
eventOptions: { passive: false }, // 事件选项
}
);
</script>
<template>
<div
ref="el"
:style="{
transform: `translate(${position.x}px, ${position.y}px)`,
cursor: 'grab',
}"
>
拖拽我
</div>
</template>
@use-gesture/react (React)
jsx
import { useSpring, animated } from "@react-spring/web";
import { useDrag } from "@use-gesture/react";
function DraggableBox() {
// 使用 react-spring 管理动画状态
const [{ x, y }, api] = useSpring(() => ({ x: 0, y: 0 }));
// 绑定拖拽手势
const bind = useDrag(
({ offset: [ox, oy] }) => {
// 更新动画目标值
api.start({ x: ox, y: oy });
},
{
// 从当前位置开始拖拽
from: () => [x.get(), y.get()],
}
);
return (
<animated.div
{...bind()} // 绑定手势事件
style={{
x,
y,
width: 100,
height: 100,
background: "lightblue",
cursor: "grab",
touchAction: "none",
}}
>
拖拽我
</animated.div>
);
}
三、点击延迟与点透问题
300ms 点击延迟
移动浏览器为了支持双击缩放, 会在 touchend 后等待 300ms, 判断用户是否会再次点击.
解决方案一: 设置 viewport meta 标签 (推荐)
html
<meta name="viewport" content="width=device-width, initial-scale=1" />
这是最基本的要求, 现代移动端项目都应该设置此标签.
解决方案二: 使用 CSS touch-action 属性
css
button,
a {
touch-action: manipulation; /* 禁用双击缩放 */
}
解决方案三: 使用 Pointer Events
javascript
element.addEventListener("pointerdown", (e) => {
// 立即响应, 无延迟
handleClick();
});
点透问题 (Click Through)
当上层元素使用 touch 事件立即隐藏, 下层元素使用 click 事件时, 会发生点透.
javascript
maskA.addEventListener("touchend", () => {
maskA.style.display = "none"; // 立即隐藏
});
linkB.addEventListener("click", () => {
console.log("300ms 后意外触发"); // 点透!
});
解决方案一: 统一使用 Pointer Events (推荐)
javascript
maskA.addEventListener("pointerdown", () => {
maskA.style.display = "none";
});
linkB.addEventListener("pointerdown", () => {
console.log("正常触发");
});
解决方案二: 阻止默认行为
javascript
maskA.addEventListener("touchend", (e) => {
e.preventDefault(); // 阻止后续的 click 事件
maskA.style.display = "none";
});
⚠️ 注意: preventDefault() 会阻止页面滚动等默认行为, 谨慎使用.
解决方案三: 延迟隐藏
javascript
maskA.addEventListener("touchend", () => {
setTimeout(() => {
maskA.style.display = "none";
}, 350); // 延迟到 click 事件触发后
});
四、性能优化与最佳实践
性能优化
使用被动监听器
javascript
// ✅ 推荐: 使用 passive 提升滚动性能
element.addEventListener("pointermove", handleMove, { passive: true });
// ❌ 不推荐: 默认会阻塞滚动
element.addEventListener("pointermove", handleMove);
节流与防抖
javascript
// 节流 (限制触发频率)
function throttle(fn, delay) {
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime >= delay) {
fn.apply(this, args);
lastTime = now;
}
};
}
// 使用节流优化 pointermove
element.addEventListener(
"pointermove",
throttle((e) => {
console.log(e.clientX, e.clientY);
}, 16)
); // 约 60fps
及时清理事件监听
javascript
class DraggableElement {
constructor(element) {
this.element = element;
this.onPointerDown = this.onPointerDown.bind(this);
this.onPointerMove = this.onPointerMove.bind(this);
this.onPointerUp = this.onPointerUp.bind(this);
this.element.addEventListener("pointerdown", this.onPointerDown);
}
onPointerDown(e) {
this.element.setPointerCapture(e.pointerId);
this.element.addEventListener("pointermove", this.onPointerMove);
this.element.addEventListener("pointerup", this.onPointerUp);
}
onPointerMove(e) {
// 拖拽逻辑
}
onPointerUp(e) {
this.element.releasePointerCapture(e.pointerId);
this.element.removeEventListener("pointermove", this.onPointerMove);
this.element.removeEventListener("pointerup", this.onPointerUp);
}
destroy() {
this.element.removeEventListener("pointerdown", this.onPointerDown);
}
}
最佳实践
✅ 推荐做法
javascript
// 1. 优先使用 Pointer Events
element.addEventListener("pointerdown", handlePointer);
// 2. 始终设置 viewport
// <meta name="viewport" content="width=device-width, initial-scale=1">
// 3. 使用 touch-action 控制触摸行为
element.style.touchAction = "none"; // 禁用浏览器默认手势
// 4. 正确使用指针捕获
element.addEventListener("pointerdown", (e) => {
element.setPointerCapture(e.pointerId);
});
element.addEventListener("pointerup", (e) => {
element.releasePointerCapture(e.pointerId);
});
// 5. 处理 pointercancel 事件
element.addEventListener("pointercancel", (e) => {
// 清理状态
});
❌ 避免的做法
javascript
// ❌ 混用不同类型的事件
element.addEventListener("touchstart", handleTouch);
button.addEventListener("click", handleClick);
// ❌ 忘记释放指针捕获
element.addEventListener("pointerdown", (e) => {
element.setPointerCapture(e.pointerId);
// 忘记在 pointerup 中释放
});
// ❌ 不处理 pointercancel
// 可能导致状态错乱
// ❌ 过度使用 preventDefault
element.addEventListener("touchmove", (e) => {
e.preventDefault(); // 会阻止页面滚动
});
CSS touch-action 属性
css
/* 允许所有触摸操作 (默认) */
.default {
touch-action: auto;
}
/* 禁用所有触摸操作 */
.no-touch {
touch-action: none;
}
/* 仅允许滚动 */
.scroll-only {
touch-action: pan-x pan-y;
}
/* 禁用双击缩放 */
.no-zoom {
touch-action: manipulation;
}
/* 仅允许水平滚动 */
.horizontal-scroll {
touch-action: pan-x;
}
五、Touch Events 快速参考
⚠️ Legacy API: Touch Events 已被标记为遗留 API, W3C 推荐使用 Pointer Events.
适用场景: 仅在需要兼容 iOS 13 以下、Android 5.0 以下设备时使用.
核心事件
| 事件名 | 触发时机 |
|---|---|
touchstart |
手指触摸到屏幕时 |
touchmove |
手指在屏幕上滑动时 |
touchend |
手指离开屏幕时 |
touchcancel |
系统中断触摸行为时 |
基础用法
javascript
element.addEventListener("touchstart", (e) => {
const touch = e.touches[0];
console.log(touch.clientX, touch.clientY);
});
element.addEventListener("touchend", (e) => {
// 注意: touchend 时 touches 为空, 需要用 changedTouches
const touch = e.changedTouches[0];
console.log(touch.clientX, touch.clientY);
});
TouchEvent 属性
touches: 当前屏幕上所有手指的列表targetTouches: 当前元素上所有手指的列表changedTouches: 本次事件涉及的触点列表
迁移到 Pointer Events
javascript
// Touch Events
element.addEventListener("touchstart", (e) => {
const touch = e.touches[0];
const x = touch.clientX;
const y = touch.clientY;
});
// 改为 Pointer Events
element.addEventListener("pointerdown", (e) => {
const x = e.clientX;
const y = e.clientY;
});