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

底层框架/原理

sortablejs

核心的拖拽原理,我们通过使用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);

});

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