封装一个拖拽对象

1. 如何让一个 DOM 元素动起来

拖拽的本质就是让 DOM 元素能够跟着鼠标运动起来。

在页面中创建一个 class 名为 drag 的 div 标签,它的基本样式如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>拖拽封装</title>
  <style>
    .drag {
      width: 50px;
      height: 50px;
      background-color: red;
    }
  </style>
</head>

<body>
  <div class="drag"></div>
</body>
</html>

由于 left/top 会导致频繁的重排与回流,因此我们在处理元素运动时控制 translate 的值。

.drag {
  width: 50px;
  height: 50px;
  background-color: red;
  transform: translateX(0px);
}

首先要考虑兼容性,需要判断当前浏览器环境支持的 transform 属性是哪一种

// 获取当前浏览器支持的 transform 兼容写法
function getTransform() {
  var transform = '',
    divStyle = document.createElement('div').style,
    _transforms = [
      'transform',
      'webkitTransform',
      'MozTransform',
      'msTransform',
      'OTransform'
    ],
    i = 0,
    len = _transforms.length;

  for (; i < len; i++) {
    if (_transforms[i] in divStyle) {
      // 找到之后立即返回,结束函数
      return (transform = _transforms[i]);
    }
  }

  // 如果没有找到,就直接返回空字符串
  return transform;
}

该方法用于获取当前浏览器支持的 transform 属性。 如果返回空字符串,则表示该浏览器不支持 transform,这个时候就要考虑使用 left/top

2. 如何获取元素的初始位置

获取元素的初始位置,需要声明一个专门用来获取元素样式的功能函数。获取元素样式的方法在 IE 中与其他浏览器中有所不同,所以需要考虑兼容性:

function getStyle(elem, property) {
  // IE通过 currentStyle 来获取元素的样式,
  // 其他浏览器通过 getComputedStyle 来获取
  return document.defaultView.getComputedStyle
    ? document.defaultView.getComputedStyle(elem, false)[property]
    : elem.currentStyle[property];
}

有了这个方法,然后来实现一个获取元素位置的方法

function getTargetPos(elem) {
  var pos = { x: 0, y: 0 };
  var transform = getTransform();
  if (transform) {
    var transformValue = getStyle(elem, transform);
    if (transformValue == 'none') {
      elem.style[transform] = 'translate(0, 0)';
      return pos;
    } else {
      var temp = transformValue.match(/-?\d+/g);
      return (pos = {
        x: parseInt(temp[4].trim()),
        y: parseInt(temp[5].trim())
      });
    }
  } else {
    if (getStyle(elem, 'position') == 'static') {
      elem.style.position = 'relative';
      return pos;
    } else {
      var x = parseInt(getStyle(elem, 'left') ? getStyle(elem, 'left') : 0);
      var y = parseInt(getStyle(elem, 'top') ? getStyle(elem, 'top') : 0);
      return (pos = {
        x: x,
        y: y
      });
    }
  }
}

在拖拽过程中,需要不停地设置目标元素的位置,这样它才能够移动起来,因此还需要声明一个设置元素位置的方法。

// pos = { x: 200, y: 100 }
function setTargetPos(elem, pos) {
  var transform = getTransform();
  if (transform) {
    elem.style[transform] = 'translate(' + pos.x + 'px, ' + pos.y + 'px)';
  } else {
    elem.style.left = pos.x + 'px';
    elem.style.top = pos.y + 'px';
  }
  return elem;
}

有了这几个工具方法后,就可以使用更为完善的方式来实现上述要求的效果了

var drag = document.querySelector('.drag');

drag.addEventListener('click', function() {
  var curPos = getTargetPos(this);
  setTargetPos(this, {
    x: curPos.x + 5,
    y: curPos.y
  });
}, false);

拖拽的原理

结合 mousedownmousemovemouseup 这三个事件来实现拖拽。在这些事件触发的回调函数中得到了一个事件对象,通过事件对象获取当前鼠标所处的位置。

  • 当鼠标按下 mousedown 时,记住鼠标的初始位置与目标元素的初始位置。当鼠标移动时,目标元素也跟着移动,因此鼠标与目标元素的位置有如下关系: 移动后鼠标位置-鼠标初始位置=移动后目标元素位置-目标元素初始位置

  • 如果鼠标位置的差值用变量 dis 来表示,那么目标元素的位置就等于: 移动后目标元素位置=dis+目标元素的初始位置

  • 通过事件对象中提供的鼠标位置,在鼠标移动时可以计算出鼠标移动位置的差值,然后根据上面的关系,计算出目标元素的当前位置,这样拖拽就能够实现了。

代码实现

  • 第一步:准备工作
// 获取目标元素对象
var drag = document.querySelector('.drag');

// 声明2个变量用来保存鼠标初始位直的x, y坐标
var startX = 0;
var startY = 0;

// 声明2个变量用来保存目标元素初始位直的X, y坐标
var sourceX = 0;
var sourceY = 0;
  • 第二步:功能函数
// 获取当前浏览器支持的 transform 兼容写法
function getTransform() {}

// 获取元素属性
function getStyle(elem, property) {}

// 获取元素的初始位直
function getTargetPos(elem) {}

// 设置元素的初始位直
function setTargetPos(elem, potions) {}
  • 第三步:声明三个事件的回调
drag.addEventListener('mousedown', start, false);

// 绑定在 mousedown 上的回调,event为传入的事件对象
function start(event) {
  // 获取鼠标初始位直
  startX = event.pageX;
  startY = event.pageY;

  // 获取元素初始位置
  var pos = getTargetPos(drag);

  sourceX = pos.x;
  sourceY = pos.y;

  // 绑定
  document.addEventListener('mousemove', move, false);
  document.addEventListener('mouseup', end, false);
}

function move(event) {
  // 获取鼠标当前位置
  var currentX = event.pageX;
  var currentY = event.pageY;

  // 计算差值
  var distanceX = currentX - startX;
  var distanceY = currentY - startY;

  // 计算并设直元素当前位置
  setTargetPos(drag, {
    x: (sourceX + distanceX).toFixed(),
    y: (sourceY + distanceY).toFixed()
  })
}

function end(event) {
  document.removeEventListener('mousemove', move);
  document.removeEventListener('mouseup', end);
  // do something
}

至此,一个简单的拖拽就实现了。

使用面向对象进行封装

我们的目标是,只要声明一个拖拽实例,然后传入目标元素就自动具备可以被拖拽的功能。

为了避免变量污染,我们需要将模块放置在一个函数自执行方式模拟的块级作用域中。

(function() {
  // ...
})();

接下来我们如何用面向对象的思维合理地处理属性与方法的位置,需要考虑以下问题:

  • 构造函数中:属性与方法为当前实例所单独拥有,只能被当前实例访问,并且每声明一个实例,其中的方法都会被重新创建一次。
  • 原型中: 属性与方法为所有实例共同拥有,可以被所有实例访问,新声明的实例不会重复创建方法。
  • 模块作用域中:属性和方法不能被任何实例访问,但是能被内部方法访问,新声明的实例不会重复创建相同的方法。

对于方法的判断则比较简单,因为构造函数中的方法总是在声明一个新的实例时被重复创建,因此声明方法时应尽量避免出现在构造函数中。如果你的方法中需要用到构造函数中的变量,或者想要公开,那么就需要放在原型中。如果方法需要私有不被外界访问,那么就放置在模块作用域中。

使用面向对象封装上面的几点必须认真思考。如果在封装时没有思考清楚,很可能会遇到很多意想不到的 bug。

直接上代码:

(function() {
  // 这是一个私有属性,不需要被实例访问
  var transform = getTransform();

  function Drag(selector) {
    // 放在构造函数中的属性,被每一个实例所单独拥有
    this.elem = typeof selector == 'Object' ? selector : document.getElementById(selector);
    this.startX = 0;
    this.startY = 0;
    this.sourceX = 0;
    this.sourceY = 0;

    this.init();
  }

  // 原型
  Drag.prototype = {
    constructor: Drag,

    init: function() {
      // 初始时需要做哪些事情
      this.setDrag();
    },

    // 稍作改造,仅用于获取当前元素的属性,类似于getName
    getStyle: function(property) {
      return document.defaultView.getComputedStyle ? document.defaultView.getComputedStyle(this.elem, false)[property] : this.elem.currentStyle[property];
    },

    // 用来获取当前元素的位直信息,注意与之前的不同之处
    getPosition: function() {
      var pos = {
        x: 0,
        y: 0
      };
      if (transform) {
        var transformValue = this.getStyle(transform);
        if (transformValue == 'none') {
          this.elem.style[transform] = 'translate(0, 0)';
        } else {
          var temp = transformValue.match(/-?\d+/g);
          pos = {
            x: parseInt(temp[4].trim()),
            y: parseInt(temp[5].trim()),
          }
        }
      } else {
        if (this.getStyle('position') == 'static') {
          this.elem.style.position = 'relative';
        } else {
          pos = {
            x: parseInt(this.getStyle('left') ? this.getStyle('left') : 0),
            y: parseInt(this.getStyle('top') ? this.getStyle('top') : 0)
          }
        }
      }

      return pos;
    },

    // 用来设直当前元素的位置
    setPosition: function(pos) {
      if (transform) {
        this.elem.style[transform] = 'translate(' + pos.x + 'px, ' + pos.y + 'px)';
      } else {
        this.elem.style.left = pos.x + 'px';
        this.elem.style.top = pos.y + 'px';
      }
    },

    // 该方法用来绑定事件
    setDrag: function() {
      var self = this;
      this.elem.addEventListener('mousedown', start, false);

      function start(event) {
        self.startX = event.pageX;
        self.startY = event.pageY;

        var pos = self.getPosition();

        self.sourceX = pos.x;
        self.sourceY = pos.y;

        document.addEventListener('mousemove', move, false);
        document.addEventListener('mouseup', end, false);
      }

      function move(event) {
        var currentX = event.pageX;
        var currentY = event.pageY;

        var distanceX = currentX - self.startX;
        var distanceY = currentY - self.startY;

        self.setPosition({
          x: (self.sourceX + distanceX).toFixed(),
          y: (self.sourceY + distanceY).toFixed()
        })
      }

      function end(event) {
        document.removeEventListener('mousemove', move);
        document.removeEventListener('mouseup', end);
        // do other things
      }
    }
  }

  // 私有方法,仅仅用来获取 transform 的兼容写法
  function getTransform() {
    var transform = '',
      divStyle = document.createElement('div').style,
      transformArr = ['transform', 'webkitTransform', 'MozTransform', 'msTransform', 'OTransform'],
      i = 0,
      len = transformArr.length;

    for (; i < len; i++) {
      if (transformArr[i] in divStyle) {
        return transform = transformArr[i]
      }
    }

    return transform;
  }

  // 对外暴露方法
  window.Drag = Drag;
})();

使用时只需

// 使用
new Drag('target');

这样一个拖拽对象就封装完成了,封装过程代码都有注释详解,很简单。

将拖曳对象扩展为一个 jQuery 插件

jQuery 中可以使用 $.extend 扩展 jQuery 工具方法,来使用 $.fn.extend 扩展原型方法。当然,这里的拖拽插件扩展为原型方法是最合适的。

在上面封装的代码基础上我们再加一些

//通过扩展方法将拖曳扩展为 jQuery 的一个实例方法
(function($) {
  $.fn.extend({
    canDrag: function() {
      new Drag(this[0]);
      return this;
      // 注意:为了保证 jQuery 所有的方法都能够链式访问, 
      // 每一个方法的最后都需妥返回 this, 即返回 jQuery 实例
    }
  })
})(jQuery);

这样就能够很轻松地让目标 DOM 元素具备拖拽能力了,使用时只需

$('#target').canDrag();