# 如何实现表格的列拖拽的
因为使用el-table实现,所以一些抓取dom的class类通过el-table内置的类实现,实际如果是简单表格的话,可以自行增加
class来实现
# 底层框架/原理
核心的拖拽原理,我们通过使用sortablejs
提供的dom
拖拽方案,实现
我们通过让sortablejs
的el
参数指定到el-table
的header
上
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对应的数据进行切换即可
}
})
# 拖拽优化
虽然核心代码很简单,但是不够完美,拖拽的时候只有表头可以进行拖动,实际上整列是没有跟着一起拖动的
所以我们需要进行样式上的优化,主要有两点
- 拖拽时候的影子
- 该列所有td跟随表头拖动
# 拖拽影子优化
影子实际上是可以通过dataTransfer
的setDragImage
来修改的,参数支持传入一个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
对应的td
和th
在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);
});
当然还有一些可以优化的点,但是目前就是一个比较完善的列拖拽了