基于 Stimulus 实现拖动排序组件
最近开发 GeekNote 的时候基于 Stimulus 实现了一个拖动排序组件,在这里记录一下原理。
提醒:已经有一个很强大的排序 js 库 SortableJS,如果它能很好的满足你的需求,那么直接用这个库就好了。自己实现更多是为了学习原理,或者为了更好的定制功能。
首先定义一个基本的 HTML 结构:
<ul>
<li>item 1</li>
<li>item 2</li>
<li>item 3</li>
</ul>
然后设置样式:
ul {
margin: 0;
padding: 0;
}
li {
list-style: none;
padding: 8px;
border: 1px solid #ccc;
background: white;
}
目前看起来是这样的:
现在列表的条目还不能交互,需要加上一个属性让它可以拖动:
<ul>
<li draggable="true">item 1</li>
<li draggable="true">item 2</li>
<li draggable="true">item 3</li>
</ul>
通过设置 draggable="true"
,浏览器会让这个 HTML 元素成为可拖动对象,效果如下:
但浏览器还不明白拖动后需要做什么。接下来需要完成逻辑部分,告诉浏览器拖放元素之后要执行什么操作。如果用原生的 JavaScript,代码看起来会是这样:
<ul ondragover="dragoverHandler">
<li draggable="true" ondragstart="dragstartHandle">item 1</li>
<li draggable="true" ondragstart="dragstartHandle">item 2</li>
<li draggable="true" ondragstart="dragstartHandle">item 3</li>
</ul>
<script>
function dragstartHandle() { ... }
function dragoverHandle() { ... }
</script>
显然,这种方式不好管理代码,所以这里使用 Stimulus 让 JavaScript 代码组织更有规范。
要使用 Stimulus,首先给 HTML 元素加上相关的 data-*
属性,这些属性会在后续用到:
<ul data-controller="sortable">
<li draggable="true" data-sortable-target="item">item 1</li>
<li draggable="true" data-sortable-target="item">item 2</li>
<li draggable="true" data-sortable-target="item">item 3</li>
</ul>
然后新建一个 Stimulus 控制器:
// sortable_controller.js
import { Controller } from "stimulus"
export default class extends Controller {
static targets = ["item"]
connect() {
this.element.addEventListener("dragstart", this.dragstart.bind(this))
this.element.addEventListener("dragend", this.dragend.bind(this))
this.element.addEventListener("dragover", this.dragover.bind(this))
}
dragstart() { }
dragend() { }
dragover() { }
}
在 connnect()
方法里,给 element
绑定了若干个需要处理的事件。element
也就是添加了 data-controller="sortable"
属性的 <ul>
元素。
先看 dragstart
,当 element
内有元素开始被拖动时会触发此事件。在拖动开始时,需要记录被拖动的元素和原始位置,以便后续处理。
dragstart(event) {
// 保存当前拖动的条目
this.draggingItem = event.target
// 保存当前拖动的条目的原始位置
this.originIndex = this.itemTargets.indexOf(this.draggingItem)
// 添加拖动样式,使用 setTimeout 是为了不影响浏览器生成的拖动缩略图
setTimeout(() => {
this.draggingItem.classList.add('dragging')
}, 1)
}
.dragging {
border: 1px dashed #ccc;
}
拖动开始时记录了条目信息和修改了样式,相应的要在拖动结束时改回去:
dragend(event) {
this.draggingItem.classList.remove('dragging')
this.draggingItem = null
this.originIndex = null
}
接下来是难点部分,如何实现当拖动光标在列表中移动时,让被拖动的条目跟随光标移动?
基本的原理是,如果光标移动到某个条目的上半部分时,将拖动条目插入到目标条目之前;如果移动到某个条目的下半部分,则将拖动条目插入到目标条目之后。光标和目标条目的位置关系可以通过 getBoundingClientRect()
方法和 event.ClientY
属性获得。
相应的代码如下:
dragover(event) {
// 阻止浏览器的原生操作
event.preventDefault()
if (this.draggingItem) {
// 关系到浏览器的一些视觉效果
event.dataTransfer.dropEffect = "move"
// 找到目标条目,注意 dragover 的 target 属性不一定对应 item 元素,也可以是它的子元素
let dragoverItem = event.target.closest('[data-sortable-target="item"]')
// 判断拖动对象是否有效
if (dragoverItem && dragoverItem != this.draggingItem) {
// 获取拖放目标的中点纵坐标。
let rect = dragoverItem.getBoundingClientRect()
let center = rect.top + rect.height / 2
// 光标在中点纵坐标的前面还是后面
if (event.clientY < center) {
// 如果是新的插入位置,插入目标前方。
if (this.dropPlaceChanged(dragoverItem, 'before')) {
dragoverItem.insertAdjacentElement('beforebegin', this.draggingItem)
this.setDropPlace(dragoverItem, 'before')
}
} else {
// 如果是新的插入位置,插入目标后方
if (this.dropPlaceChanged(dragoverItem, 'after')) {
dragoverItem.insertAdjacentElement('afterend', this.draggingItem)
this.setDropPlace(dragoverItem, 'after')
}
}
}
}
}
// 判断是否是新的插入位置,避免重复移动。
dropPlaceChanged(item, position) {
return this.dropItem != item || this.dropPosition != position
}
// 保存插入位置,用于下次判断。
setDropPlace(item, position) {
this.dropItem = item
this.dropPosition = position
}
现在浏览器已经可以根据光标移动,将拖动条目移动到新位置了。
最后一个问题是如何将新的位置通知到组件外部,以实现业务逻辑。这里用的方法是,在 dragend
的时候触发一个自定义事件,让新的位置随事件发送,然后组件外部就可以根据这个事件实现相应的业务逻辑。
dragend(event) {
// 触发自定义事件
this.triggerChange()
this.draggingItem.classList.remove('dragging')
this.draggingItem = null
this.originIndex = null
// 清理拖放时产生的临时变量
this.dropItem = null
this.dropPosition = null
}
triggerChange() {
let index = this.itemTargets.indexOf(this.draggingItem)
if (index != this.originIndex) {
let event = new CustomEvent('sortable:change', { bubbles: true, detail: { position: index } })
this.draggingItem.dispatchEvent(event)
}
}
这样在组件外部,可以监听位置变更事件:
let list = document.querySelector('ul')
list.addEventListener('sortable:change', (event) => {
event.target // 移动的条目
event.detail.position // 移动到的位置
})
以上示例的完整代码和演示可以看下方链接:
https://codepen.io/chloerei/pen/rNmXdgo
参考资料