实现vue表格组件Table的拖拽功能

812 阅读3分钟

概述

最近在做后台管理系统,由于表格中涉及到多列,但是一列中往往会出现大量的表格内容,为了给用户提供更好的用户体验,因此需要实现对表格列的拖拽功能,以便用户可以拖拽查看更多内容,由于表格组件实现全部功能还是很复杂的,我个人对iview组件库中的table组件拖拽调整列宽比较感兴趣,因此自己也实现一下这个功能,加深对拖拽以及各种尺寸计算的理解。

最终效果

动画.gif

实现分析

表格基本功能

  • 首先要实现这个功能,我们需要实现一个基本功能的表格组件,包含提供列表数据(data)和列配置项(column)
  • 对于列是否可拖拽和初始宽度,我们直接在column数据项中配置
  • 为了实现更加灵活的表格组件,对于单元格的内容,我们需要提供作用域插槽,让用户对表格中的数据展示方式能够自定义展示方式。
  • 对上面进行封装完毕其实一个基本的表格组件就完成,更多的功能在上面基础之上添加

表格推拽功能

  • 表格推拽需要对清楚原生js实现拖拽元素的操作流程,请参考原生js实现拖拽,或者以及html5新提供的推拽相关的事件(drag,dragstart,startend等),参考MDN推拽
  • 需要对offsetLeft、offsetParent、clientX、offsetWidth、getBoundingClientRect等这些常用来计算尺寸和距离的属性和方法。
  • 对于列宽需要通过colgroup标签和col标签来设置,以及表格的table-layout属性。

实现

提供两个版本的拖拽写法,除了拖拽的部分不同其余完全一样

原生js实现版本

./Table.vue

<template>
  <div class="gnip-table" ref="gnipTable">
    <div class="overflow-wrap" ref="overflowWrap" :style="{ width: initTableWidth + 'px' }">
      <div class="table-wrap" :style="{ width: dynamicTableWidth + 'px' }">
        <table :style="{ width: dynamicTableWidth + 'px' }">
          <!--  标签用于对表格中的列进行组合,以便对其进行格式化。 -->
          <colgroup>
            <col
              :width="item.width || columnDefault"
              v-for="(item, index) in column"
              :key="index"
              ref="colgroupItems"
            />
          </colgroup>
          <!-- 表头 -->
          <thead>
            <tr>
              <th v-for="(item, index) in column" :key="index" class="gnip-th">
                <span class="cell-title">{{ item.title }}</span>
                <span
                  class="drag-line"
                  @mousedown="handleMouseDown(index, $event)"
                ></span>
              </th>
            </tr>
          </thead>
          <!-- 表体 -->
          <tbody v-if="data.length">
            <tr v-for="(dataColumn, dataIndex) in data" :key="data.Id">
              <td v-for="(item, index) in column" :key="index">
                <div class="content-cel" v-if="item.slot">
                  <slot
                    :name="item.slot"
                    v-bind:row="dataColumn"
                    v-bind:index="dataIndex"
                  ></slot>
                </div>
                <div class="content-cel" v-else>
                  {{ dataColumn[item.key] }}
                </div>
              </td>
            </tr>
          </tbody>
          <tfoot v-if="!data.length">
            <tr>
              <td :colspan="column.length">
                <div class="data-empty">暂无数据</div>
              </td>
            </tr>
          </tfoot>
        </table>
      <!-- 拖拽线 -->
        <div
          class="drag-resize-line"
          :style="computedResizeLineStyle"
          v-if="showDragResizeLine"
        ></div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    data: {
      type: Array,
      default: () => {
        return [];
      },
    },
    column: {
      type: Array,
      default: () => {
        return [];
      },
    },
  },
  data() {
    return {
      // 拖拽线左侧偏移量
      dragLineLeft: 0,
      // 当前拖拽的td索引项
      activeDragIndex: -1,
      // 开始拖拽那一刻,距离表格左侧的距离
      activeDragStartIndexOffsetLeft: 0,
      // 表格的宽度
      gnipTableScrollWidth: "",
      // 是否显示拖拽线
      showDragResizeLine: false,
      // 默认列宽为自动平分计算
      columnDefault: 0,
      // 记录对应列是被拖拽过
      dragedRecord: [],
      // 表格宽度,初始的时候固定宽度,超出滚动条,兼容多列数据放不下问题
      initTableWidth: 0,
      // 动态计算表格宽度
      dynamicTableWidth: 0,
    };
  },
  computed: {
    computedResizeLineStyle() {
      return {
        left: this.dragLineLeft + "px",
      };
    },
  },
  mounted() {
    this.init();
  },
  methods: {
    // 初始化
    init() {
      this.initTableScrollWidth();
      // 初始化北拖拽的记录
      this.dragedRecord = new Array(this.column.length).fill(0).map((item, index) => {
          if (this.column[index].width) {
            // [是否被拖拽过或者设置过默认宽度,被拖拽的宽度或者具体拖拽后的宽度(实时更新)]
            return [true, this.column[index].width * 1];
          } else {
            return [false, 0];
          }
        });
    },
    // 初始化表格宽度
    initTableScrollWidth() {
      this.gnipTableScrollWidth = this.$refs.gnipTable.offsetWidth;
       this.dynamicTableWidth = this.gnipTableScrollWidth - 1;
       this.initTableWidth =  this.gnipTableScrollWidth + 1;
      // 自定义宽度的列数
      const autoWidthColumnCount = this.column.filter((item) => !item.width).length;
      // 剩余可自定义宽度
      const resetAutoWidth = this.column.filter((item) => item.width).reduce((prev, now) => prev + now.width * 1, 0);
      // 剩余自适应宽度的列可分得的宽度,最低为0
      const columnResetWidth = (this.gnipTableScrollWidth - resetAutoWidth) / autoWidthColumnCount;
      this.columnDefault = columnResetWidth > 0 ? columnResetWidth : 0;
    },
    // 鼠标按下
    handleMouseDown(index, event) {
      // console.log("鼠标按下");
      // 计算拖拽线的距离
      this.computedDragLineOffsetStart(event.target);
      //记录当前拖拽的表头索引
      this.activeDragIndex = index;
      // 显示拖拽线
      this.showDragResizeLine = true;
      document.addEventListener("mousemove", this.handleMouseMove); //拖动中
      document.addEventListener("mouseup", this.handleMouseup); //抬起
    },
    // 拖动中
    handleMouseMove(event) {
      // console.log("拖动中");
      this.computedDragLineOffsetDraging(event);
      // 设置body鼠标样式为col-resize
      document.body.style.cursor = "col-resize";
    },
    // 鼠标抬起
    handleMouseup(event) {
      this.computedDragLineOffsetDraging(event);
      // // 设置宽度
      this.setColGroupItemWidth();
      this.activeDragStartIndexOffsetLeft = 0;
      // 隐藏拖拽线
      this.showDragResizeLine = false;
      // 恢复body的鼠标样式
      document.body.style.cursor = "";

      // 移除事件
      document.removeEventListener("mousemove", this.handleMouseMove);
      document.removeEventListener("mousedown", this.handleMouseDown);
      document.removeEventListener("mouseup", this.handleMouseup);
    },
    // 计算拖拽线的偏移量
    computedDragLineOffsetStart(targetEle) {
      let parent = targetEle.offsetParent;
      let left = targetEle.offsetLeft;
      while (parent) {
        if (parent == this.$refs.overflowWrap) {
          // 说明找到了外层相对定位的父级
          break;
        }
        left += parent.offsetLeft;
        parent = parent.offsetParent;
      }
      // 如果出现了水平滚动条,要加上水平滚动条卷走的宽度
      this.dragLineLeft = left + this.$refs.overflowWrap.scrollLeft;
      // console.log(this.$refs.)
      // 记录按下刚开始拖拽的时候的位置
      !this.activeDragStartIndexOffsetLeft && (this.activeDragStartIndexOffsetLeft = left);
      return left;
    },
    // 拖拽中计算偏移量
    computedDragLineOffsetDraging(event) {
      let clientX = event.clientX;
      let targetEle = this.$refs.overflowWrap;
      let x = targetEle.offsetLeft;
      let parent = targetEle.offsetParent;
      while (parent) {
        x += parent.offsetLeft;
        parent = parent.offsetParent;
      }
      let left = clientX - x < 0 ? 0 : clientX - x;
      this.dragLineLeft = left + this.$refs.overflowWrap.scrollLeft;;
    },
    // 计算表头列的宽度
    setColGroupItemWidth() {
      // 需要增加的宽度
      let addWidth = this.dragLineLeft - this.activeDragStartIndexOffsetLeft;

      this.$nextTick(() => {
        //  原来的宽度
        let { width } =
          this.$refs.colgroupItems[
            this.activeDragIndex
          ].getBoundingClientRect();
        // 设置一个最小的宽度取值为30px,避免出现负值
        const computedWidth = addWidth + width < 30 ? 30 : addWidth + width;
        // 动态设置表格宽度
        this.dynamicTableWidth = this.dynamicTableWidth + addWidth > this.initTableWidth ? this.dynamicTableWidth += addWidth : this.initTableWidth - 1;
        // 标记记录当前列为拖拽过了
        this.dragedRecord[this.activeDragIndex] = [true, computedWidth];
        // 自定义宽度的列数(没被拖拽的重新计算列宽)
        const autoWidthColumnCount = this.dragedRecord.filter(
          (item) => !item[0]
        ).length;
        // 计算需要减去的宽度
        const subtractWidth = this.dragedRecord.filter((item) => item[0]).reduce((prev, now) => {
            return now[1] + prev;
          }, 0);
        for (let i = 0; i < this.$refs.colgroupItems.length; i++) {
          // 设置当前拖拽列的宽度
          if (i == this.activeDragIndex) {
            this.$refs.colgroupItems[this.activeDragIndex].setAttribute( "width", computedWidth);
          } else {
            // 设置其它的列的宽度(排除配置项设置过的列)
            const isComputed = (this.dragedRecord.find((item, index) => index == i) || [])[0];
            !isComputed &&
              this.$refs.colgroupItems[i].setAttribute("width", (this.dynamicTableWidth - subtractWidth) / autoWidthColumnCount);
          }
        }
      });
    },
  },
};
</script>

<style lang="less">
.gnip-table {
  position: relative;
  .table-wrap {
    // overflow: auto;
  }
  .overflow-wrap{
    position: relative;
    overflow: auto;
  }
  table {
    border-collapse: collapse;
    table-layout: fixed;
    border: 1px solid #e8eaec;
    .data-empty {
      text-align: center;
    }
    .gnip-th {
      position: relative;
      background-color: #f8f8f9;
      padding: 8px 0;
      .drag-line {
        position: absolute;
        width: 5px;
        height: 100%;
        right: 0;
        top: 0;
        cursor: col-resize;
        user-select: none;
        z-index: 1;
      }
    }
  }
  table,
  th,
  td {
    border: 1px solid #e8eaec;
    text-align: center;
    word-break: break-all;
  }
  thead {
    .th {
      background-color: #f8f8f9;
    }
  }
  .drag-resize-line {
    height: 100%;
    width: 1px;
    border-right: 1px dashed green;
    position: absolute;
    left: 0;
    top: 0;
  }
  tbody {
    tr {
      &:hover {
        background: #ebf7ff;
      }
    }
    td {
      height: 48px;
      box-sizing: border-box;
    }
  }
}
</style>

HTML5版本的

./Table.vue

<template>
  <div class="gnip-table" ref="gnipTable">
    <div
      class="overflow-wrap"
      ref="overflowWrap"
      :style="{ width: initTableWidth + 'px' }"
    >
      <div class="table-wrap" :style="{ width: dynamicTableWidth + 'px' }">
        <table :style="{ width: dynamicTableWidth + 'px' }">
          <!--  标签用于对表格中的列进行组合,以便对其进行格式化。 -->
          <colgroup>
            <col
              :width="item.width || columnDefault"
              v-for="(item, index) in column"
              :key="index"
              ref="colgroupItems"
            />
          </colgroup>
          <!-- 表头 -->
          <thead>
            <tr>
              <th v-for="(item, index) in column" :key="index" class="gnip-th">
                <span class="cell-title">{{ item.title }}</span>
                <span
                  class="drag-line"
                  draggable="true"
                  @dragstart="handleDragStart(index, $event)"
                  @drag="handleDrag($event)"
                  @dragend="handleDragend($event)"
                ></span>
              </th>
            </tr>
          </thead>
          <!-- 表体 -->
          <tbody v-if="data.length">
            <tr v-for="(dataColumn, dataIndex) in data" :key="data.Id">
              <td v-for="(item, index) in column" :key="index">
                <div class="content-cel" v-if="item.slot">
                  <slot
                    :name="item.slot"
                    v-bind:row="dataColumn"
                    v-bind:index="dataIndex"
                  ></slot>
                </div>
                <div class="content-cel" v-else>
                  {{ dataColumn[item.key] }}
                </div>
              </td>
            </tr>
          </tbody>
          <tfoot v-if="!data.length">
            <tr>
              <td :colspan="column.length">
                <div class="data-empty">暂无数据</div>
              </td>
            </tr>
          </tfoot>
        </table>
        <!-- 拖拽线 -->
        <div
          class="drag-resize-line"
          :style="computedResizeLineStyle"
          v-if="showDragResizeLine"
        ></div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    data: {
      type: Array,
      default: () => {
        return [];
      },
    },
    column: {
      type: Array,
      default: () => {
        return [];
      },
    },
  },
  data() {
    return {
      // 拖拽线左侧偏移量
      dragLineLeft: 0,
      // 当前拖拽的td索引项
      activeDragIndex: -1,
      // 开始拖拽那一刻,距离表格左侧的距离
      activeDragStartIndexOffsetLeft: 0,
      // 表格的宽度
      gnipTableScrollWidth: "",
      // 是否显示拖拽线
      showDragResizeLine: false,
      // 默认列宽为自动平分计算
      columnDefault: 0,
      // 记录对应列是被拖拽过
      dragedRecord: [],
      // 表格宽度,初始的时候固定宽度,超出滚动条,兼容多列数据放不下问题
      initTableWidth: 0,
      // 动态计算表格宽度
      dynamicTableWidth: 0,
    };
  },
  computed: {
    computedResizeLineStyle() {
      return {
        left: this.dragLineLeft + "px",
      };
    },
  },
  mounted() {
    this.init();
  },
  methods: {
    // 初始化
    init() {
      this.initTableScrollWidth();
      // 初始化北拖拽的记录
      this.dragedRecord = new Array(this.column.length)
        .fill(0)
        .map((item, index) => {
          if (this.column[index].width) {
            // [是否被拖拽过或者设置过默认宽度,被拖拽的宽度或者具体拖拽后的宽度(实时更新)]
            return [true, this.column[index].width * 1];
          } else {
            return [false, 0];
          }
        });
    },
    // 初始化表格宽度
    initTableScrollWidth() {
      this.gnipTableScrollWidth = this.$refs.gnipTable.offsetWidth;
      this.dynamicTableWidth = this.gnipTableScrollWidth - 1;
      this.initTableWidth = this.gnipTableScrollWidth + 1;
      // 自定义宽度的列数
      const autoWidthColumnCount = this.column.filter(
        (item) => !item.width
      ).length;
      // 剩余可自定义宽度
      const resetAutoWidth = this.column
        .filter((item) => item.width)
        .reduce((prev, now) => prev + now.width * 1, 0);
      // 剩余自适应宽度的列可分得的宽度,最低为0
      const columnResetWidth =
        (this.gnipTableScrollWidth - resetAutoWidth) / autoWidthColumnCount;
      this.columnDefault = columnResetWidth > 0 ? columnResetWidth : 0;
    },
    // 推拽开始(执行一次)
    handleDragStart(index, event) {
      console.log("推拽开始");
      // 计算拖拽线的距离
      this.computedDragLineOffsetStart(event.target);
      //记录当前拖拽的表头索引
      this.activeDragIndex = index;
      // 显示拖拽线
      this.showDragResizeLine = true;
    },
    // 拖动中
    handleDrag(event) {
      console.log("拖动中");
      this.computedDragLineOffsetDraging(event);
      // 设置body鼠标样式为col-resize
      document.body.style.cursor = "col-resize";
    },
    // 推拽结束
    handleDragend(event) {
      console.log("拖拽结束");
      this.computedDragLineOffsetDraging(event);
      // // 设置宽度
      this.setColGroupItemWidth();
      this.activeDragStartIndexOffsetLeft = 0;
      // 隐藏拖拽线
      this.showDragResizeLine = false;
      // 恢复body的鼠标样式
      document.body.style.cursor = "";
    },
    // 计算拖拽线的偏移量
    computedDragLineOffsetStart(targetEle) {
      let parent = targetEle.offsetParent;
      let left = targetEle.offsetLeft;
      while (parent) {
        if (parent == this.$refs.overflowWrap) {
          // 说明找到了外层相对定位的父级
          break;
        }
        left += parent.offsetLeft;
        parent = parent.offsetParent;
      }
      // 如果出现了水平滚动条,要加上水平滚动条卷走的宽度
      this.dragLineLeft = left + this.$refs.overflowWrap.scrollLeft;
      // console.log(this.$refs.)
      // 记录按下刚开始拖拽的时候的位置
      !this.activeDragStartIndexOffsetLeft &&
        (this.activeDragStartIndexOffsetLeft = left);
      return left;
    },
    // 拖拽中计算偏移量
    computedDragLineOffsetDraging(event) {
      let clientX = event.clientX;
      let targetEle = this.$refs.overflowWrap;
      let x = targetEle.offsetLeft;
      let parent = targetEle.offsetParent;
      while (parent) {
        x += parent.offsetLeft;
        parent = parent.offsetParent;
      }
      let left = clientX - x < 0 ? 0 : clientX - x;
      this.dragLineLeft = left + this.$refs.overflowWrap.scrollLeft;
    },
    // 计算表头列的宽度
    setColGroupItemWidth() {
      // 需要增加的宽度
      let addWidth = this.dragLineLeft - this.activeDragStartIndexOffsetLeft;

      this.$nextTick(() => {
        //  原来的宽度
        let { width } =
          this.$refs.colgroupItems[
            this.activeDragIndex
          ].getBoundingClientRect();
        // 设置一个最小的宽度取值为30px,避免出现负值
        const computedWidth = addWidth + width < 30 ? 30 : addWidth + width;
        // 动态设置表格宽度
        this.dynamicTableWidth =
          this.dynamicTableWidth + addWidth > this.initTableWidth
            ? (this.dynamicTableWidth += addWidth)
            : this.initTableWidth - 1;
        // 标记记录当前列为拖拽过了
        this.dragedRecord[this.activeDragIndex] = [true, computedWidth];
        // 自定义宽度的列数(没被拖拽的重新计算列宽)
        const autoWidthColumnCount = this.dragedRecord.filter(
          (item) => !item[0]
        ).length;
        // 计算需要减去的宽度
        const subtractWidth = this.dragedRecord
          .filter((item) => item[0])
          .reduce((prev, now) => {
            return now[1] + prev;
          }, 0);
        for (let i = 0; i < this.$refs.colgroupItems.length; i++) {
          // 设置当前拖拽列的宽度
          if (i == this.activeDragIndex) {
            this.$refs.colgroupItems[this.activeDragIndex].setAttribute(
              "width",
              computedWidth
            );
          } else {
            // 设置其它的列的宽度(排除配置项设置过的列)
            const isComputed = (this.dragedRecord.find(
              (item, index) => index == i
            ) || [])[0];
            !isComputed &&
              this.$refs.colgroupItems[i].setAttribute(
                "width",
                (this.dynamicTableWidth - subtractWidth) / autoWidthColumnCount
              );
          }
        }
      });
    },
  },
};
</script>

<style lang="less">
.gnip-table {
  position: relative;
  .table-wrap {
    // overflow: auto;
  }
  .overflow-wrap {
    position: relative;
    overflow: auto;
  }
  table {
    border-collapse: collapse;
    table-layout: fixed;
    border: 1px solid #e8eaec;
    .data-empty {
      text-align: center;
    }
    .gnip-th {
      position: relative;
      background-color: #f8f8f9;
      padding: 8px 0;
      .drag-line {
        position: absolute;
        width: 5px;
        height: 100%;
        right: 0;
        top: 0;
        cursor: col-resize;
        user-select: none;
        z-index: 1;
      }
    }
  }
  table,
  th,
  td {
    border: 1px solid #e8eaec;
    text-align: center;
    word-break: break-all;
  }
  thead {
    .th {
      background-color: #f8f8f9;
    }
  }
  .drag-resize-line {
    height: 100%;
    width: 1px;
    border-right: 1px dashed green;
    position: absolute;
    left: 0;
    top: 0;
  }
  tbody {
    tr {
      &:hover {
        background: #ebf7ff;
      }
    }
    td {
      height: 48px;
      box-sizing: border-box;
    }
  }
}
</style>


使用

./App.vue

<template>
  <div class="app">
    <div class="table">
      <Table :data="data" :column="column">
        <template v-slot:operate="{ row, index }">
          <button>操作{{ index }}</button>
        </template>
      </Table>
    </div>
  </div>
</template>

<script>
import { Table } from "@/components/Table";
export default {
  components: { Table },
  data() {
    return {
      data: [
        {
          id: 1,
          title:
            "小杰小杰小杰小杰小杰小杰小杰小杰小杰小杰小杰小杰小杰小杰小杰小杰小杰小杰小杰小杰小杰小杰小杰小杰小杰小杰小杰",
          age: 12,
          sex: "男",
          address: "重庆",
          job: "教师",
        },
        {
          id: 2,
          title: "小明",
          age: 12,
          sex: "男",
          address: "四川",
          job: "教师",
        },
        {
          id: 3,
          title: "小雅",
          age: 12,
          sex: "女",
          address: "南宁",
          job: "程序员",
        },
        {
          id: 4,
          title: "小丽",
          age: 12,
          sex: "女",
          address: "北京",
          job: "学生",
        },
      ],
      column: [
        {
          title: "姓名",
          key: "title",
          width: "200",
          fixed: "left",
          resizable: true,
        },
        {
          title: "年龄",
          key: "age",
          resizable: true,
          width: "300",
        },
        {
          title: "性别",
          key: "sex",
          resizable: true,
          width: "200",
        },
        {
          title: "id",
          key: "id",

          resizable: true,
        },
        {
          title: "地址",
          key: "address",
          resizable: true,
        },
        {
          title: "职业",
          key: "job",
          resizable: true,
        },
        {
          title: "操作",
          slot: "operate",
          resizable: true,
        },
      ],
    };
  },
};
</script>

<style lang="less">
* {
  margin: 0;
  padding: 0;
}
.app {
  padding: 20px;
  button {
    padding: 10px;
    background-color: #008c8c;
    color: #fff;
    margin: 20px 0;
  }
  .container {
    .operate {
      text-align: center;
    }
    .aline {
      width: 50%;
    }
    h2 {
      font-weight: bold;
      font-size: 20px;
    }
    .aline {
      &:nth-child(1) {
        margin-right: 20px;
      }
    }
    display: flex;
    justify-content: space-between;
  }
}
.table {
  width: 1000px;
  margin: 20px auto;
}
</style>