cover

基于 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;
}

目前看起来是这样的:

截屏2021-08-20 19.44.47.png

现在列表的条目还不能交互,需要加上一个属性让它可以拖动:

<ul>
  <li draggable="true">item 1</li>
  <li draggable="true">item 2</li>
  <li draggable="true">item 3</li>
</ul>

通过设置 draggable="true",浏览器会让这个 HTML 元素成为可拖动对象,效果如下:

截屏2021-08-20 19.46.02.png

但浏览器还不明白拖动后需要做什么。接下来需要完成逻辑部分,告诉浏览器拖放元素之后要执行什么操作。如果用原生的 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;
}

截屏2021-08-20 20.01.12.png

拖动开始时记录了条目信息和修改了样式,相应的要在拖动结束时改回去:

  dragend(event) {
    this.draggingItem.classList.remove('dragging')
    this.draggingItem = null
    this.originIndex = null
  }

接下来是难点部分,如何实现当拖动光标在列表中移动时,让被拖动的条目跟随光标移动?

基本的原理是,如果光标移动到某个条目的上半部分时,将拖动条目插入到目标条目之前;如果移动到某个条目的下半部分,则将拖动条目插入到目标条目之后。光标和目标条目的位置关系可以通过 getBoundingClientRect() 方法和 event.ClientY 属性获得。

Untitled Diagram (2).png

相应的代码如下:

  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
  }

现在浏览器已经可以根据光标移动,将拖动条目移动到新位置了。

截屏2021-08-20 20.35.09.png

最后一个问题是如何将新的位置通知到组件外部,以实现业务逻辑。这里用的方法是,在 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


参考资料

5
2