首页>>前端>>JavaScript->SortableJS 原理分析(源码)

SortableJS 原理分析(源码)

时间:2023-12-01 本站 点击:0

前言

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.groupSortable.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(是否允许添加拖动过来的元素)。

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

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

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

如果拖拽元素的位置小于目标元素的位置,说明是从上往下拖动,那么将 dragEl 移动到 target.nextSibling 之前;

如果拖拽元素的位置大于目标元素的位置,说明是从下往上拖动,那么只需将 dragEl 移动到 target 之前即可;

整个移动过程均采用 DOM 操作 insertBefore 来实现。

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

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

drop

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

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

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

动画

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

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

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

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

在操作 DOM 移动 dragEl 之前,记录容器内所有子项的位置;

进行 DOM 操作进行位置交换,DOM 操作本身没有动画;

这时再去记录一次移动后的容器内所有子项的位置;

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

通过 translate 先让元素立刻回到移动前的位置;

此时给元素自身设置过度效果 transform

这时候就可以通过 translate 让元素回到移动之后的位置。

大致实现如下:

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

最后

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

原文:https://juejin.cn/post/7097479808279379975


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:/JavaScript/6419.html