SortableJS 原理分析(源码)

·  阅读 443

前言

SortableJS 是基于 H5 拖拽 API 实现的一个轻量级 JS 拖拽排序库,它适用于以下一些场景:

  • 容器项目拖动排序:容器列表内的子项目,通过拖动进行位置调换,且具有动画效果;
  • 容器间的项目移动:将一个容器列表中的子项目,拖动到另一个容器列表中(移动/克隆)。

不论是容器内元素顺序排序,或是两个容器内的元素进行移动,本质上是在通过操作 DOM 来实现。

下面我们先熟悉一下 SortableJS 基本使用。

示例

1、HTML 结构:

<div class="row">
  <div id="leftContainer" class="list-group col-6">
    <div class="list-group-item">Item 1</div>
    <div class="list-group-item">Item 2</div>
    <div class="list-group-item">Item 3</div>
    <div class="list-group-item">Item 4</div>
    <div class="list-group-item">Item 5</div>
    <div class="list-group-item">Item 6</div>
  </div>
  
  <div id="rightContainer" class="list-group col-6">
    <div class="list-group-item tinted">Item 1</div>
    <div class="list-group-item tinted">Item 2</div>
    <div class="list-group-item tinted">Item 3</div>
    <div class="list-group-item tinted">Item 4</div>
    <div class="list-group-item tinted">Item 5</div>
    <div class="list-group-item tinted">Item 6</div>
  </div>
</div>
复制代码

2、为容器实例化:

new Sortable(leftContainer, {
  group: {
    name: 'group',
    pull: 'clone',
    put: true
  },
});

new Sortable(rightContainer, {
  group: 'group',
});
复制代码

现在,就可以在容器内进行排序拖动,或者拖动左侧容器元素,添加到右侧容器中。

思路分析

在看源码之前,还是需要对 H5 拖拽 用法有一定了解,如果不熟悉,直接去看源码很容易就放弃。

若你对 H5 拖拽 API 比较熟悉,就可以根据 SortableJS 的视图呈现效果,想出个大概思路。

拖拽,首先要搞清楚两个词汇对象:

  • 拖动元素:作为拖拽元素被拖起(下文叫 dragEl);
  • 目标元素:作为拖拽元素即将被放置时的参照物(下文叫 target);

在 SortableJS 中,拖拽离不开以下几个事件:

  • dragstart:作为拖拽元素,按下鼠标开始拖动元素时触发(拖拽周期只触发一次);
  • dragend:作为拖拽元素,当鼠标松开拖放元素时触发(拖拽周期只触发一次);
  • dragover:作为拖拽元素,当拖动元素进行移动,会持续触发,需要在这里取消默认事件,否则元素无法被拖动(松开时元素的预览幽灵图又回去了);
  • drop:作为目标元素,当鼠标松开拖放元素时触发(拖拽周期只触发一次);

下面我们一起去分析 SortableJS 具体实现。

源码

实例构造函数

从上面的 示例 使用上得知,SortableJS 是一个构造函数,接收容器元素和配置项:

const expando = 'Sortable' + (new Date).getTime();

function Sortable(el, options) {
  this.el = el; // root element
  this.options = options = Object.assign({}, options);
  el[expando] = this;

  const defaults = {
    group: null,
    sort: true, // 默认容器可以排序
    animation: 0,
    removeCloneOnHide: true, // 将一个容器元素拖动至另一个容器后,默认
    setData: function (dataTransfer, dragEl) {
      dataTransfer.setData('Text', dragEl.textContent);
    }
  };

  // 参数合并
  for (var name in defaults) {
    !(name in options) && (options[name] = defaults[name]);
  }

  // 规范 group
  _prepareGroup(options);

  // 绑定原型方法为私有方法
  for (var fn in this) {
    if (fn.charAt(0) === '_' && typeof this[fn] === 'function') {
      this[fn] = this[fn].bind(this);
    }
  }

  // 绑定指针触摸事件,类似 mousedown
  on(el, 'pointerdown', this._prepareDragStart);
  on(el, 'dragover', this);
  on(el, 'dragenter', this);
}
复制代码

初始化示例做了以下几件事件:

  • 将传入的参数与提供的 默认参数 进行合并;
  • 规范传入的 group 格式;
  • 将原型上的方法绑定在实例对象上,便于使用;
  • 绑定 pointerdowndragoverdragenter 事件,其中 pointerdown 可以看作是 dragstart 事件,做了一些拖拽前的准备工作。

group 用于两个容器元素的相互拖拽场景,规范 group 核心代码如下:

function _prepareGroup(options) {
  function toFn(value, pull) {
    return function(to, from) {
      let sameGroup = to.options.group.name &&
              from.options.group.name &&
              to.options.group.name === from.options.group.name;
      if (value == null && (pull || sameGroup)) {
        return true;
      } else if (value == null || value === false) {
        return false;
      } else if (pull && value === 'clone') {
        return value;
      } else {
        return value === true;
      }
    };
  }

  let group = {};
  let originalGroup = options.group;

  if (!originalGroup || typeof originalGroup != 'object') {
    originalGroup = { name: originalGroup };
  }

  group.name = originalGroup.name;
  group.checkPull = toFn(originalGroup.pull, true);
  group.checkPut = toFn(originalGroup.put);

  options.group = group;
}
复制代码

_prepareDragStart 拖动前的准备工作

当鼠标按下触发 pointerdown 事件时,会保存拖动元素的信息,提供后续使用,并且注册 dragstart 事件:

let oldIndex,
  newIndex;
  
let dragEl = null; // 拖拽元素
let rootEl = null; // 容器元素
let parentEl = null; // 拖拽元素的父节点
let nextEl = null; // 拖拽元素下一个元素
let activeGroup = null; // options.group
  
Sortable.prototype = {
  _prepareDragStart(evt) {
    let target = evt.target,
      el = this.el,
      options = this.options;

    oldIndex = index(target);
    rootEl = el;
    dragEl = target;
    parentEl = dragEl.parentNode;
    nextEl = dragEl.nextSibling;
    activeGroup = options.group;

    dragEl.draggable = true; // 设置元素拖拽属性

    on(dragEl, 'dragend', this);
    on(rootEl, 'dragstart', this._onDragStart);
    on(document, 'mouseup', this._onDrop);
  },
}
复制代码

on 就是 addEventListenerindex 方法用于获取元素在父容器内的索引:

function on(el, event, fn) {
  el.addEventListener(event, fn);
}

function off(el, event, fn) {
  el.removeEventListener(event, fn);
}

function index(el) {
  if (!el || !el.parentNode) return -1;
  let index = 0;
  // 返回元素节点之前的兄弟元素节点(不包括文本节点、注释节点)
  while (el = el.previousElementSibling) {
    if (el !== Sortable.clone) index++;
  }
  return index;
}
复制代码

_onDragStart 用于处理 dragstart 事件逻辑,_onDrop 用于处理拖拽结束逻辑,比如这里执行了 dragEl.draggable = true;,那么在 mouseup 鼠标松开后需将 draggable = false

这里有趣的一点是 dragend 事件,它的处理函数绑定的是 this 即 Sortable 实例本身,我们都知道实例对象是一个对象,怎么能作为函数使用呢?

其实 addEventListener 第二参数可以是函数,也可以是对象,当为对象时,需要提有一个 handleEvent 方法来处理事件:

Sortable.prototype = {
  handleEvent: function (evt) {
    switch (evt.type) {
      case 'dragend':
        this._onDrop(evt);
        break;
      case 'dragover':
        evt.stopPropagation();
        evt.preventDefault();
        break;
      case 'dragenter':
        if (dragEl) {
          this._onDragOver(evt);
        }
        break;
    }
  },
}
复制代码

到这里,整个拖拽流程功能函数都暴露在了眼前:

  • _onDragStart 处理 dragstart 拖拽开始工作;
  • _onDragOver 处理拖拽移动到别的元素时工作;
  • _onDrop 处理鼠标拖动结束的收尾工作。

dragstart

这里做了两件事情:

  • clone 一个 dragEl 元素副本,用于两个容器项目移动时使用;
  • 触发外部传入的 clonedragstart 事件;
let cloneEl = null, cloneHidden = null; // clone 元素

_onDragStart(evt) {
  let dataTransfer = evt.dataTransfer;
  let options = this.options;

  cloneEl = clone(dragEl);
  cloneEl.removeAttribute("id");
  cloneEl.draggable = false;

  // 设置拖拽数据
  if (dataTransfer) {
    dataTransfer.effectAllowed = 'move';
    options.setData && options.setData.call(this, dataTransfer, dragEl);
  }

  Sortable.active = this;
  Sortable.clone = cloneEl;
  _dispatchEvent({
    sortable: this,
    name: 'clone'
  });
  _dispatchEvent({
    sortable: this,
    name: 'start',
    originalEvent: evt
  });
},

function clone(el) {
  return el.cloneNode(true);
}
复制代码

_dispatchEvent 会通过 new window.CustomEvent 构造一个事件对象,将拖拽元素的信息添加到自定义事件对象上,传递给外部的注册事件函数,大体代码如下:

function dispatchEvent(...params) {
  // sortable 没有传,就根据 rootEl 获取 sortable。
  sortable = (sortable || (rootEl && rootEl[expando]));
  if (!sortable) return;

  let evt,
    options = sortable.options,
    onName = 'on' + name.charAt(0).toUpperCase() + name.substr(1);

  // 自定义事件,拿到事件对象,满足外部用户传入的事件正常使用
  if (window.CustomEvent) {
    evt = new CustomEvent(name, {
      bubbles: true,
      cancelable: true
    });
  } else {
    evt = document.createEvent('Event');
    evt.initEvent(name, true, true);
  }

  evt.to = toEl || rootEl;
  evt.from = fromEl || rootEl;
  evt.item = targetEl || rootEl;
  evt.clone = cloneEl;
  evt.oldIndex = oldIndex;
  evt.newIndex = newIndex;

  // 执行外部传入的事件
  if (options[onName]) {
    options[onName].call(sortable, evt);
  }
}
复制代码

可见,拖拽的核心逻辑不在 dragstart 中,下面我们去看 dragenter 的处理函数 _onDragOver

dragenter

SortableJS 的核心逻辑在 _onDragOver 中,拿容器内项目排序为例:当拖动 dragEl 元素,移动到另一个元素上时,会发生两者的位置交换,可见,Sort 的逻辑在这里。

首先,在实例化对象时绑定了 dragover 和 dragenter 事件,并且通过 handleEvent 将事件逻辑交由 _onDragOver 来处理:

on(el, 'dragover', this);
on(el, 'dragenter', this);

handleEvent: function (evt) {
  switch (evt.type) {
    case 'dragover':
      evt.stopPropagation();
      evt.preventDefault();
      break;
    case 'dragenter':
      if (dragEl) {
        this._onDragOver(evt);
      }
      break;
  }
},
复制代码

_onDragOver 中,需要注意一点是:假如有两个容器,那就有两个 new Sortable 实例对象,isOwner 将为 false,这是就需要判断拖动容器的 activeGroup.pull(是否允许被移动)和 group.put(是否允许添加拖动过来的元素)。

_onDragOver(evt) {
  let el = this.el,
    _this = this,
    target = evt.target,
    options = this.options,
    group = options.group,
    activeSortable = Sortable.active,
    isOwner = (activeGroup === group),
    canSort = options.sort;

  if (activeSortable && !options.disabled && isOwner
    ? canSort
    : (this.lastPutMode = activeGroup.checkPull(this, activeSortable, dragEl, evt)) && group.checkPut(this, activeSortable, dragEl, evt)
  ) {
    if (target.parentNode === el) {
      const parentNode = target.parentNode;
      const children = Array.from(parentNode.children);
      const dragElIndex = children.indexOf(dragEl);
      const targetElIndex = children.indexOf(target);

      let nextSibling = target.nextElementSibling;
      let after = dragElIndex < targetElIndex;
      parentNode.insertBefore(dragEl, after ? nextSibling : target);
      parentEl = dragEl.parentNode; // 更改父节点

      if (isOwner) {
        activeSortable._hideClone();
      } else {
        activeSortable._showClone(_this);
      }

      newIndex = index(dragEl);
      _dispatchEvent({
        sortable: _this,
        name: 'change',
        toEl: el,
        newIndex,
        newDraggableIndex,
        originalEvent: evt
      });
    }
  }
},
复制代码

上面的核心在于下面这一行代码:

parentNode.insertBefore(dragEl, after ? nextSibling : target);
复制代码
  • 如果拖拽元素的位置小于目标元素的位置,说明是从上往下拖动,那么将 dragEl 移动到 target.nextSibling 之前;
  • 如果拖拽元素的位置大于目标元素的位置,说明是从下往上拖动,那么只需将 dragEl 移动到 target 之前即可;
  • 整个移动过程均采用 DOM 操作 insertBefore 来实现。

另外如果是两个容器的场景(isOwner = false ),并且拖动元素的容器 activeGroup.pull = clone,需要将 dragstart 创建的 clone 元素渲染到容器中:

if (isOwner) {
  activeSortable._hideClone();
} else {
  activeSortable._showClone(_this);
}

_hideClone() {
  if (!cloneHidden) {
    css(cloneEl, 'display', 'none');
    cloneHidden = true;
  }
},

_showClone: function(putSortable) {
  if (putSortable.lastPutMode !== 'clone') {
    this._hideClone();
    return;
  }

  if (cloneHidden) {
    if (dragEl.parentNode == rootEl) {
      rootEl.insertBefore(cloneEl, dragEl);
    } else if (nextEl) {
      rootEl.insertBefore(cloneEl, nextEl);
    } else {
      rootEl.appendChild(cloneEl);
    }
    css(cloneEl, 'display', '');
    cloneHidden = false;
  }
},
复制代码

drop

drop 主要做一些收尾工作,如将 dragEl.draggable = false,移除绑定的 mouseup、dragstart、dragend 事件,触发用户传入的 sort、end 事件等。

不过注意,虽然起名叫 drop,触发的事件确是 dragend

_onDrop(evt) {
  let el = this.el,
  newIndex = index(dragEl);
  parentEl = dragEl && dragEl.parentNode;

  off(el, 'dragstart', this._onDragStart);
  off(dragEl, 'dragend', this);
  off(document, 'mouseup', this._onDrop);
  dragEl.draggable = false;

  if (rootEl !== parentEl) {
    // 从一个列表中拖放到另一个列表中
    if (newIndex >= 0) {
      // Add event
      _dispatchEvent({
        name: 'add',
        ...
      });

      // Remove event
      _dispatchEvent({
        name: 'remove',
        ...
      });

      // Sort event
      _dispatchEvent({
        name: 'sort',
        ...
      });
    }
  } else {
    // 一个容器内部排序
    if (newIndex !== oldIndex) {
      if (newIndex >= 0) {
        _dispatchEvent({
          sortable: this,
          name: 'update',
          toEl: parentEl,
          originalEvent: evt
        });

        _dispatchEvent({
          sortable: this,
          name: 'sort',
          toEl: parentEl,
          originalEvent: evt
        });
      }
    }
  }

  if (Sortable.active) {
    if (newIndex == null || newIndex === -1) {
      newIndex = oldIndex;
    }

    _dispatchEvent({
      sortable: this,
      name: 'end',
      toEl: parentEl,
      originalEvent: evt
    });
  }
},
复制代码

动画

如果想在拖动排序中有一定的 animation 动画效果,可以配置动画属性,属性值是动画持续时长:

new Sortable(leftContainer, {
  group: {
    name: 'group',
    pull: 'clone',
    put: true
  },
  animation: 150,
});
复制代码

动画的时机也是在 dragenter 中,大致的思路如下:

1、记录:记录容器子项位置信息

  • 在操作 DOM 移动 dragEl 之前,记录容器内所有子项的位置;
  • 进行 DOM 操作进行位置交换,DOM 操作本身没有动画;
  • 这时再去记录一次移动后的容器内所有子项的位置;

2、执行:有了上面几步的操作,接下来就可以根据移动前后的位置进行动画操作

  • 通过 translate 先让元素立刻回到移动前的位置;
  • 此时给元素自身设置过度效果 transform
  • 这时候就可以通过 translate 让元素回到移动之后的位置。

大致实现如下:

if (target.parentNode === el) {
  const parentNode = target.parentNode;
  const children = Array.from(parentNode.children);
  const duration = this.options.animation;

  // 1、记录移动前的位置
  const animationStates = [];
  for (let i = 0; i < children.length; i++) {
    const child = children[i];
    animationStates.push({
      target: child,
      rect: child.getBoundingClientRect(),
    });
  }

  // ... DOM 操作移动

  function isRectEqual(rect1, rect2) {
    return Math.round(rect1.top) === Math.round(rect2.top) &&
      Math.round(rect1.left) === Math.round(rect2.left) &&
      Math.round(rect1.height) === Math.round(rect2.height) &&
      Math.round(rect1.width) === Math.round(rect2.width);
  }
  function animate(target, currentRect, toRect, duration) {
    if (!duration) return;

    css(target, 'transition', '');
    css(target, 'transform', '');
    let translateX = currentRect.left - toRect.left,
      translateY = currentRect.top - toRect.top;

    css(target, 'transform', 'translate3d(' + translateX + 'px,' + translateY + 'px,0)');
    (function repaint(target) { target.offsetWidth })(target); // 重绘 transform: translate
    css(target, 'transition', 'transform ' + duration + 'ms' + (_this.options.easing ? ' ' + _this.options.easing : ''));
    css(target, 'transform', 'translate3d(0,0,0)');

    (typeof target.animated === 'number') && clearTimeout(target.animated);
    target.animated = setTimeout(function () {
      css(target, 'transition', '');
      css(target, 'transform', '');
      target.animated = false;
    }, duration);
  }

  // 2、记录移动后的位置
  animationStates.forEach(state => {
    let { target, rect: animatingRect } = state,
      toRect = target.getBoundingClientRect();
    // 3、执行动画
    if (!isRectEqual(animatingRect, toRect)) {
      animate(target, animatingRect, toRect, duration);
    }
  });
}
复制代码

最后

本文以探索 SortableJS 拖拽思路为主线,去了解业界开源拖拽库的设计与思路。感谢阅读。

分类:
前端
分类:
前端
收藏成功!
已添加到「」, 点击更改