前言:在前端开发中,table组件用的应该是最多的了,尤其是在后台管理系统中表格更是常见,大部分ui组件库都封装了table组件,封装的功能也很完善,我们只要会用就行了,这也导致了大部分人只停留在了会使用,本篇文章就带大家来学习一下table组件的封装,table组件常用功能的封装在上一篇文章已经讲过了,本篇文章主要分享的是表头单元格合并功能。
实现效果
仓库地址
Gitee: gitee.com/sz-liunian/…
了解table
table一般由thead和tbody组成,包含了一个或多个tr、th或td
- tr 标签表示表格里面的一行
- th 标签表示表头里面的一行
- td 标签表示一个单元格
合并单元格使用的是rowspan 和colspan 属性,rowspan实现跨行合并,colspan实现跨列合并
跨行合并
<th rowspan="3"></th>
垂直方向三个单元格合并成一个单元格
跨列合并
<th colspan="3"></th>
水平方向三个单元格合并成一个单元格
分析elementui和ant-design-vue table 组件
elementui table 组件 使用的是 <el-table-column><el-table-column>渲染列
ant-design 使用columns数组定义列
比较两种方式,显而易见使用columns数组定义列更简单、更易维护。
表头单元格合并分析
这里我们也仿照ant-design定义多级表头columns(添加children子列),其他参数定义请看上篇文章
const columns = [
{
title: '日期',
width: 100,
key: 'date',
ellipsis: true,
fixed: 'left',
slotHeader: 'slotDate'
},
{
title: '基本信息',
key: 'baseInfo',
children: [
{
title: '姓名',
width: 95,
key: 'name',
fieldName: 1,
align: 'right',
commaSplice: true,
// formatter
},
{
title: 'other',
key: 'other',
children: [
{
title: '年龄',
width: 95,
key: 'age',
fieldName: 1,
align: 'right'
},
{
title: '公司',
width: 95,
key: 'company',
fieldName: 1,
align: 'right'
},
]
}
]
},
{
title: '岗位',
width: 95,
key: 'work',
fieldName: 1,
align: 'right',
commaSplice: true,
},
{
title: '城市',
width: 130,
key: 'city',
align: 'right',
commaSplice: true,
fieldName: 3,
ellipsis: true
}
]
我们期望渲染的效果
为方便给每一个表头设置colSpan、rowSpan属性,我们可以把数组扁平化处理,并且给每列数组添加hasChildren、deep、keysStr属性。
- hasChildren:有子选项设置为true,反之为false
- deep:代表当前的表头所在的深度(可以理解为行)
- keysStr:值是当前表头key和其所有父表头拼接成的字符串
例如
-
日期没有子选项hasChildren=false, 所在的深度deep =1,keysStr = 'date'
-
基本信息有子选项hasChildren=true,所在的深度deep =1,keysStr = ‘baseInfo’
-
姓名没有子选项hasChildren=false,所在的深度deep =2,keysStr = ‘baseInfo-name’
-
other有子选项hasChildren=true,所在的深度deep =2,keysStr = ‘baseInfo-other’
-
年龄没有子选项hasChildren=false,所在的深度deep =3,keysStr = ‘baseInfo-other-age’
接下来添加方法,处理数据
/**
* 数据处理
* @params columns: 遍历的数组
* @params obj: 处理后的结果
* @params deep: 当前深度
* @params isFirst: 是否是第一次调用函数
* @params currentKey: 从父级到当前子级的key
* @params fixed: 固定列子集添加fixed值
*/
function getColumnList(columns, obj, deep, isFirst, currentKey, fixed) {
columns.forEach(item => {
// 重置深度
if (isFirst) {
deep = 1
}
const ob = {
...item
}
ob.keysStr = isFirst ? ob.key : currentKey + '-' + ob.key
ob.deep = deep
if (fixed) {
ob.fixed = fixed
}
if (!ob.children) {
ob.hasChildren = false
obj.list.push(ob)
} else {
ob.hasChildren = true
deep++
if (deep > obj.maxDeep) {
obj.maxDeep = deep
}
obj.list.push({
...ob,
children: []
})
getColumnList(item.children, obj, deep, false, ob.keysStr, ob.fixed || undefined)
}
})
}
const columnObj = computed(() => {
const obj = {
list: [], // 扁平化数据列表
maxDeep: 1 // 初始值,代表深度为1,表头只占一行
}
getColumnList(props.columns, obj, 1, true, '', undefined)
console.log('list===>', obj.list, obj.maxDeep)
return obj
})
执行计算属性,打印出来的list值
从打印的数据可以看出,表头最大深度为3(表头占3行) 以上数据处理都是为了给每一个表头设置colSpan、rowSpan属性做铺垫,上面我们讲过colSpan属性是跨列合并,rowSpan属性是跨行合并,接下来给每一项设置colSpan、rowSpan属性。
设置colSpan属性
由此图可以看出
- 日期所占的列数 = 当前项keyStr('date')包含当前key('date')属性,并且hasChildren 为false的项的长度 = 1
- 基本信息所占的列数 = 当前项keyStr('baseInfo')包含当前key('baseInfo')属性,并且hasChildren 为false( 姓名、年龄、公司属性值hasChildren为false) 的项的长度 = 3
- other所占的列数 = 当前项keyStr('baseInfo-other')包含当前key('other')属性,并且hasChildren 为false( 年龄、公司属性值hasChildren为false) 的项的长度= 2
- ...
- 可以得出 表头单元格所占列数 = 当前项keyStr包含当前key属性,并且hasChildren为false的所有项的长度
接下来修改方法
const columnObj = computed(() => {
const obj = {
list: [], // 扁平化数据列表
maxDeep: 1 // 最大深度
}
getColumnList(props.columns, obj, 1, true, '', undefined)
obj.list.forEach((item, index) => {
// 当前单元格占得列数 = 当前项keyStr包含当前key属性,并且hasChildren为false的所有项的长度
item.colSpan = obj.list.filter(ite => ite.keysStr.includes(item.key) && !ite.hasChildren).length
})
return obj
})
设置rowSpan属性
由此图可看出
属性 hasChildren为false时
- 日期 没有子选项,属性 hasChildren为false,rowspan = 1,即所占行数为1
- 姓名 没有子选项,属性 hasChildren为false,rowspan = 1,即所占行数为1
- 年龄 没有子选项,属性 hasChildren为false,rowspan = 1,即所占行数为1
- ...
- 可以得出 表头项属性 hasChildren为false时, rowspan = 1,即所占行数为1
属性 hasChildren为true时
- 基本信息 有子选项,属性 hasChildren为true,deep为1,rowspan = 最大深度(maxDeep, 即表头占的总行数) - 当前深度 + 1 = 3 - 1 + 1 = 3
- other 有子选项,属性 hasChildren为true,deep为2,rowspan = 最大深度(maxDeep, 即表头占的总行数) - 当前深度 + 1 = 3 - 2 + 1 = 2
- ...
- 可以得出 表头属性 hasChildren为true时,rowspan = 最大深度(maxDeep, 即表头占的总行数) - 当前深度 + 1
接下来修改计算属性
const columnObj = computed(() => {
const obj = {
list: [], // 扁平化数据列表
maxDeep: 1 // 最大深度
}
getColumnList(props.columns, obj, 1, true, '', undefined)
obj.list.forEach((item, index) => {
// 当前单元格占得列数 = 当前项keyStr包含当前key属性,并且hasChildren为false的所有项的长度
item.colSpan = obj.list.filter(ite => ite.keysStr.includes(item.key) && !ite.hasChildren).length
if (!item.hasChildren) {
// 表头属性 hasChildren为true时,rowspan = 最大深度(maxDeep, 即表头占的总行数) - 当前深度 + 1
item.rowSpan = obj.maxDeep - item.deep + 1 // 根据表格得出规律
} else {
// 表头项属性 hasChildren为false时, rowspan = 1,即所占行数为1
item.rowSpan = 1
}
})
console.log('list===>', obj)
return obj
})
打印一下list数据
修改计算属性columnArr
const columnArr = computed(() => {
const { list, maxDeep } = unref(columnObj)
let obj = fixedArr.reduce((pre, key) => {
pre[key] = []
return pre
}, {})
list.forEach((item, index) => {
// fixed 值为 String
if (item.fixed === 'left' || item.fixed === 'right') {
obj[item.fixed].push(item)
} else {
obj.center.push(item)
}
})
const wt = fixedArr.reduce((pre, key) => {
pre[key] = getColWidth(obj[key], fixedWidthDefault.value)
return pre
}, {})
const arr = []
Object.keys(obj).forEach(key => {
if (obj[key].length > 0) {
arr.push({
titleList: getColumnArr(obj[key], maxDeep),
col: obj[key].filter(item => !item.hasChildren),
fixed: key,
colWidth: wt[key], // 列宽度
// 此处宽度需动态计算,屏幕宽度不一样对应的1rem等于多少px不一样
width: ['left', 'right'].includes(key) ? wt[key] + 'px' : `calc(100% - ${wt.left + wt.right}px)`,
headerSlots: getHeaderSlots(obj[key]),
colSlots: obj[key].filter(item => slots[item.key]),
shadow: key !== 'center' ? isXScroll.value : false
})
}
})
// 最后一列添加类名
const objeFirst = arr[0]
objeFirst.isFirstCol = true
const objeEnd = arr[arr.length - 1]
objeEnd.isLastCol = true
return arr
})
修改之后index.js文件代码如下,由于代码过长,css样式未贴出,详细代码请查看代码仓库
<template>
<div class="table-block">
<div class="table-block-list" ref="tableRef">
<div
v-for="item in unref(columnArr)"
:key="item.fixed"
class="table-block-list-content"
:class="[
item.shadow && `content-${item.fixed}`,
{ 'first-col': showBorder && item.isFirstCol && item.fixed === 'center'},
{ 'list-last': showBorder && item.isLastCol && item.fixed === 'center'}
]"
:style="{
width: item.width
}"
>
<tableTitle
v-if="showTitle"
v-bind="$attrs"
:titleList="item.titleList"
:columns="item.col"
:rowWidth="item.colWidth"
:fixedCol="item.fixed"
:isXScroll="isXScroll"
:showBorder="showBorder"
:maxDeep="unref(columnObj).maxDeep"
:id="item.fixed === 'center' ? rowTitleId : ''"
@scroll="e => onscrollHandler(e, rowListId)"
>
<template v-for="ite in item.headerSlots" #[ite.slotHeader]="{ titleItem }">
<slot :name="ite.slotHeader" :titleItem="titleItem"> </slot>
</template>
</tableTitle>
<list
v-if="showList && data.length > 0"
v-bind="$attrs"
:columns="item.col"
:data="data"
:rowWidth="item.colWidth"
:fixedCol="item.fixed"
:isXScroll="isXScroll"
:showBorder="showBorder"
:fixedWidthDefault="unref(fixedWidthDefault)"
:fixedLeftLength="unref(fixedLeftLength)"
:centerColumnLength="unref(centerColumnLength)"
:id="item.fixed === 'center' ? rowListId : ''"
@scroll="e => onscrollHandler(e, rowTitleId)"
>
</list>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, unref, onMounted, nextTick, useSlots } from 'vue'
import tableTitle from './title.vue'
import list from './list.vue'
const props = defineProps({
// 展示的列
columns: {
type: Array,
default: () => []
},
// 源数据
data: {
type: Array,
default: () => []
},
// 是否显示标题头
showTitle: {
type: Boolean,
default: true
},
// 是否显示列表数据
showList: {
type: Boolean,
default: true
},
// 是否显示边框
showBorder: {
type: Boolean,
default: false
}
})
const defaultConst = reactive({
viewWidth: 360
})
// 屏幕宽度
function getViewWidth(dom: HTMLElement): Number {
if (!dom.value) {
return
}
const tableBlockStyle = window.getComputedStyle(dom.value)
const width = +tableBlockStyle.width.replace('px', '')
const paddingLeft = +tableBlockStyle.paddingLeft.replace('px', '')
const paddingRight = +tableBlockStyle.paddingRight.replace('px', '')
const widthSum = width - paddingLeft - paddingRight
return !isNaN(widthSum) ? widthSum : defaultConst.viewWidth
}
const tableRef = ref(null)
onMounted(() => {
nextTick(() => {
defaultConst.viewWidth = getViewWidth(tableRef)
})
})
const fixedWidthDefault = ref(70) // 单元格默认宽度
// 获取列宽和
function getColWidth(arr: Array, defaultWidth: Number): Number {
return +arr
.reduce((pre, cur) => {
pre += cur.width ? +cur.width : defaultWidth
return pre
}, 0)
.toFixed(2)
}
const rowTitleId = ref('rowTitle' + Math.ceil(Math.random() * 10 ** 10))
const rowListId = ref('rowList' + Math.ceil(Math.random() * 10 ** 10))
// title和list同步滚动
const onscrollHandler = function(e: HTMLElement, id: Number) {
const dom = document.querySelector(`#${id}`)
if (dom) {
dom.scrollLeft = e?.target?.scrollLeft
}
}
const slots = useSlots()
// 获取title 插槽
function getHeaderSlots(arr: Array) {
console.log('slots===>', arr, slots)
return arr.filter(item => item.slotHeader && slots[item.slotHeader])
}
/**
* 数据处理
* @params columns: 遍历的数组
* @params obj: 处理后的结果
* @params deep: 当前深度
* @params isFirst: 是否是第一次调用函数
* @params currentKey: 从父级到当前子级的key
* @params fixed: 固定列子集添加fixed值
*/
function getColumnList(columns, obj, deep, isFirst, currentKey, fixed) {
columns.forEach(item => {
// 重置深度
if (isFirst) {
deep = 1
}
const ob = {
...item
}
ob.keysStr = isFirst ? ob.key : currentKey + '-' + ob.key
ob.deep = deep
if (fixed) {
ob.fixed = fixed
}
if (!ob.children) {
ob.hasChildren = false
obj.list.push(ob)
} else {
ob.hasChildren = true
deep++
if (deep > obj.maxDeep) {
obj.maxDeep = deep
}
obj.list.push({
...ob,
children: []
})
getColumnList(item.children, obj, deep, false, ob.keysStr, ob.fixed || undefined)
}
})
}
// 表头数据
function getColumnArr(list, maxDeep) {
return list.reduce(
(pre, cur) => {
pre[cur.deep - 1].push(cur)
return pre
},
Array.from({ length: maxDeep }).map(() => [])
)
}
const columnObj = computed(() => {
const obj = {
list: [], // 扁平化数据列表
maxDeep: 1 // 最大深度
}
getColumnList(props.columns, obj, 1, true, '', undefined)
obj.list.forEach((item, index) => {
// 当前单元格占得列数 = 当前项keyStr包含当前key属性,并且hasChildren为false的所有项的长度
item.colSpan = obj.list.filter(ite => ite.keysStr.includes(item.key) && !ite.hasChildren).length
if (!item.hasChildren) {
// 表头属性 hasChildren为true时,rowspan = 最大深度(maxDeep, 即表头占的总行数) - 当前深度 + 1
item.rowSpan = obj.maxDeep - item.deep + 1 // 根据表格得出规律
} else {
// 表头项属性 hasChildren为false时, rowspan = 1,即所占行数为1
item.rowSpan = 1
}
})
console.log('list===>', obj)
return obj
})
// 要展示得列数
const columnsList = computed(() => {
const { list } = unref(columnObj)
return list.filter(item => !item.hasChildren)
})
// 左侧固定列
const fixedLeftLength = computed(() => {
const arr = columnsList.value.filter(item => item.fixed === 'left')
return arr.length
})
// 中间列长度
const centerColumnLength = computed(() => {
return columnsList.value.filter(item => !['left', 'right'].includes(item.fixed)).length
})
// 计算行宽
const rowWidth = computed(() => {
return columnsList.value.reduce((pre, cur) => {
pre += cur.width ? +cur.width : fixedWidthDefault.value
return pre
}, 0)
})
// x轴宽度超出屏幕宽度
const isXScroll = computed(() => {
return rowWidth.value - defaultConst.viewWidth > 0
})
// 列list
const fixedArr = ['left', 'center', 'right']
const columnArr = computed(() => {
const { list, maxDeep } = unref(columnObj)
let obj = fixedArr.reduce((pre, key) => {
pre[key] = []
return pre
}, {})
list.forEach((item, index) => {
// fixed 值为 String
if (item.fixed === 'left' || item.fixed === 'right') {
obj[item.fixed].push(item)
} else {
obj.center.push(item)
}
})
const wt = fixedArr.reduce((pre, key) => {
pre[key] = getColWidth(obj[key], fixedWidthDefault.value)
return pre
}, {})
const arr = []
Object.keys(obj).forEach(key => {
if (obj[key].length > 0) {
arr.push({
titleList: getColumnArr(obj[key], maxDeep),
col: obj[key].filter(item => !item.hasChildren),
fixed: key,
colWidth: wt[key], // 列宽度
// 此处宽度需动态计算
width: ['left', 'right'].includes(key) ? wt[key] + 'px' : `calc(100% - ${wt.left + wt.right}px)`,
headerSlots: getHeaderSlots(obj[key]),
colSlots: obj[key].filter(item => slots[item.key]),
shadow: key !== 'center' ? isXScroll.value : false
})
}
})
// 最后一列添加类名
const objeFirst = arr[0]
objeFirst.isFirstCol = true
const objeEnd = arr[arr.length - 1]
objeEnd.isLastCol = true
return arr
})
</script>
title文件 th标签添加 rowspan、colspan 属性
// th标签样式
const thDom = col => {
return col.map((item, index, arr) => {
return (
<th
key={index}
rowspan={item.rowSpan}
colspan={item.colSpan}
class={[
{ 'show-border': props.showBorder },
{ 'border-bottom': props.borderBottom },
{ 'column-last': item.isLastCol }
]}
style={{
'z-index': 'fixed' in item ? '60' : '',
'text-align': item.align || (item.hasChildren ? 'center' : 'left'),
background: props.headerBackground || undefined
}}
onClick={ e => changeSort(item)}
>{tableTitleCell(item)}</th>
)
})
}
测试组件
<template>
<navbar
@backHandler="backHandler"
navTitle="单元格可以合并的表格"
></navbar>
<div class="page-home">
<!-- showBorder -->
<table-list
:columns="titleList"
:data="dataList"
showBorder
>
<template #slotDate="{item, titleItem}">
<div class="user-info">
<span
class="ellipsis"
>{{ titleItem.title }}</span>
</div>
</template>
</table-list>
</div>
</template>
<script setup lang="ts">
import Mock from 'mockjs'
import { useRouter } from 'vue-router'
const router = useRouter()
import tableList from '@/components/tablePro/index.vue'
import { setData } from '@/utils/getTableData.ts'
const titleList = [
{
title: '日期',
width: 100,
key: 'date',
ellipsis: true,
fixed: 'left',
slotHeader: 'slotDate'
},
{
title: '基本信息',
key: 'baseInfo',
children: [
{
title: '姓名',
width: 95,
key: 'name',
sort: 2,
fieldName: 1,
align: 'right',
commaSplice: true,
// formatter
},
{
title: 'other',
key: 'other',
children: [
{
title: '年龄',
width: 95,
key: 'age',
sort: 2,
fieldName: 1,
align: 'right'
},
{
title: '公司',
width: 95,
key: 'company',
sort: 2,
fieldName: 1,
align: 'right'
},
]
}
]
},
{
title: '岗位',
width: 95,
key: 'work',
sort: 2,
fieldName: 1,
align: 'right',
commaSplice: true,
},
{
title: '城市',
width: 130,
key: 'city',
align: 'right',
commaSplice: true,
sort: 0,
fieldName: 3,
ellipsis: true,
// fixed: 'right',
}
]
const dataObj = {
date: '@date',
name: "@cname",
age: '@integer(24, 65)',
company: '@pick(["阿里", "腾讯", "字节"])',
work: '@pick(["产品", "运营", "ui", "前端", "后端", "测试", "运维"])',
city: '@city()'
}
const dataList = setData(Mock.mock('@natural(50, 100)'), dataObj)
</script>
实现效果
组件参数
传参
| 参数 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| showTitle | 是否展示表头 | boolean | true |
| showList | 是否展示列表 | boolean | true |
| columns | 表格列的配置描述见下表 | array | [] |
| data | 列表数据 | array | [] |
| showBorder | 是否显示单元格边框 | boolean | false |
| isHeaderFixed | 表头是否吸顶 | boolean | true |
| headerFixedTopValue | 页面划动,表头吸顶top值 | Number | 88 (单位px) |
| height | 行高度 | number、string | 50(单位px) |
columns配置
| 参数 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| title | 表头展示内容 | string (支持传html) | '' |
| width | 单元格宽度 | number | 70(单位px) |
| widthAuto | 宽度自适应,会把剩余宽度按比例分配给设置了该字段的列 | boolean | false |
| key | 当前列要展示的字段 | string 每一个 表头选项,key要保持惟一 | -(无默认值) |
| fixed | 列是否固定,可选 'left' 'right' | string | -(无默认值) |
| align | 单元格对齐方式, 可选 left、center、 right | string | left |
| ellipsis | 超过宽度将自动省略 | boolean | false |
| slotHeader | title 插槽key,可使用此字段添加插槽 | string | - |
| headerBackground | 表头背景色 | string | '#fff' |
| formatter | 列表单元格格式化函数 | Function(h, row, column, index) | - |
| util | 单位 | number、string |
插槽: 表头插槽使用 columns 属性 slotHeader自定义表头内容 内容插槽使用 columns 属性key自定义单元格内容
事件
| 参数 | 说明 | 类型 |
|---|---|---|
| handlerClick | row 点击事件 | Function(row, index) |
| handlerCellClick | cell 点击事件 | Function(row, column) |
总结
本文主要讲解了基于原生table实现表头合并,分析了单元格合并原理以及单元格所占的行数和列数,推导出表头单元格rowspan和colspan的计算方法,另外还实现了表头置顶、表头自定义功能,由于篇幅过长没有叙述,具体实现方法可以去gitee仓库看一下源码,后续代码也会传到git, 表格主体单元格合并后面会单独写一篇文档进行讲解。
如果觉得文章对你有帮助,也欢迎一键三连👏👏👏,你的鼓励是支持我持续创作的动力~