前言
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
格式;
将原型上的方法绑定在实例对象上,便于使用;
绑定 pointerdown
、dragover
、dragenter
事件,其中 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
就是 addEventListener
,index
方法用于获取元素在父容器内的索引:
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
元素副本,用于两个容器项目移动时使用;
触发外部传入的 clone
和 dragstart
事件;
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
拖拽思路为主线,去了解业界开源拖拽库的设计与思路。感谢阅读。