表格的行与列的拖拽操作

Huy大约 8 分钟javascriptjavascript

本文将介绍如何使用 js 实现表格的行与列的拖拽操作。效果如下:

行与列的拖拽互换
行与列的拖拽互换

按照惯例,我们先来实现一个简单的表格,这里为了书写方便就用 Vue 的 CDN 方式进行创建,HTML 代码如下。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>表格的拖拽</title>
    <style>
      table {
        border-collapse: collapse;
        text-align: center;
      }
    </style>
  </head>
  <body>
    <script type="importmap">
      {
        "imports": {
          "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
        }
      }
    </script>

    <div id="app">
      <table width="800" border="1">
        <thead>
          <tr>
            <th v-for="(item, index) in titleData" :key="index">{{item}}</th>
          </tr>
          <tbody>
            <tr v-for="item of tableData" :key="item[0][0]">
              <td v-for="value of item" :key="value[0]">{{ value[1] }}</td>
            </tr>
          </tbody>
        </thead>
      </table>
    </div>

    <script type="module">
      import { createApp, ref } from "vue";
      const data = [
        { id: 1, name: "张三", age: 18, score: 90 },
        { id: 2, name: "李四", age: 20, score: 85 },
        { id: 3, name: "王五", age: 19, score: 88 },
        { id: 4, name: "赵六", age: 21, score: 92 },
      ];

      /** 数据转换 */
      function formateData(data) {
        return data.map((item) => {
          const arrItem = [];
          for (let key in item) {
            arrItem.push([key, item[key]]);
          }
          return arrItem;
        });
      }

      createApp({
        setup() {
          const titleData = ref(["ID", "Name", "Age", "Score"]);
          const tableData = ref(formateData(data));

          return {
            titleData,
            tableData,
          };
        },
      }).mount("#app");
    </script>
  </body>
</html>
表格
表格

实现行互换

为了表格的行能够拖动起来,我们需要给表格的行添加 draggable 属性,并设置 dragstartdragend 事件。若是对 HTML 中的 drag 等事件不清楚的可以再来回顾一下 MDN 的 drag_eventopen in new window

先来梳理一下拖拽的流程:

  1. 拖拽开始时,记录拖拽行的 index
  2. 拖拽结束时,记录拖拽结束时的 index
  3. 拖拽结束时,互换拖拽行和结束行的位置

然后是 Vue 中的自定义指令,我们通过自定义指令来绑定拖拽事件,并获取拖拽行的 index。

这里我们自定义 v-raw-drag 指令来绑定拖拽事件。

<table width="800" border="1" v-row-drag="tableData"></table>

其中 tableDate 是整个表格的数据。

在自定义指令中,可以获取到被绑定的 DOM 元素和绑定的传参值。但是为了避免每行都独自传参,我们将所需要的数据都挂载到自定义指令上。

/** 拖拽行指令 */
const vRowDrag = {
  mounted(el, bindings) {
    // 获取所有行
    const trs = el.getElementsByTagName('tbody')[0].getElementsByTagName('tr')

    // 为避免每行都独自传参, 因此将行挂载到自定义指令上
    vRowDrag.trs = trs // 所有行元素
    vRowDrag.el = el // 整个表格元素
    vRowDrag.data = bindings.value // 传参 tableData
    initDirective() // 初始化各行
  },
}

将所有值都绑定到自定义指令上后,我们就可以在 initDirective 函数中为每行绑定拖拽事件了。

/** 自定义指令初始化 */
function initDirective() {
  // 依次给每一行添加拖拽事件
  ;[...vRowDrag.trs].forEach((tr) => createDraggableElement(tr))
  bindEvent()
}

/** 创建拖拽事件 */
function createDraggableElement(tr) {
  tr.draggable = true
  // 设置拖拽事件
  tr.addEventListener('dragstart', handleDragStart, false)
  tr.addEventListener('dragend', handleDragEnd, false)
}

/** 处理绑定事件 */
function bindEvent() {
  vRowDrag.el.addEventListener('dragover', handleDragOver, false)
  vRowDrag.el.addEventListener('dragenter', (e) => e.preventDefault(), false)

  // 去除默认行为
  window.addEventListener('dragover', (e) => e.preventDefault(), false)
  window.addEventListener('dragenter', (e) => e.preventDefault(), false)
}

在处理绑定事件的末尾,还添加了两个全局事件监听器:window.addEventListener。这两个事件监听器的作用是防止在拖拽过程中触发浏览器默认的拖拽行为,例如在拖拽元素到浏览器窗口边缘时自动滚动等行为。

然后开始编辑上述绑定事件中的 handleDragStarthandleDragEndhandleDragOver 三个事件。这里的思路就很简单了,获取开始拖拽行,获取结束拖拽行,然后交换它们的位置。

function handleDragStart(e) {
  // 获取拖拽行
  const target = e.target
  draggingIndex = [...vRowDrag.trs].findIndex((item) => item === target)
}

function handleDragOver(e) {
  // 注意 这里的 e.target:表示触发事件的元素,即鼠标指针当前所在的元素 td,所以为了获取行要取它的父元素 parentNode。
  const target = e.target.parentNode
  overIndex = [...vRowDrag.trs].findIndex((item) => item === target)
}

function handleDragEnd(e) {
  if (overIndex !== -1) {
    // 互换行
    const draggingData = vRowDrag.data[draggingIndex]
    vRowDrag.data[draggingIndex] = vRowDrag.data[overIndex]
    vRowDrag.data[overIndex] = draggingData
  }
}

下面是完整的代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>表格的拖拽</title>
    <style>
      table {
        border-collapse: collapse;
        text-align: center;
      }
    </style>
  </head>
  <body>
    <script type="importmap">
      {
        "imports": {
          "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
        }
      }
    </script>

    <div id="app">
      <!-- 加入自定义指令 -->
      <table width="800" border="1" v-row-drag="tableData">
        <thead>
          <tr>
            <th v-for="(item, index) in titleData" :key="index">{{item}}</th>
          </tr>
          <tbody>
            <tr v-for="item of tableData" :key="item[0][0]">
              <td v-for="value of item" :key="value[0]">{{ value[1] }}</td>
            </tr>
          </tbody>
        </thead>
      </table>
    </div>

    <script type="module">
      import { createApp, ref } from "vue";

      const data = [
        { id: 1, name: "张三", age: 18, score: 90 },
        { id: 2, name: "李四", age: 20, score: 85 },
        { id: 3, name: "王五", age: 19, score: 88 },
        { id: 4, name: "赵六", age: 21, score: 92 },
      ];

      /** 数据转换 */
      function formateData(data) {
        return data.map((item) => {
          const arrItem = [];

          for (let key in item) {
            arrItem.push([key, item[key]]);
          }

          return arrItem;
        });
      }

      const app = createApp({
        setup() {
          const titleData = ref(["ID", "Name", "Age", "Score"]);
          const tableData = ref(formateData(data));

          return {
            titleData,
            tableData,
          };
        },
      })

      // 记录拖动行的 index
      let draggingIndex = -1;
      let overIndex = -1

      /** 拖拽行指令 */
      const vRowDrag = {
        mounted(el, bindings) {
          // 获取所有行
          const trs = el.getElementsByTagName('tbody')[0].getElementsByTagName('tr');

          // 为避免每行都独自传参, 因此将行挂载到自定义指令上
          vRowDrag.trs = trs;
          vRowDrag.el = el;
          vRowDrag.data = bindings.value;
          initDirective() // 初始化各行
        }
      }

      /** 自定义指令初始化 */
      function initDirective() {
        [...vRowDrag.trs].forEach(tr => createDraggableElement(tr))
        bindEvent()
      }

      /** 处理绑定事件 */
      function bindEvent () {
        vRowDrag.el.addEventListener('dragover', handleDragOver, false)
        vRowDrag.el.addEventListener('dragenter', (e) => e.preventDefault(), false)
        // 去除默认行为
        window.addEventListener('dragover', e => e.preventDefault(), false)
        window.addEventListener('dragenter', e => e.preventDefault(), false)
      }

      function createDraggableElement(tr) {
        tr.draggable = true
        // 设置拖拽事件
        tr.addEventListener('dragstart', handleDragStart, false)
        tr.addEventListener('dragend', handleDragEnd, false)
      }

      function handleDragStart(e) {
        // 获取拖拽行
        const target = e.target
        draggingIndex = [...vRowDrag.trs].findIndex(item => item === target)
        console.log('draggingIndex', draggingIndex);
      }

      function handleDragEnd(e) {
        if (overIndex !== -1 ) {
          // 互换行
          const draggingData = vRowDrag.data[draggingIndex];
          vRowDrag.data[draggingIndex] = vRowDrag.data[overIndex];
          vRowDrag.data[overIndex] = draggingData;
        }
      }

      function handleDragOver(e) {
        const target = e.target.parentNode
        overIndex = [...vRowDrag.trs].findIndex(item => item === target)
      }

      app.directive('row-drag', vRowDrag)
      app.mount("#app");
    </script>
  </body>
</html>

列拖拽

有了行拖拽的基础,列拖拽就很简单了。

首先,我们还是需要一个自定义指令来绑定拖拽事件,当然也可以写在一起,我这里为了让代码更加清晰,就写了两份。

这里自定义指令为 v-col-drag,但是传参同行略有差异,因为还有一个表格头,所以参数有俩个:titleDatatableData

<table width="800" border="1" v-col-drag="{titleData, tableData}"></table>

基本的思路一样,区别在于,在表格头互换后,要维护 tbody 中的数据顺序也要跟随变化。以及在 dragover 事件中获取的就是目标 td 元素,无需取父元素 tr 的值。

function handleColDragEnd(e) {
  if (overColIndex !== -1) {
    // 互换列
    const draggingTitle = vColDrag.titleData[draggingColIndex]
    vColDrag.titleData[draggingColIndex] = vColDrag.titleData[overColIndex]
    vColDrag.titleData[overColIndex] = draggingTitle

    // 列数据互换
    vColDrag.data.forEach((item) => {
      const draggingData = item[draggingColIndex]
      item[draggingColIndex] = item[overColIndex]
      item[overColIndex] = draggingData
    })
  }
  overColIndex = -1
  draggingColIndex = -1
}

function handleColOver(e) {
  // 获取的就是 目标 td 头元素
  const target = e.target
  overColIndex = [...vColDrag.ths].findIndex((item) => item === target)
}

以下是完整代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>表格的拖拽</title>
    <style>
      table {
        border-collapse: collapse;
        text-align: center;
      }
    </style>
  </head>
  <body>
    <script type="importmap">
      {
        "imports": {
          "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
        }
      }
    </script>

    <div id="app">
      <!-- 加入自定义指令 -->
      <table width="800" border="1" v-row-drag="tableData" v-col-drag="{tableData, titleData}">
        <thead>
          <tr>
            <th v-for="(item, index) in titleData" :key="index">{{item}}</th>
          </tr>
          <tbody>
            <tr v-for="item of tableData" :key="item[0][1]">
              <td v-for="value of item" :key="value[0]">{{ value[1] }}</td>
            </tr>
          </tbody>
        </thead>
      </table>
    </div>

    <script type="module">
      import { createApp, ref } from "vue";

      const data = [
        { id: 1, name: "张三", age: 18, score: 90 },
        { id: 2, name: "李四", age: 20, score: 85 },
        { id: 3, name: "王五", age: 19, score: 88 },
        { id: 4, name: "赵六", age: 21, score: 92 },
      ];

      /** 数据转换 */
      function formateData(data) {
        return data.map((item) => {
          const arrItem = [];

          for (let key in item) {
            arrItem.push([key, item[key]]);
          }

          return arrItem;
        });
      }

      const app = createApp({
        setup() {
          const titleData = ref(["ID", "Name", "Age", "Score"]);
          const tableData = ref(formateData(data));

          return {
            titleData,
            tableData,
          };
        },
      })

      // 记录拖动行的 index
      let draggingIndex = -1;
      let overIndex = -1

      // 记录拖动列
      let draggingColIndex = -1;
      let overColIndex = -1

      /** 拖拽行指令 */
      const vRowDrag = {
        mounted(el, bindings) {
          // 获取所有行
          const trs = el.getElementsByTagName('tbody')[0].getElementsByTagName('tr');

          // 为避免每行都独自传参, 因此将行挂载到自定义指令上
          vRowDrag.trs = trs;
          vRowDrag.el = el;
          vRowDrag.data = bindings.value;
          initDirective() // 初始化各行
        }
      }

      /** 拖动列指令 */
      const vColDrag = {
        mounted(el, bindings) {
          const {tableData, titleData} = bindings.value;
          vColDrag.el = el;
          vColDrag.data = tableData;
          vColDrag.titleData = titleData;

          vColDrag.ths = el.getElementsByTagName('thead')[0].getElementsByTagName('th');
          initColDirective()
        }
      }

      /** 自定义指令初始化 */
      function initDirective() {
        // 拖动行操作
        [...vRowDrag.trs].forEach(tr => createDraggableElement(tr))

        // 事件绑定
        bindEvent()
      }

      function initColDirective() {
        // 拖动列操作
        [...vColDrag.ths].forEach(th => createColDraggableElement(th))

        // 事件绑定
        // 拖动列操作
        vColDrag.el.addEventListener('dragover', handleColOver, false)
        vColDrag.el.addEventListener('dragenter', (e) => e.preventDefault(), false)

        // 去除默认行为
        window.addEventListener('dragover', e => e.preventDefault(), false)
        window.addEventListener('dragenter', e => e.preventDefault(), false)
      }

      /** 处理绑定事件 */
      function bindEvent () {
        // 拖动行操作
        vRowDrag.el.addEventListener('dragover', handleDragOver, false)
        vRowDrag.el.addEventListener('dragenter', (e) => e.preventDefault(), false)

        // 去除默认行为
        window.addEventListener('dragover', e => e.preventDefault(), false)
        window.addEventListener('dragenter', e => e.preventDefault(), false)
      }

      function createDraggableElement(tr) {
        tr.draggable = true
        // 设置拖拽事件
        tr.addEventListener('dragstart', handleDragStart, false)
        tr.addEventListener('dragend', handleDragEnd, false)
      }

      function handleDragStart(e) {
        // 获取拖拽行
        const target = e.target
        draggingIndex = [...vRowDrag.trs].findIndex(item => item === target)
      }

      function handleDragEnd(e) {
        if (overIndex !== -1 ) {
          // 互换行
          const draggingData = vRowDrag.data[draggingIndex];
          vRowDrag.data[draggingIndex] = vRowDrag.data[overIndex];
          vRowDrag.data[overIndex] = draggingData;
        }
        overIndex = -1
        draggingIndex = -1
      }

      function handleDragOver(e) {
        const target = e.target.parentNode
        overIndex = [...vRowDrag.trs].findIndex(item => item === target)
      }

      // 拖动列
      function createColDraggableElement(th) {
        th.draggable = true
        th.addEventListener('dragstart', handleColDragStart, false)
        th.addEventListener('dragend', handleColDragEnd, false)
      }

      function handleColDragStart(e) {
        draggingColIndex = [...vColDrag.ths].findIndex(item => item === e.target)
      }

      function handleColDragEnd(e) {
        if (overColIndex !== -1 ) {
          // 互换列
          const draggingTitle = vColDrag.titleData[draggingColIndex];
          vColDrag.titleData[draggingColIndex] = vColDrag.titleData[overColIndex];
          vColDrag.titleData[overColIndex] = draggingTitle;

          // 列数据互换
          vColDrag.data.forEach(item => {
            const draggingData = item[draggingColIndex];
            item[draggingColIndex] = item[overColIndex];
            item[overColIndex] = draggingData;
          })
        }
        overColIndex = -1
        draggingColIndex = -1
      }

      function handleColOver (e) {
        const target = e.target
        overColIndex = [...vColDrag.ths].findIndex(item => item === target)
      }

      app.directive('row-drag', vRowDrag)
      app.directive('col-drag', vColDrag)
      app.mount("#app");
    </script>
  </body>
</html>

整个代码,实际上并不复杂,但是实现起来还是需要一些思考的。

Loading...