拖拽功能实现的那些坑我帮你踩过了

令狐园园 移动 阅读 2,321
赞 16 收藏
二维码
手机扫码查看
反馈

我的写法,亲测靠谱

搞移动端拖拽这块,我踩过的坑能写一本书了。最早的时候就是直接用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请求,那性能损耗太可怕了。

还有一点容易忽略:要考虑横竖屏切换的情况。横竖屏切换后,如果元素位置计算依赖窗口尺寸,要及时更新位置。

以上是我个人对这个拖拽实现的完整讲解,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多,后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论