实现 Table 多层级结构拖拽排序

一、背景

根据需求,需要对多层级的Table结构做拖拽排序处理,支持跨层级拖拽排序;

要求界面功能简洁,一目了然,无明显卡顿。

根据需求查看市面上常见的拖拽方案

  1. ant-design table方案

    1. 使用dnd-kit完成,例子主要是同层级排序
  2. ant-design tree方案

样式上较为满足,缺点是需求是Table结构

贴一张最终实现效果图参考

二、需求细节拆分

有几个关键点先明确:

名词解释

  • 拖拽元素------当前手拿起来的元素,通常使用drag dragId 等命名,或方法中带有drag字样

  • 目标元素------放置元素,移动或手放开时底下的元素,通常使用drop dropId等命名,或方法中带有drop字样

  • 放置类型------放置的位置距离放置元素的坐标,比如放置在目标元素上方、子元素、下方等

  • 拖动类型------目前业务中有多种拖拽功能,其中有的部分希望同层级拖动,有的则希望跨层级拖动,使用drag === 'all'代表跨层级拖动drag === 'both'代表只支持同层级拖动

拖拽方法

  • onDragStart:当用户开始对一个可拖拽元素进行拖拽操作时触发。

  • onDrag:在元素被拖拽过程中持续触发,即从 dragstart 事件触发后,只要鼠标还在移动且保持拖拽状态,就会不断触发 drag 事件。

  • onDragEnter:当被拖拽元素进入一个有效的放置目标区域时触发。

  • onDragOver:当被拖拽元素在放置目标区域内移动时触发。

  • onDragLeave:被拖拽的元素离开一个有效的拖放目标元素时触发。

  • onDrop:当用户在有效的放置目标区域松开鼠标,完成拖拽操作时触发。

需求点拆分以及代码实现

  • 当前元素如果已经展开,拖拽时应将该元素收起,否则会造成拖拽到自身或自身子元素上
typescript 复制代码
onDragStart: (event: any) => {
  const tempDragId = event.target.getAttribute('data-row-key');
  setDragId(tempDragId);
 // if (drag === 'all') {
 //   const d = listTreeMemo[temp];
 //   if (d.children) {
      onExpand();
 //   }
  }
},
  • 移动到目标元素上,需要展示蓝色的线,我们Table行高为58,分析后拆分底部20px放置在bottom,小于35px放置在body上,其余部分放置在top上;如果一移动到中间部分就展开子元素,体验很差,需要延迟2秒后展开目标元素;使用css添加蓝色的标识线
typescript 复制代码
onDragOver: (event: any) => {
  event.preventDefault();
  const nodes = findTrElement(event.target);
  const drogId = nodes.getAttribute('data-row-key')!;
  if (dragId === drogId) {
    return;
  }
  if (event.nativeEvent.layerY < 20) {
    clearTimeout(tempTimeRef.current);
    tempTimeRef.current = null;
    nodes.classList.remove('body', 'bottom');
    nodes.classList.add('parent-element', 'top');
  } else if (event.nativeEvent.layerY < 35) {
    if (drag === 'all') {
      const d = listTreeMemo[drogId];
      if (d.children && !tempTimeRef.current) {
        // 2s后展开
        tempTimeRef.current = setTimeout(() => {
          onExpand(true, d);
        }, 2000);
      }
      nodes.classList.add('parent-element', 'body', 'bottom');
    } else {
      nodes.classList.add('parent-element', 'bottom');
    }
    nodes.classList.remove('top');
  } else {
    clearTimeout(tempTimeRef.current);
    tempTimeRef.current = null;
    nodes.classList.remove('top', 'body');
    nodes.classList.add('parent-element', 'bottom');
  }
},
less 复制代码
  tr.parent-element {
    position: relative;
    &::after {
      content: '';
      position: absolute;
      left: 0;
      height: 1px;
      background-color: #1677ff;
      z-index: 20;
      width: 100%;
    }
    &.top::after {
      top: 1px;
    }
    &.bottom::after {
      bottom: 1px;
    }
  }
  • 拖拽移动:如果表格元素过多会导致出现滚动条,拖拽到最下方或者最上方希望能自动滚动

注意要做节流

typescript 复制代码
onDrag: (event: any) => {
  scrollRun(event);
}

export function getOffsetTop(element: HTMLElement | null) {
  let tempElement = element;
  let offsetTop = 0;
  while (tempElement) {
    offsetTop += tempElement.offsetTop;
    tempElement = tempElement.offsetParent as HTMLElement;
  }
  return offsetTop;
}

const { run: scrollRun } = useThrottleFn(
(event: any) => {
  if (!contentRef.current) {
    return;
  }
  const offsetTop = offsetTopRef.current;
  const offsetY = event.clientY - offsetTop;
  // 滚动元素
  const scrollDom = contentRef.current?.querySelector('.scrollbar')
    ?.firstChild as HTMLElement;
  if (!scrollDom) {
    return;
  }
  // 表头高度 54
  if (offsetY - 54 < 0) {
    scrollDom.scrollTop -= 30;
  } else if (offsetY >= contentRef.current.clientHeight) {
    scrollDom.scrollTop += 30;
  }
},
{
  wait: 100,
  leading: true,
},
);
  • 放下元素

    • 需要获取dragId和dropId,以及放置类型
    • 如果类型为body,放置到该元素的子元素,那么直接把拖拽元素移动到目标元素的children第一个
    • top或者bottom,移动到目标元素的上方或下方

由于我们实现是通过接口实现的,后端接收的参数是数组,只需要id和sortIndex即可,前端根据后端的入参调整对应的数据传输即可

typescript 复制代码
  const handleDrop = async (
    {
      dropId,
      dragId,
    }: {
      dropId: string;
      dragId: string;
    },
    type: 'top' | 'body' | 'bottom',
  ) => {
    if (dropId === dragId) {
      return;
    }
    let result: any[] = [];
    if (type === 'body' && drag === 'all') {
      const parentItem = listTreeMemo[dropId];

      const tempItem = listTreeMemo[dragId];
      if (parentItem.children?.length) {
        result = [tempItem, ...parentItem.children];
      } else {
        result = [tempItem];
      }
      result = result.map((v, index) => ({
        ...v,
        sortIndex: index,
        id: v.id,
        parentId: dropId || '',
      }));
    } else {
      const dropParentId = listTreeMemo[dropId]?.parentId;
      const dragIndex = listTreeMemo[dragId]?.index;

      const tempList = dropParentId
        ? [...listTreeMemo[dropParentId].children]
        : [...list];

      const draggedItem = listTreeMemo[dragId];

      if (`${tempList[dragIndex]?.id}` === `${dragId}`) {
        tempList.splice(dragIndex, 1);
      }

      const spliceIndex = tempList.findIndex(v => `${v.id}` === `${dropId}`);

      if (type === 'top') {
        tempList.splice(spliceIndex, 0, draggedItem);
      } else {
        tempList.splice(spliceIndex + 1, 0, draggedItem);
      }

      result = tempList.map((v, index) => ({
        ...v,
        sortIndex: index,
        id: v.id,
        parentId: dropParentId || '',
      }));
    }
    setDragId('');
    await handleDrag(result);

    updateTableData();
  };
  • render结构

目前我们用drag 来判断该表格是否开启拖拽模式

typescript 复制代码
<Table onRow={drag ? onRow : undefined} /*{...省略其他业务代码}*/ />

三、代码参考

从业务代码中截取部分代码参考

typescript 复制代码
  const [dragId, setDragId] = useState('');

  const tempTimeRef = useRef<any>(null);

  const offsetTopRef = useRef(0);

  const handleDrop = async (
    {
      dropId,
      dragId,
    }: {
      dropId: string;
      dragId: string;
    },
    type: 'top' | 'body' | 'bottom',
  ) => {
    if (dropId === dragId) {
      return;
    }
    let result: any[] = [];
    if (type === 'body' && drag === 'all') {
      const parentItem = listTreeMemo[dropId];

      const tempItem = listTreeMemo[dragId];
      if (parentItem.children?.length) {
        result = [tempItem, ...parentItem.children];
      } else {
        result = [tempItem];
      }
      result = result.map((v, index) => ({
        ...v,
        sortIndex: index,
        id: v.id,
        parentId: dropId || '',
      }));
    } else {
      const dropParentId = listTreeMemo[dropId]?.parentId;
      const dragIndex = listTreeMemo[dragId]?.index;

      const tempList = dropParentId
        ? [...listTreeMemo[dropParentId].children]
        : [...list];

      const draggedItem = listTreeMemo[dragId];

      if (`${tempList[dragIndex]?.id}` === `${dragId}`) {
        tempList.splice(dragIndex, 1);
      }

      const spliceIndex = tempList.findIndex(v => `${v.id}` === `${dropId}`);

      if (type === 'top') {
        tempList.splice(spliceIndex, 0, draggedItem);
      } else {
        tempList.splice(spliceIndex + 1, 0, draggedItem);
      }

      result = tempList.map((v, index) => ({
        ...v,
        sortIndex: index,
        id: v.id,
        parentId: dropParentId || '',
      }));
    }
    setDragId('');
    await handleDrag(result);

    updateTableData();
  };

  useEffect(() => {
    if (contentRef.current) {
      offsetTopRef.current = getOffsetTop(contentRef.current);
    }
  }, []);

  const { run: scrollRun } = useThrottleFn(
    (event: any) => {
      if (!contentRef.current) {
        return;
      }
      const offsetTop = offsetTopRef.current;
      const offsetY = event.clientY - offsetTop;
      const scrollDom = contentRef.current?.querySelector('.scrollbar')
        ?.firstChild as HTMLElement;
      if (!scrollDom) {
        return;
      }
      if (offsetY - 54 < 0) {
        scrollDom.scrollTop -= 30;
      } else if (offsetY >= contentRef.current.clientHeight) {
        scrollDom.scrollTop += 30;
      }
    },
    {
      wait: 100,
      leading: true,
    },
  );

  
  const onRow = () => ({
    draggable: true,
    style: { cursor: 'move' },
    onDrag: (event: any) => {
      scrollRun(event);
    },
    onDragStart: (event: any) => {
      const temp = event.target.getAttribute('data-row-key');
      setDragId(temp);
      if (drag === 'all') {
        const d = listTreeMemo[temp];
        if (d.children) {
          onExpand(false, d);
        }
      }
    },
    onDragEnter: (event: any) => {
      const nodes = findTrElement(event.target);
      const drogId = nodes.getAttribute('data-row-key')!;
      if (dragId === drogId) {
        return;
      }
      nodes.classList.add('parent-element', 'bottom');
    },
    onDragOver: (event: any) => {
      event.preventDefault();
      const nodes = findTrElement(event.target);
      const drogId = nodes.getAttribute('data-row-key')!;
      if (dragId === drogId) {
        return;
      }
      if (event.nativeEvent.layerY < 20) {
        clearTimeout(tempTimeRef.current);
        tempTimeRef.current = null;
        nodes.classList.remove('body', 'bottom');
        nodes.classList.add('parent-element', 'top');
      } else if (event.nativeEvent.layerY < 35) {
        if (drag === 'all') {
          const d = listTreeMemo[drogId];
          if (d.children && !tempTimeRef.current) {
            // 2s后展开
            tempTimeRef.current = setTimeout(() => {
              onExpand(true, d);
            }, 2000);
          }
          nodes.classList.add('parent-element', 'body', 'bottom');
        } else {
          nodes.classList.add('parent-element', 'bottom');
        }
        nodes.classList.remove('top');
      } else {
        clearTimeout(tempTimeRef.current);
        tempTimeRef.current = null;
        nodes.classList.remove('top', 'body');
        nodes.classList.add('parent-element', 'bottom');
      }
    },
    onDragLeave: (event: any) => {
      const nodes = findTrElement(event.target);
      nodes.classList.remove('parent-element', 'top', 'bottom', 'body');
      clearTimeout(tempTimeRef.current);
      tempTimeRef.current = null;
    },
    onDrop: (event: any) => {
      event.preventDefault();
      event.stopPropagation();
      clearTimeout(tempTimeRef.current);
      tempTimeRef.current = null;
      const nodes = findTrElement(event.target);
      const type =
        (nodes.classList.contains('top') && 'top') ||
        (nodes.classList.contains('body') && 'body') ||
        'bottom';
      nodes.classList.remove('parent-element', 'top', 'bottom', 'body');

      const dropId = nodes.getAttribute('data-row-key')!;
      handleDrop(
        {
          dropId,
          dragId,
        },
        type,
      );
    },
  });
  

四、参考文章

# 快速实现一个antd的table组件的拖拽排序功能

相关推荐
C语言魔术师3 分钟前
【小游戏篇】三子棋游戏
前端·算法·游戏
匹马夕阳1 小时前
Vue 3中导航守卫(Navigation Guard)结合Axios实现token认证机制
前端·javascript·vue.js
你熬夜了吗?1 小时前
日历热力图,月度数据可视化图表(日活跃图、格子图)vue组件
前端·vue.js·信息可视化
桂月二二7 小时前
探索前端开发中的 Web Vitals —— 提升用户体验的关键技术
前端·ux
hunter2062069 小时前
ubuntu向一个pc主机通过web发送数据,pc端通过工具直接查看收到的数据
linux·前端·ubuntu
qzhqbb9 小时前
web服务器 网站部署的架构
服务器·前端·架构
刻刻帝的海角9 小时前
CSS 颜色
前端·css
九酒9 小时前
从UI稿到代码优化,看Trae AI 编辑器如何帮助开发者提效
前端·trae
浪浪山小白兔10 小时前
HTML5 新表单属性详解
前端·html·html5
lee57610 小时前
npm run dev 时直接打开Chrome浏览器
前端·chrome·npm