如何实现表格的列拖拽的

# 如何实现表格的列拖拽的

因为使用el-table实现,所以一些抓取dom的class类通过el-table内置的类实现,实际如果是简单表格的话,可以自行增加class来实现

# 底层框架/原理

sortablejs (opens new window)

核心的拖拽原理,我们通过使用sortablejs提供的dom拖拽方案,实现

我们通过让sortablejsel参数指定到el-tableheader

const query = ".el-table__header-wrapper thead tr"
const el docuemnt.querySelector(query) // this.$el.querySelector(query)

那么表头的那一行的所有th就变为拖拽目标了,之后根据index的顺序变化,可以反推到列的切换上

核心代码

const sortable = new Sortable(el, {
	onEnd(evt) {
		let { newIndex, oldIndex, item } = evt;
		// 通知上级交换column位置
	}
})

# 其他一些实现

# 跨表格实现

跨表格实现思路在于,通过在window上建立一个桥接用的map 缓存table的dom => vue实例对应关系

const sortable = new Sortable(el, {
	onEnd(evt) {
	      const { to, from, pullMode } = evt;
	      const toContext = window.bridge.get(to)
	      const fromContext = window.bridge.get(from)
		let { newIndex, oldIndex, item } = evt;
		// 通知from和to对应的数据进行切换即可
	}
})

# 拖拽优化

虽然核心代码很简单,但是不够完美,拖拽的时候只有表头可以进行拖动,实际上整列是没有跟着一起拖动的

所以我们需要进行样式上的优化,主要有两点

  1. 拖拽时候的影子
  2. 该列所有td跟随表头拖动

# 拖拽影子优化

影子实际上是可以通过dataTransfersetDragImage来修改的,参数支持传入一个dom

不过,因为列是好多dom拼起来的,实际上要还原的话,难度很高,于是换了一个思路,通过dom.cloneNode复制了一个table之后,只露出拖拽那一列即可,当然,需要插入到当前页面

所以我们新建一个三层的dom节点, 分别是

  • 容器,用来在页面上放置,同时控制显示区域,通过fixed和z-index 让其不可见
  • table容器,table的容器,用来还原表格宽度,以及日后样式上的还原
  • cloneNode

所以我们通过修改setData事件来控制,具体代码如下

        setData(dataTransfer, dragEl) {
            /**
             * 在页面上创建一个当前table的wrapper,然后隐藏它,只显示那一列的部分作为拖拽对象
             * 在下一个事件循环删除dom即可
             */
            const { offsetLeft, offsetWidth, offsetHeight } = dragEl;
            const tableEl = elTableContext.$el;

            const wrapper = document.createElement("div"); // 可视区域
            wrapper.style = `position: fixed; z-index: -1;overflow: hidden; width: ${offsetWidth}px`;
            const tableCloneWrapper = document.createElement("div"); // table容器,宽度和位移
            tableCloneWrapper.style = `position: relative; left: -${offsetLeft}px; width: ${tableEl.offsetWidth}px`;
            wrapper.appendChild(tableCloneWrapper);
            tableCloneWrapper.appendChild(tableEl.cloneNode(true));

            // 推进dom,让dataTransfer可以获取
            document.body.appendChild(wrapper);
            // 拖拽位置需要偏移到对应的列上
            dataTransfer.setDragImage(
              wrapper,
              offsetLeft + offsetWidth / 2,
              offsetHeight / 2
            );
            setTimeout(() => {
              document.body.removeChild(wrapper);
            });
        },

之后拖拽的影子就完全正常了,是当前拖拽的这一列

# 拖拽跟随移动

发现的问题点

  • 拖拽位置需要修正
  • 动画带来的问题

因为实际上dom位置不需要动,这样可以在切换column变换之后让el-table自动重新渲染,所以需要

  • onMove, 自动对齐位置
  • onEnd,移除位置切换

实际操作中,最开始是希望将整个操作简化为,onMove的时候,交换th下对应的td

最开始想的匹配规则是根据th当前的index去推测对应的td列表,但是发现,因为onMove的时候会导致index变动,所以查询的列表不对,最后发现每一个td会被el-table加上当前列的列名类似table0-column2,所以只要根据拖拽的th,通过document.querySelectAll查询就可以拿到对应的td

其次,是 交换需要推导对应位置 ,最开始我尝试使用对应的需要交换的td的当前位置,作为新位置,但是发现,如果增加了动画效果,其实通过getBoundingClientRect拿到的位置是动画中的,如果进行连续拖拽的话,半路中交换的列停留的位置就会出现问题,所以还是得进行修正

以及,计算移动位置需要考虑当前已经移动的位置,才能计算真实的transition

所以我最终选择的解决方案是

  • onMove的时候触发一个 节流 的位置匹配函数让th对应的tdth在x轴上对齐
  • onEnd的时候自动清空位移
  • 拆出计算位置的函数
  • 移动过的td增加class,onEnd的时候根据class自动清空

具体代码-工具函数们

/* eslint-disable no-unused-vars */
import throttle from 'lodash/throttle'
import Sortable from "sortablejs";
const { utils } = Sortable;
const { css } = utils;

/** @type {Set<Element>} */
const animatedSet = new Set();

export const ANIMATED_CSS = "el-table-draggable-animated";
const translateRegexp = /translate\((?<x>.*)px,\s?(?<y>.*)px\)/;
const elTableColumnRegexp = /el-table_\d*_column_\d*/

/**
 * 重设transform
 * @param {Element} el 
 */
function resetTransform(el) {
  css(el, "transform", "");
  css(el, "transitionProperty", "");
  css(el, "transitionDuration", "");
}

/**
 * 获取原始的boundge位置
 * @param {Element} el
 * @param {boolean} ignoreTranslate
 * @returns {{x: number, y: number}}
 */
export function getDomPosition(el, ignoreTranslate = true) {
  const position = el.getBoundingClientRect().toJSON();
  const transform = el.style.transform;
  if (transform && ignoreTranslate) {
    const { groups = { x: 0, y: 0 } } = translateRegexp.exec(transform) || {};
    position.x = position.x - +groups.x;
    position.y = position.y - +groups.y;
  }
  return position;
}

/**
 * 添加动画
 * @param {Element} el
 * @param {string} transform
 * @param {number} animate
 */
export function addAnimate(el, transform, animate = 0) {
  el.classList.add(ANIMATED_CSS);
  css(el, "transitionProperty", `transform`);
  css(el, "transitionDuration", animate + "ms");
  css(el, "transform", transform);
  animatedSet.add(el);
}

/**
 * 清除除了可忽略选项内的动画
 * @param {Element[]|Element} targetList
 */
export function clearAnimate(targetList = []) {
  const list = Array.isArray(targetList) ? targetList : [targetList]
  const removedIteratory = list.length ? list : animatedSet.values()
  for (const el of removedIteratory) {
    el.classList.remove(ANIMATED_CSS);
    resetTransform(el)
    if (animatedSet.has(el)) {
      animatedSet.delete(el);
    }
  }
}

/**
 * 获取移动的animate
 * @param {Element} el
 * @param {{x?: number, y?:number}} target
 * @returns {string}
 */
 export function getTransform(el, target) {
  const currentPostion = getDomPosition(el)
  const originPosition = getDomPosition(el, true)
  const { x, y } = target
  const toPosition = {
    x: x!==undefined ? x : currentPostion.x,
    y: y!==undefined ? y : currentPostion.y
  }
  const transform = `translate(${toPosition.x -
    originPosition.x}px, ${toPosition.y - originPosition.y}px)`
  return transform
}

/**
 * 移动到具体位置
 * @param {Element} el
 * @param {{x?: number, y?:number}} target
 * @returns {string}
 */
export function translateTo(el, target) {
  resetTransform(el)
  const transform = getTransform(el, target)
  el.style.transform = transform
}

/**
 * 交换
 * @param {Element} newNode
 * @param {Element} referenceNode
 * @param {number} animate
 */
export function insertBefore(newNode, referenceNode, animate = 0) {
  /**
   * 动画效果
   * @todo 如果是不同列表,动画方案更新
   */
  if (animate) {
    // 同一列表处理
    if (newNode.parentNode === referenceNode.parentNode) {
      // source
      const offset = newNode.offsetTop - referenceNode.offsetTop;
      if (offset !== 0) {
        const subNodes = Array.from(newNode.parentNode.children);
        const indexOfNewNode = subNodes.indexOf(newNode);
        const indexOfReferenceNode = subNodes.indexOf(referenceNode);
        const nodes = subNodes
          .slice(
            Math.min(indexOfNewNode, indexOfReferenceNode),
            Math.max(indexOfNewNode, indexOfReferenceNode)
          )
          .filter((item) => item !== newNode);
        const newNodeHeight =
          offset > 0 ? -1 * newNode.offsetHeight : newNode.offsetHeight;
        nodes.forEach((node) =>
          addAnimate(node, `translateY(${newNodeHeight}px)`, animate)
        );
        addAnimate(newNode, `translateY(${offset}px)`, animate);
      }
    } else {
      console.log("非同一列表");
    }

    // 清除
    setTimeout(() => {
      clearAnimate();
    }, animate);
  }
  referenceNode.parentNode.insertBefore(newNode, referenceNode);
}

/**
 * 交换
 * @param {Element} newNode
 * @param {Element} referenceNode
 * @param {number} animate
 */
export function insertAfter(newNode, referenceNode, animate = 0) {
  const targetReferenceNode = referenceNode.nextSibling;
  insertBefore(newNode, targetReferenceNode, animate);
}

/**
 * 交换元素位置
 * @todo 优化定时器
 * @param {Element} prevNode
 * @param {Element} nextNode
 * @param {number} animate
 */
export function exchange(prevNode, nextNode, animate = 0) {
  const exchangeList = [
    {
      from: prevNode,
      to: nextNode,
    },
    {
      from: nextNode,
      to: prevNode,
    },
  ];
  exchangeList.forEach(({ from, to }) => {
    const targetPosition = getDomPosition(to, false)
    const transform = getTransform(from, targetPosition);
    addAnimate(from, transform, animate);
  });
}

/**
 * 从th获取对应的td
 * @todo 支持跨表格获取tds
 * @param {Element} th
 * @returns {NodeListOf<Element>}
 */
export function getTdListByTh(th) {
  const className = Array.from(th.classList).find(className => elTableColumnRegexp.test(className))
  return document.querySelectorAll(`.${className}`)
}

/**
 * 自动对齐列
 * @param {Element[]|Element} thList 
 */
export const alignmentTableByThList = throttle(
  function alignmentTableByThList(thList) {
    const list = Array.isArray(thList) ? thList : [thList]
    list.forEach(th => {
      const tdList = getTdListByTh(th)
      tdList.forEach(td => {
        const { x } = getDomPosition(th)
        translateTo(td, { x })
      })
    })
  },
  1000 / 60
)

export default {
  alignmentTableByThList,
  getTransform,
  clearAnimate,
  addAnimate,
  ANIMATED_CSS,
  getTdListByTh,
  translateTo,
  getDomPosition,
  insertAfter,
  insertBefore,
  exchange,
};

拖拽移动自动排序

onMove(evt, originalEvent) {
		const { related, willInsertAfter, dragged } = evt;
		
		// 工具函数,自动对齐之前的列
 		dom.alignmentTableByThList(Array.from(dragged.parentNode.childNodes))
 		
 		// 交换dom位置,动画
            const { animation } = vm._sortable.options;
            // 需要交换两列所有的td
            const thList = [dragged, related];
            const [fromTdList, toTdList] = (willInsertAfter
              ? thList
              : thList.reverse()
            ).map((th) => dom.getTdListByTh(th));

            fromTdList.forEach((fromTd, index) => {
              const toTd = toTdList[index];
              // 交换td位置
              dom.exchange(fromTd, toTd, animation);
            });

当然还有一些可以优化的点,但是目前就是一个比较完善的列拖拽了