从零实现一个在固定范围内的拖拽组件(前端拖拽指南)

62 阅读5分钟

🎯 前端拖拽功能完全指南:从零实现一个完美的拖拽组件

本文实现一个功能完整、边界限制精确的拖拽组件。

功能特性

精确边界限制 - 元素完全限制在指定容器内
多设备支持 - 同时支持鼠标和触摸操作
灵活初始位置 - 支持居中、随机、自定义位置
实时位置反馈 - 提供位置和边界信息

核心实现

1. 基础架构设计

class DragHelper {
  constructor(element, options = {}) {
    this.element = element;
    this.options = {
      // 拖拽范围限制
      boundary: {
        left: 0,
        top: 0,
        right: window.innerWidth,
        bottom: window.innerHeight
      },
      // 是否启用拖拽
      enabled: true,
      // 回调函数
      onStart: null,
      onMove: null,
      onEnd: null,
      // 是否限制在父元素内
      constrainToParent: true,
      // 拖拽手柄选择器
      handle: null,
      // 初始位置设置
      initialPosition: null,
      ...options
    };
    
    this.isDragging = false;
    this.startX = 0;
    this.startY = 0;
    this.initialX = 0;
    this.initialY = 0;
    
    this.init();
  }
}

2. 事件绑定机制

bindEvents() {
  // 鼠标事件
  this.handle.addEventListener('mousedown', this.onMouseDown.bind(this));
  document.addEventListener('mousemove', this.onMouseMove.bind(this));
  document.addEventListener('mouseup', this.onMouseUp.bind(this));
  
  // 触摸事件支持
  this.handle.addEventListener('touchstart', this.onTouchStart.bind(this), { passive: false });
  document.addEventListener('touchmove', this.onTouchMove.bind(this), { passive: false });
  document.addEventListener('touchend', this.onTouchEnd.bind(this));
}

关键点:

  • 使用 { passive: false } 确保触摸事件能正确阻止默认行为
  • document 上监听 mousemovemouseup,确保拖拽不会因为鼠标移出元素而中断

3. 拖拽核心逻辑

updateDrag(clientX, clientY) {
  if (!this.isDragging) return;
  
  const deltaX = clientX - this.startX;
  const deltaY = clientY - this.startY;
  
  let newX = this.initialX + deltaX;
  let newY = this.initialY + deltaY;
  
  // 应用边界限制
  const boundary = this.getBoundary();
  newX = Math.max(boundary.left, Math.min(boundary.right, newX));
  newY = Math.max(boundary.top, Math.min(boundary.bottom, newY));
  
  // 更新元素位置
  this.element.style.left = newX + 'px';
  this.element.style.top = newY + 'px';
  
  // 触发回调
  if (this.options.onMove) {
    this.options.onMove({
      element: this.element,
      x: newX,
      y: newY,
      deltaX,
      deltaY
    });
  }
}

边界限制算法

这是整个组件的核心难点。我们需要精确计算容器的内容区域,确保元素不会超出边界。

1. 获取容器内容区域

getBoundary() {
  let boundary = { ...this.options.boundary };
  
  if (this.options.constrainToParent && this.element.parentElement) {
    const parent = this.element.parentElement;
    
    // 获取容器的实际内容区域(包括边框和padding)
    const parentRect = parent.getBoundingClientRect();
    const parentStyle = window.getComputedStyle(parent);
    
    // 获取边框宽度
    const borderLeft = parseFloat(parentStyle.borderLeftWidth) || 0;
    const borderRight = parseFloat(parentStyle.borderRightWidth) || 0;
    const borderTop = parseFloat(parentStyle.borderTopWidth) || 0;
    const borderBottom = parseFloat(parentStyle.borderBottomWidth) || 0;
    
    // 获取padding
    const paddingLeft = parseFloat(parentStyle.paddingLeft) || 0;
    const paddingRight = parseFloat(parentStyle.paddingRight) || 0;
    const paddingTop = parseFloat(parentStyle.paddingTop) || 0;
    const paddingBottom = parseFloat(parentStyle.paddingBottom) || 0;
    
    // 计算实际可用的内容区域
    const availableWidth = parentRect.width - borderLeft - borderRight - paddingLeft - paddingRight;
    const availableHeight = parentRect.height - borderTop - borderBottom - paddingTop - paddingBottom;
    
    // 获取元素尺寸
    const elementWidth = this.element.offsetWidth;
    const elementHeight = this.element.offsetHeight;
    
    // 计算元素可以移动的最大位置
    const maxRight = Math.max(0, availableWidth - elementWidth);
    const maxBottom = Math.max(0, availableHeight - elementHeight);
    
    boundary = {
      left: 0,
      top: 0,
      right: maxRight,
      bottom: maxBottom
    };
  }
  
  return boundary;
}

2. 边界限制原理

// 确保元素不会超出边界
newX = Math.max(boundary.left, Math.min(boundary.right, newX));
newY = Math.max(boundary.top, Math.min(boundary.bottom, newY));

算法解释:

  • Math.min(boundary.right, newX) 确保元素不会超出右边界
  • Math.max(boundary.left, ...) 确保元素不会超出左边界
  • Y轴同理

初始位置自定义

为了让组件更加灵活,我们提供了多种初始位置设置方式:

1. 居中显示

if (this.options.initialPosition === 'center') {
  const elementWidth = this.element.offsetWidth;
  const elementHeight = this.element.offsetHeight;
  x = (boundary.right - boundary.left - elementWidth) / 2;
  y = (boundary.bottom - boundary.top - elementHeight) / 2;
}

2. 随机位置

else if (this.options.initialPosition === 'random') {
  const elementWidth = this.element.offsetWidth;
  const elementHeight = this.element.offsetHeight;
  const maxX = Math.max(0, boundary.right - boundary.left - elementWidth);
  const maxY = Math.max(0, boundary.bottom - boundary.top - elementHeight);
  x = Math.random() * maxX;
  y = Math.random() * maxY;
}

3. 自定义坐标

else if (typeof this.options.initialPosition === 'object') {
  x = this.options.initialPosition.x || 0;
  y = this.options.initialPosition.y || 0;
}

完整代码实现

DragHelper 类

/**
 * 拖拽操作封装类
 * 支持在固定范围内拖拽元素
 */
class DragHelper {
  constructor(element, options = {}) {
    this.element = element;
    this.options = {
      // 拖拽范围限制
      boundary: {
        left: 0,
        top: 0,
        right: window.innerWidth,
        bottom: window.innerHeight
      },
      // 是否启用拖拽
      enabled: true,
      // 拖拽开始时的回调
      onStart: null,
      // 拖拽过程中的回调
      onMove: null,
      // 拖拽结束时的回调
      onEnd: null,
      // 是否限制在父元素内
      constrainToParent: true,
      // 拖拽手柄选择器(可选)
      handle: null,
      // 初始位置 {x: number, y: number} 或 'center' 或 'random'
      initialPosition: null,
      ...options
    };
    
    this.isDragging = false;
    this.startX = 0;
    this.startY = 0;
    this.initialX = 0;
    this.initialY = 0;
    
    this.init();
  }
  
  init() {
    if (!this.element) {
      throw new Error('拖拽元素不能为空');
    }
    
    // 设置元素样式
    this.element.style.position = 'absolute';
    this.element.style.cursor = 'move';
    this.element.style.userSelect = 'none';
    
    // 获取拖拽手柄
    this.handle = this.options.handle ? 
      this.element.querySelector(this.options.handle) : 
      this.element;
    
    // 设置初始位置
    this.setInitialPosition();
    
    // 绑定事件
    this.bindEvents();
  }
  
  bindEvents() {
    this.handle.addEventListener('mousedown', this.onMouseDown.bind(this));
    document.addEventListener('mousemove', this.onMouseMove.bind(this));
    document.addEventListener('mouseup', this.onMouseUp.bind(this));
    
    // 触摸事件支持
    this.handle.addEventListener('touchstart', this.onTouchStart.bind(this), { passive: false });
    document.addEventListener('touchmove', this.onTouchMove.bind(this), { passive: false });
    document.addEventListener('touchend', this.onTouchEnd.bind(this));
  }
  
  onMouseDown(e) {
    if (!this.options.enabled) return;
    e.preventDefault();
    this.startDrag(e.clientX, e.clientY);
  }
  
  onTouchStart(e) {
    if (!this.options.enabled) return;
    e.preventDefault();
    const touch = e.touches[0];
    this.startDrag(touch.clientX, touch.clientY);
  }
  
  startDrag(clientX, clientY) {
    this.isDragging = true;
    this.startX = clientX;
    this.startY = clientY;
    
    // 获取元素当前位置(相对于父元素)
    const rect = this.element.getBoundingClientRect();
    const parentRect = this.element.parentElement ? this.element.parentElement.getBoundingClientRect() : { left: 0, top: 0 };
    this.initialX = rect.left - parentRect.left;
    this.initialY = rect.top - parentRect.top;
    
    // 触发开始回调
    if (this.options.onStart) {
      this.options.onStart({
        element: this.element,
        x: this.initialX,
        y: this.initialY
      });
    }
  }
  
  onMouseMove(e) {
    if (!this.isDragging) return;
    e.preventDefault();
    this.updateDrag(e.clientX, e.clientY);
  }
  
  onTouchMove(e) {
    if (!this.isDragging) return;
    e.preventDefault();
    const touch = e.touches[0];
    this.updateDrag(touch.clientX, touch.clientY);
  }
  
  updateDrag(clientX, clientY) {
    if (!this.isDragging) return;
    
    const deltaX = clientX - this.startX;
    const deltaY = clientY - this.startY;
    
    let newX = this.initialX + deltaX;
    let newY = this.initialY + deltaY;
    
    // 应用边界限制
    const boundary = this.getBoundary();
    newX = Math.max(boundary.left, Math.min(boundary.right, newX));
    newY = Math.max(boundary.top, Math.min(boundary.bottom, newY));
    
    // 更新元素位置
    this.element.style.left = newX + 'px';
    this.element.style.top = newY + 'px';
    
    // 触发移动回调
    if (this.options.onMove) {
      this.options.onMove({
        element: this.element,
        x: newX,
        y: newY,
        deltaX,
        deltaY
      });
    }
  }
  
  onMouseUp(e) {
    if (!this.isDragging) return;
    this.endDrag();
  }
  
  onTouchEnd(e) {
    if (!this.isDragging) return;
    this.endDrag();
  }
  
  endDrag() {
    this.isDragging = false;
    
    // 触发结束回调
    if (this.options.onEnd) {
      const rect = this.element.getBoundingClientRect();
      this.options.onEnd({
        element: this.element,
        x: rect.left,
        y: rect.top
      });
    }
  }
  
  getBoundary() {
    let boundary = { ...this.options.boundary };
    
    // 如果限制在父元素内
    if (this.options.constrainToParent && this.element.parentElement) {
      const parent = this.element.parentElement;
      
      // 获取容器的实际内容区域(包括边框和padding)
      const parentRect = parent.getBoundingClientRect();
      const parentStyle = window.getComputedStyle(parent);
      
      // 获取边框宽度
      const borderLeft = parseFloat(parentStyle.borderLeftWidth) || 0;
      const borderRight = parseFloat(parentStyle.borderRightWidth) || 0;
      const borderTop = parseFloat(parentStyle.borderTopWidth) || 0;
      const borderBottom = parseFloat(parentStyle.borderBottomWidth) || 0;
      
      // 获取padding
      const paddingLeft = parseFloat(parentStyle.paddingLeft) || 0;
      const paddingRight = parseFloat(parentStyle.paddingRight) || 0;
      const paddingTop = parseFloat(parentStyle.paddingTop) || 0;
      const paddingBottom = parseFloat(parentStyle.paddingBottom) || 0;
      
      // 计算实际可用的内容区域
      const availableWidth = parentRect.width - borderLeft - borderRight - paddingLeft - paddingRight;
      const availableHeight = parentRect.height - borderTop - borderBottom - paddingTop - paddingBottom;
      
      // 获取元素尺寸
      const elementWidth = this.element.offsetWidth;
      const elementHeight = this.element.offsetHeight;
      
      // 计算元素可以移动的最大位置
      const maxRight = Math.max(0, availableWidth - elementWidth);
      const maxBottom = Math.max(0, availableHeight - elementHeight);
      
      boundary = {
        left: 0,
        top: 0,
        right: maxRight,
        bottom: maxBottom
      };
    }
    
    return boundary;
  }
  
  // 设置初始位置
  setInitialPosition() {
    if (!this.options.initialPosition) {
      this.updatePosition();
      return;
    }
    
    const boundary = this.getBoundary();
    let x, y;
    
    if (this.options.initialPosition === 'center') {
      // 居中显示
      const elementWidth = this.element.offsetWidth;
      const elementHeight = this.element.offsetHeight;
      x = (boundary.right - boundary.left - elementWidth) / 2;
      y = (boundary.bottom - boundary.top - elementHeight) / 2;
    } else if (this.options.initialPosition === 'random') {
      // 随机位置
      const elementWidth = this.element.offsetWidth;
      const elementHeight = this.element.offsetHeight;
      const maxX = Math.max(0, boundary.right - boundary.left - elementWidth);
      const maxY = Math.max(0, boundary.bottom - boundary.top - elementHeight);
      x = Math.random() * maxX;
      y = Math.random() * maxY;
    } else if (typeof this.options.initialPosition === 'object') {
      // 自定义位置
      x = this.options.initialPosition.x || 0;
      y = this.options.initialPosition.y || 0;
    } else {
      this.updatePosition();
      return;
    }
    
    // 确保位置在边界内
    x = Math.max(boundary.left, Math.min(boundary.right, x));
    y = Math.max(boundary.top, Math.min(boundary.bottom, y));
    
    // 设置位置
    this.element.style.left = x + 'px';
    this.element.style.top = y + 'px';
  }
  
  updatePosition() {
    const rect = this.element.getBoundingClientRect();
    const parentRect = this.element.parentElement ? this.element.parentElement.getBoundingClientRect() : { left: 0, top: 0 };
    const relativeX = rect.left - parentRect.left;
    const relativeY = rect.top - parentRect.top;
    this.element.style.left = relativeX + 'px';
    this.element.style.top = relativeY + 'px';
  }
  
  // 设置拖拽范围
  setBoundary(boundary) {
    this.options.boundary = { ...this.options.boundary, ...boundary };
  }
  
  // 设置位置
  setPosition(x, y) {
    const boundary = this.getBoundary();
    const constrainedX = Math.max(boundary.left, Math.min(boundary.right, x));
    const constrainedY = Math.max(boundary.top, Math.min(boundary.bottom, y));
    
    this.element.style.left = constrainedX + 'px';
    this.element.style.top = constrainedY + 'px';
    
    return { x: constrainedX, y: constrainedY };
  }
  
  // 获取当前位置
  getPosition() {
    const rect = this.element.getBoundingClientRect();
    const parentRect = this.element.parentElement ? this.element.parentElement.getBoundingClientRect() : { left: 0, top: 0 };
    return {
      x: rect.left - parentRect.left,
      y: rect.top - parentRect.top
    };
  }
  
  // 启用/禁用拖拽
  setEnabled(enabled) {
    this.options.enabled = enabled;
    this.element.style.cursor = enabled ? 'move' : 'default';
  }
  
  // 销毁拖拽功能
  destroy() {
    this.handle.removeEventListener('mousedown', this.onMouseDown);
    document.removeEventListener('mousemove', this.onMouseMove);
    document.removeEventListener('mouseup', this.onMouseUp);
    
    this.handle.removeEventListener('touchstart', this.onTouchStart);
    document.removeEventListener('touchmove', this.onTouchMove);
    document.removeEventListener('touchend', this.onTouchEnd);
  }
}

// 导出类
if (typeof module !== 'undefined' && module.exports) {
  module.exports = DragHelper;
} else if (typeof window !== 'undefined') {
  window.DragHelper = DragHelper;
}

使用示例

基础使用

// 创建拖拽实例
const dragInstance = new DragHelper(element, {
  constrainToParent: true,
  onStart: (data) => console.log('开始拖拽', data),
  onMove: (data) => console.log('拖拽中', data),
  onEnd: (data) => console.log('拖拽结束', data)
});

自定义初始位置

// 居中显示
const drag1 = new DragHelper(element, {
  initialPosition: 'center'
});

// 随机位置
const drag2 = new DragHelper(element, {
  initialPosition: 'random'
});

// 自定义坐标
const drag3 = new DragHelper(element, {
  initialPosition: { x: 100, y: 50 }
});

动态控制

// 设置位置
dragInstance.setPosition(200, 100);

// 获取当前位置
const position = dragInstance.getPosition();

// 启用/禁用拖拽
dragInstance.setEnabled(false);

// 销毁拖拽功能
dragInstance.destroy();

性能优化

1. 事件节流

对于频繁的 mousemove 事件,可以考虑添加节流:

updateDrag(clientX, clientY) {
  if (!this.isDragging) return;
  
  // 使用 requestAnimationFrame 节流
  if (this.animationFrame) {
    cancelAnimationFrame(this.animationFrame);
  }
  
  this.animationFrame = requestAnimationFrame(() => {
    // 拖拽逻辑...
  });
}

2. 内存管理

destroy() {
  // 清理事件监听器
  this.handle.removeEventListener('mousedown', this.onMouseDown);
  document.removeEventListener('mousemove', this.onMouseMove);
  document.removeEventListener('mouseup', this.onMouseUp);
  
  // 清理引用
  this.element = null;
  this.handle = null;
  this.options = null;
}

完整代码和演示: [gitee.com/xcxsj/tools…]

如果这篇文章对你有帮助,请点赞支持!👍

image.png