拖拽功能实现的那些坑我帮你踩过了
我的写法,亲测靠谱
搞移动端拖拽这块,我踩过的坑能写一本书了。最早的时候就是直接用touch事件写,后来发现各种卡顿、冲突问题层出不穷。现在我一般用这个套路,稳定得很。
class DraggableElement {
constructor(element) {
this.element = element;
this.startX = 0;
this.startY = 0;
this.currentX = 0;
this.currentY = 0;
this.isDragging = false;
this.init();
}
init() {
this.element.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: false });
this.element.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
this.element.addEventListener('touchend', this.handleTouchEnd.bind(this));
this.element.addEventListener('touchcancel', this.handleTouchCancel.bind(this));
}
handleTouchStart(e) {
if (e.touches.length !== 1) return;
const touch = e.touches[0];
this.startX = touch.clientX - this.currentX;
this.startY = touch.clientY - this.currentY;
this.isDragging = true;
// 防止默认行为,避免页面滚动
e.preventDefault();
}
handleTouchMove(e) {
if (!this.isDragging || e.touches.length !== 1) return;
const touch = e.touches[0];
this.currentX = touch.clientX - this.startX;
this.currentY = touch.clientY - this.startY;
this.updatePosition();
// 继续阻止默认行为
e.preventDefault();
}
handleTouchEnd() {
this.isDragging = false;
}
handleTouchCancel() {
this.isDragging = false;
}
updatePosition() {
this.element.style.transform = translate3d(${this.currentX}px, ${this.currentY}px, 0);
}
}
这个写法的好处很明显:passive设为false保证preventDefault生效,transform比left/top性能更好,而且touchcancel事件处理掉了意外中断的情况。最重要的是,这种写法在iOS和Android上都很稳定。
这几种错误写法,别再踩坑了
最常见的坑就是忘记设置passive为false。我之前在一个电商项目里就是这么干的,结果在iPhone上拖拽元素的时候,页面也会跟着滚动,用户体验糟糕透了。
// 错误写法!这种情况下preventDefault可能无效
element.addEventListener('touchmove', function(e) {
// 各种拖拽逻辑
e.preventDefault(); // 这里可能不起作用!
});
还有人喜欢用left、top来改变位置,我劝你还是放弃吧:
// 效率低下的写法
element.style.left = x + 'px';
element.style.top = y + 'px';
每次修改都会触发重排,特别是在移动设备上,卡得你想砸手机。用transform就完全不同了,GPU加速,丝般顺滑。
另一个常见的错误是没处理多点触控:
// 错误!没有判断触摸点数量
handleTouchMove(e) {
// 没有检查touches.length,多指操作会出问题
const touch = e.touches[0];
// ...拖拽逻辑
}
想象一下用户想缩放页面,结果你的拖拽逻辑还在执行,那场面就很尴尬了。
实际项目中的坑
之前做购物车排序功能的时候,遇到一个诡异的问题:拖拽过程中突然松手,有时候会触发点击事件。折腾了半天才发现,原来是在touchend的时候没有延迟处理点击阻止。
class DraggableWithClickPrevent extends DraggableElement {
constructor(element) {
super(element);
this.dragged = false;
this.clickTimeout = null;
}
handleTouchStart(e) {
super.handleTouchStart(e);
this.dragged = false;
}
handleTouchMove(e) {
if (super.handleTouchMove(e)) {
this.dragged = true;
}
}
handleTouchEnd(e) {
super.handleTouchEnd(e);
if (this.dragged) {
// 拖拽结束后,短时间内阻止click事件
if (this.clickTimeout) {
clearTimeout(this.clickTimeout);
}
const stopClick = function(stopEvent) {
stopEvent.stopPropagation();
stopEvent.preventDefault();
stopEvent.stopImmediatePropagation();
};
this.element.addEventListener('click', stopClick, true);
this.clickTimeout = setTimeout(() => {
this.element.removeEventListener('click', stopClick, true);
}, 300); // 300ms缓冲期
}
}
}
还有一个问题是边界检测。比如拖拽弹窗的时候,不能让它拖出屏幕外:
updatePosition() {
// 限制拖拽范围
const maxX = window.innerWidth - this.element.offsetWidth;
const maxY = window.innerHeight - this.element.offsetHeight;
const boundedX = Math.max(0, Math.min(this.currentX, maxX));
const boundedY = Math.max(0, Math.min(this.currentY, maxY));
this.element.style.transform = translate3d(${boundedX}px, ${boundedY}px, 0);
}
这里要注意,如果用Math.min(Math.max())这种嵌套,很容易搞混,我一般会拆开写,虽然代码多了点但不容易出错。
性能优化那些事儿
移动端性能是个敏感话题。拖拽过程中如果做了太多DOM操作,掉帧是很正常的。我一般会在requestAnimationFrame里面处理位置更新:
class OptimizedDraggable extends DraggableElement {
constructor(element) {
super(element);
this.rafId = null;
this.needsUpdate = false;
}
handleTouchMove(e) {
if (!this.isDragging || e.touches.length !== 1) return;
const touch = e.touches[0];
this.currentX = touch.clientX - this.startX;
this.currentY = touch.clientY - this.startY;
if (!this.rafId) {
this.rafId = requestAnimationFrame(() => {
this.updatePosition();
this.rafId = null;
});
}
e.preventDefault();
}
}
这样做的话,每帧最多只执行一次位置更新,性能会好很多。不过要注意清理raf,不然内存泄漏。
还有一点,如果拖拽元素比较复杂,建议拖拽的时候简化样式,拖拽结束再恢复:
handleTouchStart(e) {
super.handleTouchStart(e);
// 拖拽开始时简化样式
this.originalBoxShadow = this.element.style.boxShadow;
this.originalTransition = this.element.style.transition;
this.element.style.boxShadow = 'none';
this.element.style.transition = 'none';
}
handleTouchEnd(e) {
super.handleTouchEnd(e);
// 拖拽结束后恢复样式
this.element.style.boxShadow = this.originalBoxShadow;
this.element.style.transition = this.originalTransition;
}
这样的小优化在低端设备上特别明显。
兼容性和第三方库的选择
说实话,自己写拖拽逻辑确实麻烦,但如果项目要求不高,我还是倾向于手写。毕竟引入一个库可能会带来不必要的体积和依赖问题。
如果一定要用库的话,interactjs还算靠谱,但是文档写得有点绕,而且在某些Android机型上有兼容性问题。SortableJS更适合列表排序场景,拖拽面板这种场景不太合适。
我之前试过hammer.js,功能确实强大,但是包体积太大了,对于性能敏感的应用来说不太友好。
最后的小贴士
移动端拖拽最需要注意的就是和其他手势的冲突。比如拖拽的时候不要影响浏览器的返回手势,不要干扰页面滚动。这些都是细节问题,但用户体验好坏就在这些地方。
另外记得加个防抖处理,特别是拖拽过程中需要频繁触发回调的场景。我见过有人在drag过程中直接发AJAX请求,那性能损耗太可怕了。
还有一点容易忽略:要考虑横竖屏切换的情况。横竖屏切换后,如果元素位置计算依赖窗口尺寸,要及时更新位置。
以上是我个人对这个拖拽实现的完整讲解,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多,后续会继续分享这类博客。

暂无评论