近期接到一个需求,需要实现一个动态表格,并使表格的列支持拖拽伸缩。在初步调研时,发现官方文档中已有相关案例,但一上手实现后却遇到了许多 bug,导致功能无法正常使用。查阅相关博客,也是找到了对应的解决方案。谨以此文记录下本次的踩坑过程,分享解决方案与优化思路。
目录
一、效果图
二、遇到的相关问题解决
1 拖拽不生效问题
解决方案:变更 vue-draggable-resizable 的版本号为:V2.1.0
npm install --save vue-draggable-resizable@2.1.0
2 限制表格宽度
解决方案:添加滚动属性:
<a-table
...
:scroll="{ x: scrollX }"
...
>
...
</a-table>
还需要动态计算该字段,避免列宽拖动,而滚动条实际长度未产生变化的问题。
computed: {
// 动态获取scrollX,防止数组固定列的大小变化
scrollX () {
return this.columns.reduce((preVal, curVal) => {
return preVal + curVal.width
}, 0)
}
},
3 限制表格最小及最大宽度。
解决方案:于初始化拖拽事件中限制,主体逻辑如下:
initDrag (columns) {
const historyMap = new Map()
...
const resizeableTitle = (h, props, children) => {
let deltaX = 0
...
const { key, ...restProps } = props
const col = columns.find((col) => {
const k = col.dataIndex || col.key
return k === key
})
...
const onDrag = (x) => {
// 在拖拽过程中计算基于初始位置的偏移量
let calculateX = 0
const historyX = historyMap.get(key) || 0
deltaX = historyX && historyX - x
if (historyX && col.width === this.threshold.max) {
// 场景一:拖拽超出最大值
if (historyX > x) calculateX = col.width - deltaX
else calculateX = col.width
} else if (historyX && col.width === this.threshold.min) {
// 场景二:拖拽超出最小值
if (historyX < x) calculateX = col.width - deltaX
else calculateX = col.width
} else {
// 场景三:区间范围内
calculateX = col.width - deltaX
}
...
let width = Math.max(calculateX, this.threshold.min)
width = Math.min(width, this.threshold.max)
col.width = width
historyMap.set(key, x)
}
const onDragstop = (x) => {
historyMap.set(key, x)
...
}
return (
<th
{...restProps}
v-ant-ref={(r) => (thDom = r)}
width={draggingState[key]}
class="resize-table-th"
>
{children}
<vue-draggable-resizable
...
x={col.width || draggingState[key]}
...
draggable={true}
...
onDragging={onDrag}
onDragstop={onDragstop}
></vue-draggable-resizable>
</th>
)
}
this.components = {
header: {
cell: resizeableTitle
}
}
},
4 限制某些列不可伸缩。
解决方案:判定绑定dataIndex字段,返回原dom
initDrag (columns) {
...
const resizeableTitle = (h, props, children) => {
...
const { key, ...restProps } = props
const col = columns.find((col) => {
const k = col.dataIndex || col.key
return k === key
})
// action || serial 限制操作列不可伸缩
if (!col.width || col.dataIndex === 'action' || col.dataIndex === 'serial') {
return <th {...restProps}>{children}</th>
}
...
return (
...
)
}
this.components = {
header: {
cell: resizeableTitle
}
}
},
上述问题所参考的博客:
三、组件封装
解决上述问题功能已经处于可用状态,但需要用于多处表格页面,故而进行了封装。
主要考虑了以下几点:
1 表格字段存在格式化、枚举值映射及其余特殊操作。
解决方案:使用插槽的动态传入组件:
父组件:
<template>
<div>
...
<scalable-column ... :slotList="slotList">
<template v-slot:serial="{ text, record, index }">
{{ query.pageSize * (query.pageNum - 1) + index + 1 }}
</template>
<template v-slot:action="{ record }">
<a @click="handleDetail(record)">详情</a>
</template>
</scalable-column>
...
</div>
</template>
<script>
import ScalableColumn from '@/components/Table/ScalableColumn.vue'
export default {
components: {
ScalableColumn
},
data () {
return {
...
query: {
pageSize: 10,
pageNum: 1
},
slotList: [
{ name: 'action' },
{ name: 'serial' }
],
...
}
},
methods: {
handleDetail (record) {
...
}
}
}
</script>
子组件:
<template>
<div class="table-box">
<a-table
...
>
<!-- 传递给table的slot -->
<template v-for="slot in slotList" v-slot:[slot.name]="text, record, index">
<!-- 父组件中传递进的slot -->
<slot :name="slot.name" :text="text" :record="record" :index="index"></slot>
</template>
</a-table>
</div>
</template>
<script>
...
export default {
name: 'App',
data () {
return {
...
}
},
props: {
...
slotList: {
type: Array,
default: () => []
},
...
},
...
}
</script>
2 动态传参
表头数据等都是动态的,还有表格相关配置等,均通过参数形式传入表格组件;由于此为一类表格,则大部分的参数可于表格组件内设定,也暴露出相关参数用于接收父组件传参。
父组件:
因表头需要有相关的格式限制,故于此处强调:
1 需设定宽度。若不设定,会出现占满剩余宽度或被挤成一个字符的情况;
2 设置插槽的需添加 sccopedSlots 属性,否则无法插入渲染;
3 超长省略(可选):ellipsis属性;
4 其余属性本身就是使用的 a-table,可于官网查看。
<template>
<div>
...
<scalable-column :tableData="loadData" :columns="columns" :loading="spinning" ...>
...
</scalable-column>
...
</div>
</template>
<script>
import ScalableColumn from '@/components/Table/ScalableColumn.vue'
export default {
components: {
ScalableColumn
},
data () {
return {
...
query: {
pageSize: 10,
pageNum: 1
},
slotList: [
{ name: 'action' },
{ name: 'serial' }
],
columns: [],
...
}
},
created () {
this.initPage().then(() => {
...
})
},
methods: {
initPage () {
return new Promise((resolve, reject) => {
this.columns = this.creatTableHeader(tableData.data.columns)
resolve()
})
},
handleDetail (record) {
...
},
/**
* description:
* @param {Array} columns
* 格式:
* [
* {
* name: 'prop1',
* label: '字段中文名1',
* ...
* },
* {
* name: 'prop2',
* label: '字段中文名2',
* ...
* },
* {
* name: 'prop3',
* label: '字段中文名3',
* ...
* },
* ...
* ]
* @returns {Array}
*/
creatTableHeader (columns) {
const baseColumns = [
{
title: '序号',
dataIndex: 'serial',
scopedSlots: { customRender: 'serial' },
width: 100,
ellipsis: true,
align: 'center',
fixed: 'left'
},
...columns.filter(col => col.name && col.label).map(col => ({
title: col.label,
dataIndex: col.name,
width: 150,
ellipsis: true
})),
{
title: '操作',
dataIndex: 'action',
align: 'center',
width: 100,
ellipsis: true,
scopedSlots: { customRender: 'action' },
fixed: 'right'
}
]
return baseColumns
},
}
}
</script>
子组件则正常接收使用即可。
3 功能:拖拽列头(表头),变更表头顺序
引入依赖:
npm install --save sortablejs
子组件:
// 列拖拽
columnDrop () {
var _this = this
const wrapperTr = document.querySelector('.ant-table-wrapper tr')
this.sortable = Sortable.create(wrapperTr, {
handle: '.ant-table-header-column', // 防止拉伸时触发拖拽
animation: 180,
delay: 0,
// 之前调用onEnd方法会出现表格DOM不更新以及表头对不上的情况所以更换为onUpdate方法
onUpdate: function (evt) {
// 修改items数据顺序
var newIndex = evt.newIndex
var oldIndex = evt.oldIndex
const newItem = wrapperTr.children[newIndex]
const oldItem = wrapperTr.children[oldIndex]
// 先删除移动的节点
wrapperTr.removeChild(newItem)
// 再插入移动的节点到原有节点,还原了移动的操作
if (newIndex > oldIndex) {
wrapperTr.insertBefore(newItem, oldItem)
} else {
wrapperTr.insertBefore(newItem, oldItem.nextSibling)
}
// 更新items数组
var item = _this.columns.splice(oldIndex, 1)
_this.columns.splice(newIndex, 0, item[0])
// 下一个tick就会走patch更新
// 如果需要缓存表头,比如缓存到vuex中
// 每次更新调取保存用户接口
// _this.saveColumns("columnChange", _this.columns);
}
})
}
四、完整代码
1 未封装版
<template>
<div class="table-box">
<a-table
:rowKey="(record, index) => index"
:columns="columns"
:components="components"
:data-source="tableData"
:scroll="{ x: scrollX }"
>
<template v-slot:serial="text, record, index">
{{ query.pageSize * (query.pageNum - 1) + index + 1 }}
</template>
<template v-slot:action="text, record, index">
<a @click="handleDetail(record)">详情</a>
</template>
</a-table>
</div>
</template>
<script>
import Sortable from 'sortablejs'
import Vue from 'vue'
import VueDraggableResizable from 'vue-draggable-resizable'
Vue.component('vue-draggable-resizable', VueDraggableResizable)
export default {
name: 'App',
data () {
return {
query: {
pageSize: 10,
pageNum: 1
},
columns: [
{
title: '序号',
dataIndex: 'serial',
scopedSlots: { customRender: 'serial' },
width: 100,
ellipsis: true,
align: 'center',
fixed: 'left'
},
{
title: '字段中文名1',
dataIndex: 'prop1',
width: 150,
ellipsis: true
},
{
title: '字段中文名2',
dataIndex: 'prop2',
width: 150,
ellipsis: true
},
{
title: '字段中文名3',
dataIndex: 'prop3',
width: 150,
ellipsis: true
},
{
title: '字段中文名4',
dataIndex: 'prop-notfound',
width: 150,
ellipsis: true
},
{
title: '字段中文名5',
dataIndex: 'prop5',
width: 150,
ellipsis: true
},
{
title: '操作',
dataIndex: 'action',
align: 'center',
width: 100,
ellipsis: true,
scopedSlots: { customRender: 'action' },
fixed: 'right'
}
],
tableData: [
{
prop1: '值1-1',
prop2: '值2-1',
prop3: '值3-1'
},
{
prop1: '值1-2',
prop2: '值2-2',
prop3: '值3-2'
},
{
prop1: '值1-3',
prop2: '值2-3',
'prop-notfound': '值4-3',
prop4: '我没有在字段说明里出现, 因此我是多余的不用管',
prop5: '值5-3'
}
],
// 表格列拖拽阈值
threshold: {
min: 100,
max: 400
},
components: null
}
},
computed: {
// 动态获取scrollX,防止数组固定列的大小变化
scrollX () {
return this.columns.reduce((preVal, curVal) => {
return preVal + curVal.width
}, 0)
}
},
created () {
this.initDrag(this.columns)
},
mounted () {
this.columnDrop()
},
methods: {
initDrag (columns) {
const historyMap = new Map()
const draggingMap = {}
columns.forEach((col) => {
draggingMap[col.key] = col.width
})
const draggingState = Vue.observable(draggingMap)
const resizeableTitle = (h, props, children) => {
let deltaX = 0
let thDom = null
const { key, ...restProps } = props
const col = columns.find((col) => {
const k = col.dataIndex || col.key
return k === key
})
// action || serial 限制操作列不可伸缩
if (!col.width || col.dataIndex === 'action' || col.dataIndex === 'serial') {
return <th {...restProps}>{children}</th>
}
const onDrag = (x) => {
// 在拖拽过程中计算基于初始位置的偏移量
let calculateX = 0
const historyX = historyMap.get(key) || 0
deltaX = historyX && historyX - x
if (historyX && col.width === this.threshold.max) {
// 场景一:拖拽超出最大值
if (historyX > x) calculateX = col.width - deltaX
else calculateX = col.width
} else if (historyX && col.width === this.threshold.min) {
// 场景二:拖拽超出最小值
if (historyX < x) calculateX = col.width - deltaX
else calculateX = col.width
} else {
// 场景三:区间范围内
calculateX = col.width - deltaX
}
draggingState[key] = 0
let width = Math.max(calculateX, this.threshold.min)
width = Math.min(width, this.threshold.max)
col.width = width
historyMap.set(key, x)
}
const onDragstop = (x) => {
historyMap.set(key, x)
draggingState[key] = thDom.getBoundingClientRect().width
}
return (
<th
{...restProps}
v-ant-ref={(r) => (thDom = r)}
width={draggingState[key]}
class="resize-table-th"
>
{children}
<vue-draggable-resizable
key={col.key}
class="table-draggable-handle"
w={10}
x={col.width || draggingState[key]}
z={1}
axis="x"
draggable={true}
resizable={false}
onDragging={onDrag}
onDragstop={onDragstop}
></vue-draggable-resizable>
</th>
)
}
this.components = {
header: {
cell: resizeableTitle
}
}
},
// 列拖拽
columnDrop () {
var _this = this
const wrapperTr = document.querySelector('.ant-table-wrapper tr')
this.sortable = Sortable.create(wrapperTr, {
handle: '.ant-table-header-column', // 防止拉伸时触发拖拽
animation: 180,
delay: 0,
// 之前调用onEnd方法会出现表格DOM不更新以及表头对不上的情况所以更换为onUpdate方法
onUpdate: function (evt) {
// 修改items数据顺序
var newIndex = evt.newIndex
var oldIndex = evt.oldIndex
const newItem = wrapperTr.children[newIndex]
const oldItem = wrapperTr.children[oldIndex]
// 先删除移动的节点
wrapperTr.removeChild(newItem)
// 再插入移动的节点到原有节点,还原了移动的操作
if (newIndex > oldIndex) {
wrapperTr.insertBefore(newItem, oldItem)
} else {
wrapperTr.insertBefore(newItem, oldItem.nextSibling)
}
// 更新items数组
var item = _this.columns.splice(oldIndex, 1)
_this.columns.splice(newIndex, 0, item[0])
// 下一个tick就会走patch更新
// 如果需要缓存表头,比如缓存到vuex中
// 每次更新调取保存用户接口
// _this.saveColumns("columnChange", _this.columns);
}
})
},
handleDetail (record) {
console.log(record)
}
}
}
</script>
<style lang="less" scoped>
.table-box {
/deep/.resize-table-th {
position: relative;
.table-draggable-handle {
height: 100% !important;
bottom: 0;
left: auto !important;
right: 0px;
cursor: col-resize;
touch-action: none;
position: absolute;
transform: none !important;
/* :hover 状态下的效果 */
&:hover {
background-color: #808080; /* 悬停时背景颜色变成中灰色 */
color: #fff; /* 改变文字颜色,使其更具对比度 */
transform: scale(1.05); /* 轻微放大,增加交互感 */
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); /* 悬停时增加阴影 */
}
/* :active 状态时 */
&:active {
background-color: #5a5a5a; /* 点击时背景变为深灰色 */
transform: scale(1); /* 点击时恢复原大小 */
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); /* 恢复阴影 */
}
/* :focus 状态时,键盘聚焦时 */
&:focus {
outline: none; /* 去除默认的聚焦框 */
box-shadow: 0 0 0 2px rgba(169, 169, 169, 0.5); /* 聚焦时增加浅灰色外框 */
}
&::after {
content: "|";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #ccc;
}
}
}
}
</style>
2 封装版
子组件:
<template>
<div class="table-box">
<a-table
size="default"
:rowKey="(record, index) => index"
:columns="columns"
:components="components"
:data-source="tableData"
:scroll="{ x: scrollX }"
:pagination="pagination"
:loading="loading"
v-if="columns.length"
>
<!-- 传递给table的slot -->
<template v-for="slot in slotList" v-slot:[slot.name]="text, record, index">
<!-- 父组件中传递进的slot -->
<slot :name="slot.name" :text="text" :record="record" :index="index"></slot>
</template>
</a-table>
</div>
</template>
<script>
import Sortable from 'sortablejs'
import Vue from 'vue'
import VueDraggableResizable from 'vue-draggable-resizable'
Vue.component('vue-draggable-resizable', VueDraggableResizable)
export default {
name: 'ScalableColumn',
data () {
return {
// 表格列拖拽阈值
threshold: {
min: 100,
max: 400
},
components: null
}
},
props: {
size: {
type: String,
default: 'default'
},
loading: {
type: Boolean,
default: false
},
pagination: {
type: Boolean,
default: false
},
slotList: {
type: Array,
default: () => []
},
tableData: {
type: Array,
default: () => []
},
columns: {
type: Array,
default: () => []
}
},
computed: {
// 动态获取scrollX,防止数组固定列的大小变化
scrollX () {
return this.columns.reduce((preVal, curVal) => {
return preVal + curVal.width
}, 0)
}
},
watch: {
columns: {
handler (newVal, oldVal) {
if (newVal.length) {
this.initDrag(this.columns)
this.$nextTick(() => {
this.columnDrop()
})
}
},
deep: true,
immediate: true
}
},
methods: {
initDrag (columns) {
const historyMap = new Map()
const draggingMap = {}
columns.forEach((col) => {
draggingMap[col.key] = col.width
})
const draggingState = Vue.observable(draggingMap)
const resizeableTitle = (h, props, children) => {
let deltaX = 0
let thDom = null
const { key, ...restProps } = props
const col = columns.find((col) => {
const k = col.dataIndex || col.key
return k === key
})
// action || serial 限制操作列不可伸缩
if (!col.width || col.dataIndex === 'action' || col.dataIndex === 'serial') {
return <th {...restProps}>{children}</th>
}
const onDrag = (x) => {
// 在拖拽过程中计算基于初始位置的偏移量
let calculateX = 0
const historyX = historyMap.get(key) || 0
deltaX = historyX && historyX - x
if (historyX && col.width === this.threshold.max) {
// 场景一:拖拽超出最大值
if (historyX > x) calculateX = col.width - deltaX
else calculateX = col.width
} else if (historyX && col.width === this.threshold.min) {
// 场景二:拖拽超出最小值
if (historyX < x) calculateX = col.width - deltaX
else calculateX = col.width
} else {
// 场景三:区间范围内
calculateX = col.width - deltaX
}
draggingState[key] = 0
let width = Math.max(calculateX, this.threshold.min)
width = Math.min(width, this.threshold.max)
col.width = width
historyMap.set(key, x)
}
const onDragstop = (x) => {
historyMap.set(key, x)
draggingState[key] = thDom.getBoundingClientRect().width
}
return (
<th
{...restProps}
v-ant-ref={(r) => (thDom = r)}
width={draggingState[key]}
class="resize-table-th"
>
{children}
<vue-draggable-resizable
key={col.key}
class="table-draggable-handle"
w={10}
x={col.width || draggingState[key]}
z={1}
axis="x"
draggable={true}
resizable={false}
onDragging={onDrag}
onDragstop={onDragstop}
></vue-draggable-resizable>
</th>
)
}
this.components = {
header: {
cell: resizeableTitle
}
}
},
// 列拖拽 暂时未启用 但功能可用 调用须于mounted中挂载
columnDrop () {
var _this = this
const wrapperTr = document.querySelector('.ant-table-wrapper tr')
this.sortable = Sortable.create(wrapperTr, {
handle: '.ant-table-header-column', // 防止拉伸时触发拖拽
animation: 180,
delay: 0,
// 之前调用onEnd方法会出现表格DOM不更新以及表头对不上的情况所以更换为onUpdate方法
onUpdate: function (evt) {
// 修改items数据顺序
var newIndex = evt.newIndex
var oldIndex = evt.oldIndex
const newItem = wrapperTr.children[newIndex]
const oldItem = wrapperTr.children[oldIndex]
// 先删除移动的节点
wrapperTr.removeChild(newItem)
// 再插入移动的节点到原有节点,还原了移动的操作
if (newIndex > oldIndex) {
wrapperTr.insertBefore(newItem, oldItem)
} else {
wrapperTr.insertBefore(newItem, oldItem.nextSibling)
}
// 更新items数组
var item = _this.columns.splice(oldIndex, 1)
_this.columns.splice(newIndex, 0, item[0])
// 下一个tick就会走patch更新
// 如果需要缓存表头,比如缓存到vuex中
// 每次更新调取保存用户接口
// _this.saveColumns("columnChange", _this.columns);
}
})
}
}
}
</script>
<style lang="less" scoped>
.table-box {
/deep/.resize-table-th {
position: relative;
.table-draggable-handle {
height: 100% !important;
bottom: 0;
left: auto !important;
right: 0px;
cursor: col-resize;
touch-action: none;
position: absolute;
transform: none !important;
/* :hover 状态下的效果 */
&:hover {
background-color: #808080; /* 悬停时背景颜色变成中灰色 */
color: #fff; /* 改变文字颜色,使其更具对比度 */
transform: scale(1.05); /* 轻微放大,增加交互感 */
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); /* 悬停时增加阴影 */
}
/* :active 状态时 */
&:active {
background-color: #5a5a5a; /* 点击时背景变为深灰色 */
transform: scale(1); /* 点击时恢复原大小 */
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); /* 恢复阴影 */
}
/* :focus 状态时,键盘聚焦时 */
&:focus {
outline: none; /* 去除默认的聚焦框 */
box-shadow: 0 0 0 2px rgba(169, 169, 169, 0.5); /* 聚焦时增加浅灰色外框 */
}
&::after {
content: "|";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #ccc;
}
}
}
}
</style>
父组件使用:
<template>
<scalable-column :tableData="loadData" :columns="columns" :slotList="slotList" :loading="spinning">
<template v-slot:serial="{ text, record, index }">
{{ query.pageSize * (query.pageNum - 1) + index + 1 }}
</template>
<template v-slot:action="{ record }">
<a @click="handleDetail(record)">详情</a>
</template>
</scalable-column>
</template>
<script>
import ScalableColumn from '@/components/Table/ScalableColumn'
import tableData from './mock/tableData'
export default {
name: 'Demo',
components: {
ScalableColumn
},
data () {
return {
// 表格参数
columns: [],
slotList: [
{ name: 'action' },
{ name: 'serial' }
],
loadData: [],
spinning: false,
query: {
pageSize: 10,
pageNum: 1
}
}
},
created () {
this.initPage().then(() => {
this.initData()
})
},
methods: {
initPage () {
return new Promise((resolve, reject) => {
this.columns = this.creatTableHeader(tableData.data.columns)
resolve()
})
},
// 初始化
initData () {
this.spinning = true
// mock
this.$nextTick(() => {
this.loadData = tableData.data.list
this.spinning = false
})
},
/**
* description:
* @param {Array} columns
* 格式:
* [
* {
* name: 'prop1',
* label: '字段中文名1',
* ...
* },
* {
* name: 'prop2',
* label: '字段中文名2',
* ...
* },
* {
* name: 'prop3',
* label: '字段中文名3',
* ...
* },
* ...
* ]
* @returns {Array}
*/
creatTableHeader (columns) {
const baseColumns = [
{
title: '序号',
dataIndex: 'serial',
scopedSlots: { customRender: 'serial' },
width: 100,
ellipsis: true,
align: 'center',
fixed: 'left'
},
...columns.filter(col => col.name && col.label).map(col => ({
title: col.label,
dataIndex: col.name,
width: 150,
ellipsis: true
})),
{
title: '操作',
dataIndex: 'action',
align: 'center',
width: 100,
ellipsis: true,
scopedSlots: { customRender: 'action' },
fixed: 'right'
}
]
return baseColumns
},
handleDetail (record) {
console.log(record, 'record')
}
}
}
</script>
mock数据(js文件):
const response = {
'data': {
'columns': [
{
'name': 'prop1',
'label': '字段中文名1'
},
{
'name': 'prop2',
'label': '字段中文名2'
},
{
'name': 'prop3',
'label': '字段中文名3'
},
{
'name': 'prop-notfound',
'label': '字段中文名4'
},
{
'name': 'prop5',
'label': '字段中文名5'
}
],
'list': [
{
'prop1': '值1-1',
'prop2': '值2-1',
'prop3': '值3-1'
},
{
'prop1': '值1-2',
'prop2': '值2-2',
'prop3': '值3-2'
},
{
'prop1': '值1-3',
'prop2': '值2-3',
'prop-notfound': '值4-3',
'prop4': '我没有在字段说明里出现, 因此我是多余的不用管',
'prop5': '值5-3'
}
],
total: 3
}
}
export default response