SortableJS 的那些 "坑"

2,958 阅读4分钟

前言

SortableJS 是一个功能强大的 JavaScript 拖拽库,但在使用过程中不免会遇到一些令人抓挠的“坑”。

本片文章将记录自身在一些应用场景下遇到的一些问题,以及问题的解决方案。

SortableJS 的基础使用可以参考:SortableJS,一个简单的 JS 拖拽库

拖动克隆 clone

1、业务场景:
比如有左右两个拖拽容器,左侧容器内的 Element 可以拖拽(clone)放置在右侧容器中。

<div className="row" style={{ display: 'flex' }}>
  <div id="containerLeft" className="list-group col-6" style={{ flex: 1 }}>
    <div className="list-group-item" onClick={() => setCount(count + 1)}>
      Item 1 + {count}
    </div>
    ...
  </div>

  <div id="containerRight" className="list-group col-6" style={{ flex: 1 }}>
    ...
  </div>
</div>

new Sortable(containerLeft, {
  group: {
    name: 'shared',
    pull: 'clone',
    push: false,
  }, // set both lists to same group
  animation: 150
});

new Sortable(containerRight, {
  group: {
    name: 'shared',
    pull: false,
    push: true,
  },
  animation: 150
});

2、问题描述:
当左侧容器内的 Element 被拖拽 clone 到右侧容器后,这个目标 Element 在左侧容器内将无法读到新的状态值(比如可以是 react 的 state),并且事件都失效了(比如 onclick 点击事件)。

比如每个 Element 只允许被拖拽一次到右侧容器内。在 Element 成功拖拽到右侧容器后,需要在左侧容器内更新 state 对这个元素进行禁用。

但是会发现,state 是更新了,其他 Element 也能读到新的 state,唯独此 Element 无法读取到。

反观,在拖动成功到右侧容器中的这个 Element,可以接收 state 的更新。

3、思路分析:
根据问题描述的客观分析,猜想拖拽 clone 是将 target 元素 移动到了新的容器内,而留下了一个不会流动的 clone 元素。下面我们论证一下这一点。

1)输出 evt 事件对象:
我们可以在拖拽结束后的 onEnd 事件内输出 evt 事件对象:

new Sortable(containerLeft, {
  group: {
    name: 'shared',
    pull: 'clone',
    push: false,
  }, // set both lists to same group
  animation: 150,
  onEnd(evt: any) {
    console.log(evt);
  },
});

2)查看 evt 事件对象:
现在我们回到浏览器,打开调试终端可以看到 evt 对象结构,我们先关注两个参数属性:cloneitem

当我们鼠标移动到 clone 参数值上时,对应的是左侧容器内的此 Element:

1650101275799.jpg

当我们鼠标移动到 item 参数值上时,对应的是拖拽至右侧容器内的此 Element:

1650101483995.jpg

当我们在 Element 上添加了点击事件,拖拽后会发现:

  • 在左侧容器内点击此元素不会触发 onClick 事件,事件内的更新 state 的动作也不会执行;
  • 反观在右侧容器内点击此元素,会触发 onClick 事件,并且 state 也在变化。

1650101981207.jpg

3)思考方案:
通常这种场景都是数据 List 来渲染节点,也就是说当拖拽成功后,我们需要将数据添加到右侧容器 List内,由数据去渲染视图;因此我们就可以想到:将拖拽成功至右侧容器的 Element,还原到左侧容器内。

因此我们的方案分为两步:

  • 移除左侧容器内无法流动的 clone Element;
  • 将右侧容器内正常的 item Element 还原至左侧容器内。

4、解决方案:

export const resetDragElement = (evt) => {
  // const { target, clone, item, oldIndex } = evt;
  const { target, clone, item } = evt;
  // 考虑到在容器中,会存在部分元素可以拖拽,而非所有元素都可以拖拽,若使用 evt.oldIndex 就会导致存在 bug
  const oldIndex = Array.from(target.children).findIndex(child => child === clone);
  if (target.contains(clone)) {
    target.removeChild(clone);
    target.insertBefore(item, target.children[oldIndex]);
  }
}

new Sortable(containerLeft, {
  group: {
    name: 'shared',
    pull: 'clone',
    push: false,
  }, // set both lists to same group
  animation: 150,
  onEnd(evt: any) {
    console.log(evt);
    resetDragElement(evt);
  },
});

值得注意的是:
oldIndex 并没有直接使用 evt.oldIndex,因为 evt.oldIndex 的索引位置是按照容器内支持拖动的 Element List,而非容器内所有 Element List,当容器内存在部分 Element 不能拖动时,使用 evt.oldIndex 这个索引顺序将达不到预期的效果。

排序原理

在容器内使用 SortableJS 进行拖拽排序时会发现,当拖动元素 dragEl 拖动到目标元素 targetEl 上后,dragEl 和 targetEl 的位置互换了,原理是进行到 DOM 操作:

parentNode.insertBefore(dragEl, after ? nextSibling : target);

而这一操作是发生在 dragover 移动时,即没有松开鼠标;若此时想按照通常习惯,按键盘 esc 键想撤销拖拽移动操作,会发现无法还原为拖拽移动之前的位置。

待更