iview Table组件树形表格拖拽排序实现

317 阅读4分钟

前言

 最近接到一个需求,需要在树形表格增加拖拽排序的功能,因为公司前端使用的UI库是iview,便去查看iview的Table组件API,看看有没有现成的属性和方法可以使用,查看API后发现draggable属性和@on-drag-drop方法可以实现拖拽排序功能,在Table上添加相应的内容后发现树形表格组件并不能很好的实现拖拽功能,只有一级数据具有拖拽效果。
 查看dom后发现子级的tr标签上draggable都是false导致不能拖拽如下图1-1所示,在给子级tr标签draggable设置为ture后子级可以拖动,但是@on-drag-drop并不能返回子级的信息,导致不能使用。至此决定对iview的Table组件进行修改,也方便以后有相关需求后可以方便的复用。 1730037804785.png

图1-1

功能实现

 大概实现思路是直接操作dom元素,并在dom元素上增加相应的拖拽事件以实现拖拽排序的功能。在以后的开发中若是遇见组件库并不能直接满足需求时,且不能修改源码,就可以使用这种方法对组件进行修改以实现相应的功能。
需要注意的是这种操作可能会导致其他正在使用相关组件的地方发生意想不到的问题,且不易排查。在使用此方法时要控制修改dom的生效范围,以防问题的产生。

最终效果

screenshot_2024-10-27_23-16-38.gif

1.解析dom增加属性

通过this.$refs.table.$el获取对应Table的tr标签(行),增加以下属性
draggable:true 开启拖拽效果
code tr标签的唯一识别方便后续操作
location 格式为[parentIndex-childIndex-...],用于表示元素所在的层级

treeDrag() {
  let trDom = this.$refs.table.$el
      .getElementsByTagName("table")[1]
      .getElementsByTagName("tr");

  let array = []
  this.recursive(this.data, array, '')
  for (let i = 0; i < trDom.length; i++) {
    trDom[i].setAttribute("draggable", true);
    trDom[i].setAttribute("code", array[i].code);
    trDom[i].setAttribute("location", array[i].location);
  }
},

因为表格data数据是树形结构,而表格中tr标签的结构如图1-1所示,是非树形结构,则需要通过recursive() 方法将表格数据转换为非树形结构,并此过程中设置好location

recursive(inArr, outArr, parentIndex) {
  inArr.forEach((item, index) => {
    outArr.push({ code: item.id, location: parentIndex === '' ? parentIndex + index : `${parentIndex}-${index}` })
    if (item.children) {
      this.recursive(item.children, outArr, parentIndex === '' ? parentIndex + index : `${parentIndex}-${index}`);
    }
  });
},

2.增加拖拽事件

使用到的拖拽事件有dragstart,dragend,dragenter,dragleave,dragover
在data中定义好对应事件,在表格重新加载或数据发生变化时方便销毁拖拽事件(若不销毁之前的拖拽事件,会导致重复绑定,事件触发多次的问题)

data() {
  return {
    drag: {
      code: '',
      location: ''
    },
    goal: {
      code: '',
      location: '',
      way:''
    },
    dragstart:null,
    dragend: null,
    dragenter: null,
    dragleave: null,
    dragover: null,
  };
},

dragover 设置dropEffect 修改鼠标样式,否则是默认的禁用标志, 通过getBoundingClientRect() 方法,以获取相应元素的位置、鼠标悬浮位置等信息,用于判断鼠标在目标行的上半部分还是下半部分。

mounted() {
  this.dragover = (e) => {
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move'; // 设置鼠标样式为移动
    const rect = e.target.getBoundingClientRect()
    let element = this.findRow(e.target)
    const mouseY = e.clientY;
    if (mouseY < rect.top + (rect.height / 2)) {
      this.goal.way = "top"
      element.classList.add('drag-top')
      element.classList.remove('drag-bottom')
    } else {
      this.goal.way = "bottom"
      element.classList.add('drag-bottom')
      element.classList.remove('drag-top')
    }
  }

  this.dragstart = (e) => {
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.dropEffect = 'move'; // 设置鼠标样式为移动
  }

  this.dragend = (e) => {
    e.preventDefault();
    this.drag.code = e.target.getAttribute('code')
    this.drag.location = e.target.getAttribute('location')
    let trDom = this.$refs.table.$el
        .getElementsByTagName("table")[1]
        .getElementsByTagName("tr");
    for (let i = 0; i < trDom.length; i++) {
      trDom[i].classList.remove('demo-table-info-row')
      trDom[i].classList.remove('drag-top')
      trDom[i].classList.remove('drag-bottom')
    }
    this.$emit('treeDragDrop', { drag: this.drag, goal: this.goal })
  };

  this.dragenter = (e) => {
    let element = this.findRow(e.target)
    element.classList.add('demo-table-info-row')
    this.goal.code = element.getAttribute('code')
    this.goal.location = element.getAttribute('location')
    e.preventDefault();
  };

  this.dragleave = (e) => {
    let element = this.findRow(e.target)
    if (this.goal.code !== element.getAttribute('code')) {
      element.classList.remove('demo-table-info-row')
    }
    element.classList.remove('drag-top')
    element.classList.remove('drag-bottom')
    e.preventDefault();
  };
},

在dragenter、dragleave、dragover有时会返回tr标签的子标签,会导致获取不到绑定在tr标签的相关属性,且也不能正确的将拖拽样式绑定到对应的行上,故通过递归的方式向上找对应标签的父标签,以获取到目标tr标签

findRow(element) {
  if (element) {
    if (element.getAttribute("code")) {
      return element
    } else {
      return this.findRow(element.parentNode)
    }
  }
},

3.完整示例代码

<template>
  <Table row-key="id" ref="table" :columns="columns" :data="data">
  </Table>
</template>
<script>
export default {
  data() {
    return {
      drag: {
        code: '',
        location: ''
      },
      goal: {
        code: '',
        location: ''
      },
      dragstart:null,
      dragend: null,
      dragenter: null,
      dragleave: null,
      dragover: null,
      columns: [
        {
          title: 'Name',
          key: 'name',
          tree: true
        },
        {
          title: 'Age',
          key: 'age'
        },
        {
          title: 'Address',
          key: 'address'
        }
      ],
      data: [
        {
          id: '100',
          name: 'John Brown',
          age: 18,
          address: 'New York No. 1 Lake Park'
        },
        {
          id: '101',
          name: 'Jim Green',
          age: 24,
          address: 'London No. 1 Lake Park',
          children: [
            {
              id: '10100',
              name: 'John Brown',
              age: 18,
              address: 'New York No. 1 Lake Park'
            },
            {
              id: '10101',
              name: 'Joe Blackn',
              age: 30,
              address: 'Sydney No. 1 Lake Park'
            },
            {
              id: '10102',
              name: 'Jon Snow',
              age: 26,
              address: 'Ottawa No. 2 Lake Park',
              children: [
                {
                  id: '1010200',
                  name: 'Jim Green',
                  age: 24,
                  address: 'New York No. 1 Lake Park'
                }
              ]
            }
          ]
        },
        {
          id: '102',
          name: 'Joe Black',
          age: 30,
          address: 'Sydney No. 1 Lake Park'
        },
        {
          id: '103',
          name: 'Jon Snow',
          age: 26,
          address: 'Ottawa No. 2 Lake Park'
        }
      ]
    };
  },
  mounted() {
    this.dragover = (e) => {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'move'; // 设置鼠标样式为移动
      const rect = e.target.getBoundingClientRect()
      let element = this.findRow(e.target)
      const mouseY = e.clientY;
      if (mouseY < rect.top + (rect.height / 2)) {
        this.goal.way = "top"
        element.classList.add('drag-top')
        element.classList.remove('drag-bottom')
      } else {
        this.goal.way = "bottom"
        element.classList.add('drag-bottom')
        element.classList.remove('drag-top')
      }
    }

    this.dragstart = (e) => {
      e.dataTransfer.effectAllowed = 'move';
      e.dataTransfer.dropEffect = 'move'; // 设置鼠标样式为移动
    }

    this.dragend = (e) => {
      e.preventDefault();
      this.drag.code = e.target.getAttribute('code')
      this.drag.location = e.target.getAttribute('location')
      let trDom = this.$refs.table.$el
          .getElementsByTagName("table")[1]
          .getElementsByTagName("tr");
      for (let i = 0; i < trDom.length; i++) {
        trDom[i].classList.remove('demo-table-info-row')
        trDom[i].classList.remove('drag-top')
        trDom[i].classList.remove('drag-bottom')
      }
      this.$emit('treeDragDrop', { drag: this.drag, goal: this.goal })
    };

    this.dragenter = (e) => {
      let element = this.findRow(e.target)
      element.classList.add('demo-table-info-row')
      this.goal.code = element.getAttribute('code')
      this.goal.location = element.getAttribute('location')
      e.preventDefault();
    };

    this.dragleave = (e) => {
      let element = this.findRow(e.target)
      if (this.goal.code !== element.getAttribute('code')) {
        element.classList.remove('demo-table-info-row')
      }
      element.classList.remove('drag-top')
      element.classList.remove('drag-bottom')
      e.preventDefault();
    };

    this.$nextTick(() => {
      this.treeDrag()
    })
  },
  methods: {

    // 递归访问数组并添加元素
    recursive(inArr, outArr, parentIndex) {
      inArr.forEach((item, index) => {
        outArr.push({ code: item.id, location: parentIndex === '' ? parentIndex + index : `${parentIndex}-${index}` })
        if (item.children) {
          this.recursive(item.children, outArr, parentIndex === '' ? parentIndex + index : `${parentIndex}-${index}`);
        }
      });
    },

    //递归查找行
    findRow(element) {
      if (element) {
        if (element.getAttribute("code")) {
          return element
        } else {
          return this.findRow(element.parentNode)
        }
      }
    },

    treeDrag() {
      let trDom = this.$refs.table.$el
          .getElementsByTagName("table")[1]
          .getElementsByTagName("tr");

      let array = []
      this.recursive(this.data, array, '')
      for (let i = 0; i < trDom.length; i++) {
        trDom[i].setAttribute("draggable", true);
        trDom[i].setAttribute("code", array[i].code);
        trDom[i].setAttribute("location", array[i].location);
        trDom[i].removeEventListener("dragend", this.dragend);
        trDom[i].removeEventListener("dragenter", this.dragenter);
        trDom[i].removeEventListener("dragleave", this.dragleave);
        trDom[i].removeEventListener("dragstart", this.dragstart);
        trDom[i].removeEventListener("dragover", this.dragover);

        trDom[i].addEventListener("dragend", this.dragend);
        trDom[i].addEventListener('dragenter', this.dragenter);
        trDom[i].addEventListener('dragleave', this.dragleave);
        trDom[i].addEventListener("dragstart", this.dragstart);
        trDom[i].addEventListener("dragover", this.dragover);
      }
    },

    closeTreeDrag() {
      let trDom = this.$refs.table.$el
          .getElementsByTagName("table")[1]
          .getElementsByTagName("tr");
      for (let i = 0; i < trDom.length; i++) {
        trDom[i].setAttribute("draggable", false);
        trDom[i].removeEventListener("dragend", this.dragend);
        trDom[i].removeEventListener("dragenter", this.dragenter);
        trDom[i].removeEventListener("dragleave", this.dragleave);
        trDom[i].removeEventListener("dragstart", this.dragstart);
        trDom[i].removeEventListener("dragover", this.dragover);
      }
    }
  },
};
</script>
<style scoped>
:deep(.ivu-table) .demo-table-info-row td {
  background-color: #d0dbf9 !important;
  color: #fff !important;
}
:deep(.ivu-table) .drag-top td {
  border-top: #57a3f3 solid 3px !important;
}

:deep(.ivu-table) .drag-bottom td {
  border-bottom: #57a3f3 solid 3px !important;
}
</style>

4.排序逻辑及失败回滚逻辑

以下方法的排序逻辑是只有相同父级下的同一级数据可以拖动排序,

treeDragDrop(e){
  if(e.drag.location === e.goal.location){
    this.$Message.success('排序成功')
    return
  }
  if(this.compareStrings(e.drag.location, e.goal.location)){
    let drag = e.drag.location.split("-").map(item => parseInt(item))
    let goal = e.goal.location.split("-").map(item => parseInt(item))
    let opArray = this.data
    if (drag.length > 1) {
      for (let i = 0; i < drag.length-1; i++) {
        opArray = opArray[drag[i]].children
      }
    }
    let rollBack = JSON.parse(JSON.stringify(opArray[drag[drag.length-1]]))
    
    // 排序逻辑
    if(e.goal.way === 'top'){
      opArray.splice(goal[goal.length-1], 0,opArray[drag[drag.length-1]])
      opArray.splice(drag[drag.length-1] < goal[goal.length-1] ? drag[drag.length-1] : drag[drag.length-1]+1, 1)
    }else{
      opArray.splice(goal[goal.length-1]+1, 0,opArray[drag[drag.length-1]])
      opArray.splice(drag[drag.length-1] < goal[goal.length-1] ? drag[drag.length-1] : drag[drag.length-1]+1, 1)
    }
    

    // 排序失败时的回滚逻辑
    if(e.goal.way === 'top'){
      if(drag[drag.length-1]>goal[goal.length-1]){
        opArray.splice(drag[drag.length-1]+1,0,rollBack)
        opArray.splice(goal[goal.length-1],1)
      }else{
        opArray.splice(goal[goal.length-1]-1,1)
        opArray.splice(drag[drag.length-1],0,rollBack)
      }
    }else{
      if(drag[drag.length-1]>goal[goal.length-1]){
        opArray.splice(drag[drag.length-1]+1,0,rollBack)
        opArray.splice(goal[goal.length-1]+1,1)
      }else{
        opArray.splice(goal[goal.length-1],1)
        opArray.splice(drag[drag.length-1],0,rollBack)
      }
    }
    // 排序后由于数据发生变化通过treeDrag()方法更新location信息
    this.$nextTick(()=>{
      this.$refs.treeTable.treeDrag()
    })
  }else{
    this.$Message.warning('不能跨部门移动')
  }
}
// 用于比较是否是相父同级数据
compareStrings(str1, str2) {
  const regex = /.*-(?=[^-]+$)/;
  const part1 = str1.replace(regex, '');
  const part2 = str2.replace(regex, '');
  const beforeDash1 = str1.slice(0, str1.length - part1.length);
  const beforeDash2 = str2.slice(0, str2.length - part2.length);
  return beforeDash1 === beforeDash2;
}