最近在写培养计划制作平台,想实现一个使用拖拽课程的方式生成培养计划的组件。

省流:没折腾出一个可用的 demo,转战dnd-kit了,真香(

理想效果是:不仅能从课程列表里拖放课程到培养计划树中,还可以在树上自由拖拽课程或者课程类别到树的任意课程类别中。

这个效果隐藏着一些挑战:

  • 培养计划树可以有无数层嵌套
  • 可能拖拽前后父元素不再是先前那个父元素,需要区分父元素有无变化的情况
  • 可能拖拽前后被拖拽的组件在树中的层次发生了变化
  • 由于课程不可能再包含课程,所以只有课程类别才能作为容器被拖拽子元素

这就衍生出一系列值得商榷的问题,比如数据该以何种格式存放比较高效,因为培养计划树可以有无数层,如果用递归的方式(也就是多维不定长数组)存放数据,被修改位置的数据在树中的层次问题其实是很容易让人混乱的,要找它的父元素更是难上加难,要存放很多冗余的信息。最容易想到的办法就是每次修改数据都对树递归地使用 find 来定位元素,然而这显然是很低效的;此外,既然课程不能再包含课程,只有课程类别才能作为拖拽的容器,那其实我们还需要在拖拽时区别对课程和课程类别的处理方式。

抱着这些问题简单了解了一下 react 实现拖拽的方式。本来想尝试手写一个拖拽组件,然而写 demo 的过程中感觉实现效果不尽人意,为了保证用户体验就暂时先寻求组件库的支持了。稍晚可能会写一篇关于原生实现 react 拖拽组件的体验。

经过简单的调研,感觉 react-beautiful-dnd 实现的拖拽比较自然,于是去查看了该组件库的仓库和官网。官网看起来只有实现的一些 demo 效果,并没有可供调试的源码,仓库里倒是有存放到 CodeSandbox 的一些 examples,于是选了一个最贴近需求的组件看了一下实现。

组件代码

这个组件实现了删除功能、添加新分组功能、添加新 item 功能、支持 item 在分组之间移动功能。然而,它的 demo 是直接照只有两层层次的思路写的:state 的结构是二维数组,每个父容器都是 state 的第一维数组,每次拖动 item 时,能获取新旧父元素 id 和新旧位置 idx,也就是说 item 前后在 state 两维中的 idx 都知道了,由此可以直接知道子元素新旧位置。但是我们的实际需求是无限深度的树状拖拽,如果按这个思路的话,岂不是所有父元素的 idx 都要被感知?所以我又去仓库翻有没有更符合需求的 example。

翻仓库文档的过程中,看到了有意思的一段话,它提到可以使用@atlaskit/tree。从其实现效果和组件名看,大概率是符合我的需求的,于是我又去搜索了@atlaskit/tree相关的 demo,找到了梦中情 demo。它实现了跨分组拖动,也能进行树状层次嵌套。个人觉得这个 demo 对我启发最大的莫过于它对数据的存放格式

const trees = [
  {
    rootId: "5c91cba358267312089b8696",
    items: {
      "5c91cba358267312089b8696": {
        id: "5c91cba358267312089b8696",
        hasChildren: true,
        isExpanded: false,
        isChildrenLoading: false,
        data: {
          name: "Near Kiley's Run.",
          id: "5c91cba358267312089b8696",
        },
        children: ["5c91cba658267312089b8699"],
      },
      "5c91cba658267312089b8699": {
        id: "5c91cba658267312089b8699",
        hasChildren: false,
        isExpanded: false,
        isChildrenLoading: false,
        data: {
          name: "What should he live for?  A dull despair",
          id: "5c91cba658267312089b8699",
        },
        children: [],
      },
    },
  },
  {
    rootId: "5c91cbba58267312089b86cb",
    items: {
      "5c91cbba58267312089b86cb": {
        id: "5c91cbba58267312089b86cb",
        hasChildren: true,
        isExpanded: false,
        isChildrenLoading: false,
        data: {
          name: "They were outlaws both -- and on each man's head",
          id: "5c91cbba58267312089b86cb",
        },
        children: ["5c91cbbd58267312089b86ce"],
      },
      "5c91cbbe58267312089b86d3": {
        id: "5c91cbbe58267312089b86d3",
        hasChildren: true,
        isExpanded: false,
        isChildrenLoading: false,
        data: {
          name: "Some large tomatoes, rank and stale,",
          id: "5c91cbbe58267312089b86d3",
        },
        children: ["5c91cbf758267312089b873f", "5c91cbf858267312089b8744"],
      },
    },
  },
];

这个数据格式比先前的多维数组有着明显的优势。比如在改变 item 的位置时,多维数组要同时找到该项和旧父元素在 state 中存放的位置,把 item 整体从旧父元素中切割出来,再 push 到新父元素中。如果用 demo 中的格式,索引 item 只需要 state.items[item.id],至于位置变化的体现则只需要改变新旧父元素 children 里的 id,不必再把 item 整体搬来搬去。

参考了一下 demo 的具体实现,感觉@atlaskit/tree主要的工作就是提供了树形列表的 UX,具体的操作(增删移)还是开发者自己实现的。所以我决定尝试使用这个数据格式,利用react-beautiful-dnd复现 demo 的效果。

兜兜转转又回到了一开始的 demo参考react-beautiful-dnd如何实现拖拽。观察可以发现,<Droppable>是拖拽的容器,<Draggable>是被拖拽组件。它们的子元素被限定为一个函数,函数传入providedsnapshot作为给定参数。

在培养计划树中,拖拽容器也可以变成被拖拽组件。针对这种情况,递归式地创建组件是比较方便、也好理解的实现方式。于是初步参考 demo 编写组件如下:

const PaintTree = (props) => {
  return (
    <Droppable droppableId={props.droppableId}>
      {(provided, snapshot) => {
        return (
          <section
            ref={provided.innerRef}
            {...provided.droppableProps}
            style={getListStyle(snapshot.isDraggingOver)}
          >
            <section>{provided.placeholder}</section>
            {props.children?.length > 0 && (
              <section>
                {props.children.map((id, idx) => (
                  <Draggable key={id} draggableId={id} index={idx}>
                    {(provided, snapshot) => {
                      return (
                        <section
                          ref={provided.innerRef}
                          {...provided.draggableProps}
                          {...provided.dragHandleProps}
                          style={getItemStyle(
                            snapshot.isDragging,
                            provided.draggableProps.style
                          )}
                        >
                          <PaintTree
                            key={id}
                            droppableId={id}
                            {...treeData[id]}
                          />
                        </section>
                      );
                    }}
                  </Draggable>
                ))}
              </section>
            )}
          </section>
        );
      }}
    </Droppable>
  );
};

return (
  <section>
    <DragDropContext onDragEnd={onDragEnd}>
      <PaintTree
        {...treeData[exampleTreeData[0].rootId]}
        droppableId={exampleTreeData[0].rootId}
      />
    </DragDropContext>
  </section>
);

用的还是 demo 的 css,所以效果长这样(有点鬼畜)

接下来开始编写 drag 时的行为。

<DragDropContext>提供的onDragEnd会在拖拽结束时被调用,该事件会接收一个参数,包括以下内容:

{
  // 被拖拽的item的id
  "draggableId": "5c91cbb558267312089b86c6",
  "source": {
    // 原父元素id
    "droppableId": "5c91cba658267312089b8699",
    // item原来所在idx
    "index": 1
  },
  // 若没有正确拖拽到容器,则destination=null
  "destination": {
    // 新父元素id
    "droppableId": "5c91cbaa58267312089b86a8",
    // item新所在idx
    "index": 0
  }
}

据此编辑移动逻辑如下:

const handleMove = (item) => {
  const { destination, source, draggableId } = item;
  // 没有正确拖拽到容器,则不作移动
  if (!destination) return;
  let newTreeData = treeData;
  newTreeData[source.droppableId].children.splice(source.index, 1);
  newTreeData[destination.droppableId].children.splice(
    destination.index,
    0,
    draggableId
  );
  setTreeData(newTreeData);
};

const onDragEnd = (e) => {
  handleMove(e);
};

测试页面效果,拖拽行为确实生效了,但是很不自然。具体表现为:

  • 组件被拖拽时,虽然感应区域高亮行为正常,最后拖拽的结果也与高亮行为一致,但组件本身位置常常会发生突变,离鼠标有一定距离,但鼠标若离开所有拖拽的容器,则被拖拽组件又紧跟鼠标移动,用户体验感很奇怪
  • 希望将组件拖拽到两个容器之间的位置时,多数情况下两个容器并不会自动拉开距离,而是将拖拽行为识别为放入两容器中的一个

关于第一个问题,应该是因为在拖拽过程中该 item 的 id 仍然挂在父容器下,而 item 要拖动到到父容器以外的地方时,容器为感应拖拽动作会预留出占位位置,此时父容器整个位置发生移动,那么 item 也随着父元素发生了移动,造成其与鼠标之间的距离发生突变。

关于第二个问题,注释掉handleMove,重新观察拖拽高亮行为,仍然和上述表现一致。初步认定应该是<PaintTree>某些部分的编写问题。但理论上不应该出现这个问题,因为关于拖动时的占位效果,参考 demo 并没有做特殊处理,但是却仍然有占位的动效。

遇事不决,先看控制台有没有报错,果然:

报错的意思是<Droppable>缺失了placeholder,而报错指向的文档里提到:

provided.placeholder: This is used to create space in the as needed during a drag. This space is needed when a user is dragging over a list that is not the home list. Please be sure to put the placeholder inside of the component for which you have provided the ref. We need to increase the size of the itself.

看起来,之所以拖拽不自然,是因为没有placeholder占位,所以我对<PaintTree>进行了修改,在<Droppable>里添加{provided.placeholder}

重新测试,发现拖拽到两个容器之间时可以正常拉开距离,第二个问题顺利解决。但是第一个问题仍然存在,并且此时我发现了一个新问题:被拖动的元素如果同时是容器,被拖动时会莫名其妙留出一截空位,这截空位跟其被拖动元素的高度差不多,拖放结束会自行消失=-=。猜测可能是自己将自己作为容器拖放进去了。

首先尝试了在 move 时判定 destination.droppableId 和 draggableId 是不是同一个,但是随即发现,如果做了相关处理,两个拖拽的容器之间又不会自动拉开距离了。随后观察了一下此时的动作效果,发现此时<Droppable><Draggable>区域同时高亮。而将组件拖出拖拽区域时,原来留出的一截空位和<Droppable>的高亮也消失了。猜测可能是因为里层是<Droppable>,而外层是<Draggable>,此时里层反而包裹了外层。不过我很快不幸地发现,实际上只要是里层存在<Droppable>,哪怕是属于子组件的,都可以感应到外层的<Draggable>,从而自己拖动到自己的子组件里

翻阅文档 api,发现<Droppable>提供了属性isDropDisabled用于禁止被拖放元素到本容器。于是我有了新想法:可以为<PaintTree>传递isDragging属性,用于标识该组件及其父组件是否正在被拖拽。具体实现原理是,当组件被拖动时,其<Draggable>snapshot.isDragging将会置为 true,可以将该属性值传递给子元素<PaintTree>isDragging属性。此时传入<PaintTree>的 props 中的isDragging为 true,标识父元素正在被拖动,结合起来看,对于<Draggable>而言,props.isDragging||snapshot.isDragging就涵盖了该组件及其父组件是否正在被拖拽

const PaintTree = (props) => {
  return (
    <Droppable
      droppableId={props.droppableId}
+     isDropDisabled={!props.hasChildren||props.isDragging}
    >
      {
        (provided, snapshot) => {
          return (
            <section
              ref={provided.innerRef}
              {...provided.droppableProps}
              style={getListStyle(snapshot.isDraggingOver)}
            >
              <section>
                {props.data.name}
              </section>
              {
                props.children?.length > 0 &&
                  <section>
                    {props.children.map((id,idx)=>
                    <Draggable key={id} draggableId={id} index={idx} >
                      {
                        (provided, snapshot)=>{
                          return (
                            <section
                              ref={provided.innerRef}
                              {...provided.draggableProps}
                              {...provided.dragHandleProps}
                              style={getItemStyle(snapshot.isDragging,provided.draggableProps.style)}
                            >
                              <PaintTree
                                key={id}
                                droppableId={id}
+                               isDragging={snapshot.isDragging||props.isDragging}
                                {...treeData[id]}
                              />
                            </section>
                          )
                        }
                      }
                    </Draggable>
                    )}
                  </section>
              }
              {provided.placeholder}
            </section>
          )
        }
      }
    </Droppable>
  )
}

调试看了看效果,我裹我自己的问题已被解决,但是部分作为容器的元素无法拖动了……打印看了一下拖拽该容器元素时其props.isDragging的值,发现控制台先后输出truefalse两条值就没有后文了。第一条为 true 是因为我们确实对其进行了拖拽,第二条则是因为拖拽最后没有生效,又变回了 false。既然曾经生效过,那应该受了外界的干扰。另外还剩下第一个位移的问题。容明天再继续补一下,今晚想早睡了……


写博客的过程也是梳理自己思路的过程。我是一边实现一边写的博客,写下的都是当时的想法,感觉遇到某些问题时,一开始是没思路的,但是在写博客描述问题的时候,就慢慢地把思路捋顺了。这种自然而然涌现灵感的感受好棒捏-v-

虽然但是,真没想到会碰到这么多问题……本来以为昨晚就可以 debug 结束的,没想到明天还要继续 de。查询 psyq 精神状态。


昨天还遗漏两个问题没解决。一个是拖动位移突变,一个是部分作为容器的元素无法被拖动

对于第一个问题,在我的猜测中,拖动位移突变是因为随父元素的移动而产生的,如果在拖动过程中短暂地以根容器为父元素,由于根容器在拖拽过程中不会发生位移,所以也许 item 就不会再被迫跟着父元素移动了。不过实践了一下,在onDragStart时设置根容器为父元素也不可行,会直接感应到根容器中去。

由于位移突变与<Draggable>有关,于是我去仓库翻阅了关于<Draggable>的 api 文档,看到它提到了关于 position 的说明,并且指向了说明reparenting的文档

We leave elements in place when dragging. We apply position: fixed on elements when we are moving them around. This is quite robust and allows for you to have position: relative | absolute | fixed parents.

看起来元素在拖拽时是fixed定位,按道理来说是不应该被父元素影响的,此前猜测被推翻。

文档 demo 提到可以使用renderClone来定义被拖动时 render 的 item,所以修改代码如下:(已经要变成屎山了 QAQ(写完再拆一下组件好了)

const PaintTree = memo((props) => {
  return (
    <Droppable
      droppableId={props.droppableId}
+     renderClone={
+        (provided, snapshot, rubric) => {
+          // rubric中涵盖了被拖拽的元素id与其父容器的信息
+          return (
+            <div
+              {...provided.draggableProps}
+              {...provided.dragHandleProps}
+              ref={provided.innerRef}
+              style={{
                // 因为provided.draggableProps中有style属性,存放了拖拽动效与组件计算好的当前的坐标,如果直接覆盖的话会导致拖拽行为出错
+               ...provided.draggableProps.style,
+               ...getItemStyle(true)
+             }}
+           >
+             {treeData[rubric.draggableId].data.name}
+           </div>
+         )
+       }
+     }
    >
      {/* ... */}
    </Droppable>
  )
})

改动后拖动位置突变的问题顺利解决了。不过测试的时候又发现,拖拽元素时,拖动到其他层次时可以正常拖动,反而同级之间无法拖动。


2022/11/17 补:

由于一些摸鱼,这个坑一直没动,但是最近被安利了dnd-kit,用了一下真香,所以打算直接换组件库了,这个就暂时无限期拖更了()