SortableJS 原理分析(源码)
前言
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.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
就是 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
(是否允许添加拖动过来的元素)。
_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
拖拽思路为主线,去了解业界开源拖拽库的设计与思路。感谢阅读。
转载自:https://juejin.cn/post/7097479808279379975